diff --git a/README.md b/README.md
index ff3279d..4dd5da8 100644
--- a/README.md
+++ b/README.md
@@ -57,11 +57,11 @@ El alcance se estructuró en **seis fases incrementales**, cada una con objetivo
```text
Fase 1 ──► Core Autenticación · Perfil · Partidas · Descubrir
-Fase 2 ──► Resultados Notificaciones · Validación de marcadores · Reportes
-Fase 3 ──► Administración Panel de moderación · Analíticas · Auditoría
-Fase 4 ──► Torneos Cuadros eliminatorios · Parejas · Sincronización en tiempo real
-Fase 5 ──► Marcador Conteo en vivo local · Acceso sin registro
-Fase 6 ──► Ligas y stats Ligas round-robin / Elo · Estadísticas · Logros · Clasificación
+ Fase 2 ──► Resultados Notificaciones · Validación de marcadores · Reportes
+ Fase 3 ──► Administración Panel de moderación · Analíticas · Auditoría
+ Fase 4 ──► Torneos Cuadros eliminatorios · Parejas · Sincronización en tiempo real
+ Fase 5 ──► Marcador Conteo en vivo local · Acceso sin registro
+ Fase 6 ──► Ligas y stats Ligas round-robin / Elo · Estadísticas · Logros · Clasificación
```
---
@@ -185,7 +185,7 @@ La aplicación sigue una **arquitectura cliente-servidor** con separación clara
### 🔍 Descubrir
-- Listado de partidas, torneos y **ligas** públicas con ordenación por proximidad temporal
+- Listado de partidas, torneos y **ligas** públicas y **privadas con contraseña** (aparecen en Descubrir; la contraseña desbloquea la vista)
- Filtros por tipo, ciudad, fecha, plazas libres, visibilidad y estado
- Búsqueda por texto y paginación eficiente
- Actualización en tiempo real sin recarga manual
diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md
index 49003b7..e417080 100644
--- a/REQUIREMENTS.md
+++ b/REQUIREMENTS.md
@@ -17,7 +17,7 @@ App móvil para jugadores de mus en España que permite encontrar contrincantes
- **Plantilla mixta (may. 2026):** en crear/editar se pueden añadir compañeros/rivales **por nombre** además de cuentas registradas; las plazas (UI, explore y cron) cuentan texto + confirmados (máx. 2 por equipo). El creador puede registrar marcador **sin validación rival** solo si no hay otros participantes con cuenta y la partida está **`in_progress`** (`record_match_result_direct`). Tras aprobar un resultado rival, un trigger en BD confirma el resultado y finaliza la partida (`018`).
- **Eliminación de cuenta (may.–ago. 2026):** derecho de supresión RGPD vía Edge Function `delete-account`. Se borran auth, perfil, avatar y datos personales (reportes, cola de notificaciones). El **historial compartido se anonimiza**, no se elimina: referencias de partidas, **ligas y torneos** (creador, parejas, retos, grants) pasan al perfil interno **Usuario eliminado** (sentinel) o a texto «Usuario eliminado» (mig. `023`–`025`, `105`). CORS de la función: orígenes de producción + loopback local (`localhost` / `127.0.0.1`) para desarrollo web.
- **Recuperación de contraseña (jul. 2026):** el email de reset redirige a `jugamus://auth/update-password` (debe estar en Redirect URLs de Supabase). Pantalla dedicada para nueva contraseña; errores visibles en web (sin depender de `Alert.alert`); tras éxito, cierre de sesión y CTA a login. No reutilizar la misma contraseña (`same_password` → 422).
-- **Notificaciones en perfil (may./jul. 2026):** preferencias **push** («Todas») y por **evento** en perfil; sin notificaciones por correo; enlaces legales (términos, privacidad) en la misma pantalla. Eventos: unión; partida/torneo inicio, edición y cancelación (separados); resultado pendiente de validar; resultado pendiente de enviar (aviso ~5 h en curso); recordatorios 24 h y/o 2 h antes (chips multi-selección). `enqueue_notification` y `process-notifications` respetan `notify_push` + `notify_on_*` (migraciones `077`–`079`).
+- **Notificaciones en perfil (may./jul./ago. 2026):** preferencias **push** («Todas») y por **evento** en perfil; sin notificaciones por correo; enlaces legales (términos, privacidad) en la misma pantalla. Eventos: unión; partida/torneo inicio, edición y cancelación (separados); resultado pendiente de validar; resultado pendiente de enviar (aviso ~5 h en curso); recordatorios 24 h y/o 2 h antes (chips multi-selección); **solicitudes de amistad** e **invitaciones a partidas** (`notify_on_friend_request` / `notify_on_match_invitation`, mig. `110`). `enqueue_notification` y `process-notifications` respetan `notify_push` + `notify_on_*` (migraciones `077`–`079`, `110`).
- **Branding (may. 2026):** icono y splash con diseño minimalista de baraja española (basto); color de fondo `#1a5f4a` en splash e icono adaptativo Android.
- **UI Ultra Limpio (may. 2026):** rediseño visual con tokens en `src/theme/` (fondo blanco, verde `#1A5F4A`, tipografía DM Sans). Listas principales (Mis partidas, Descubrir) con filas y punto de estado; previews con `ciudad · lugar`; cabecera Mis partidas sin contador de activas; FAB speed-dial encima de la tab bar; tab bar activa en verde brand.
- **Torneos (may. 2026):** eliminación directa con parejas mixtas (registradas + texto). Partidos del cuadro reutilizan `matches` (`tournament_id`, metadatos de ronda). FAB speed-dial: crear partida, torneo o liga. Cuadro visual en canvas SVG con resultados por enfrentamiento. Byes automáticos si faltan parejas para potencia de 2; **partidas bye no cuentan** en historial ni analíticas admin. Avance de ronda al confirmar resultado (incl. propagación bye y relleno parcial del cuadro); partido siguiente con `start_at = NOW()`. Torneo pasa a `finished` al cerrar la final (y el partido de 3º/4º si está activado). Organizador puede ser árbitro (sin jugar); registra resultado directo si todos los jugadores del partido son texto. **Un jugador registrado solo en una pareja** por torneo. Descubrir: filtro partidas/torneos/ligas; partidas del cuadro o de liga no listadas ni unibles manualmente como partidas sueltas. Detalle y tarjetas muestran organizador y `ciudad · lugar`. Lugar en formularios: nombre obligatorio o «Lugar por definir». Historial de perfil colorea victoria/derrota. Sincronización multi-dispositivo: Supabase Realtime (`058`) en `matches`, participantes, resultados, torneos y parejas; invalidación React Query en Descubrir, Mis partidas, historial y ficha/cuadro de torneo (sin polling 30 s en detalle). **Edición de parejas (may.–jun. 2026):** organizador o miembros de la pareja pueden editar nombre y plazas texto en inscripción; solo el organizador elimina parejas (`062`). Opcional **3º y 4º puesto** entre perdedores de semifinales (`064`). En la ficha de un partido del cuadro, enlace **🏆 Ir al torneo** al detalle del torneo.
@@ -34,15 +34,16 @@ App móvil para jugadores de mus en España que permite encontrar contrincantes
- **Torneos — parejas y 3º puesto (jun. 2026):** cualquier miembro de la pareja puede editarla en inscripción (`062`); eliminar sigue siendo solo organizador. Al editar, **no se pueden quitar** jugadores de texto (solo renombrar; `069`). Opción **3º y 4º puesto** al crear torneo: partido entre perdedores de semifinales (`064`); torneo no finaliza hasta cerrar final y dicho partido si aplica.
- **Partidas y torneos privados (jun. 2026):** visibilidad `private` con contraseña del organizador/creador. Aparecen en Descubrir (filtro Privadas). La contraseña **desbloquea la vista** (plantel/cuadro); unirse sigue con el flujo normal (partida) o parejas (torneo). Migraciones `066`–`068`.
- **Invitaciones WhatsApp HTTPS (jul. 2026):** compartir ficha de partida o torneo por WhatsApp con enlace `https://musapp-731e1.web.app/m|t/{id}` (Firebase Hosting + App/Universal Links). Cualquier usuario que pueda ver la ficha puede compartir; privadas piden contraseña al abrir; unirse requiere registro. Sin app → redirect a tienda. EAS: `EXPO_PUBLIC_INVITE_HOST`.
-- **Sesión Auth (jul. 2026):** JWT/sesión local persistida se revalida con `getUser()` al arranque y al volver a primer plano; si el refresh falla (salvo error de red), logout local y aviso «Tu sesión ha caducado…». Timeout por background largo sigue vigente.
+- **Sesión Auth (jul.–ago. 2026):** JWT/sesión local persistida se revalida con `getUser()` al arranque y al volver a primer plano; si el refresh falla (salvo error de red), logout local y aviso «Tu sesión ha caducado…». Timeout por background largo: **6 h** (`BACKGROUND_SESSION_TIMEOUT_MS` en `sessionBackground.ts`).
- **Torneos — auto-cancel sin cuadro (jul. 2026):** en `registration` pasado `start_at` sin `bracket_generated_at` → `cancelled` (+ notificación al organizador). Crear/editar: título/ciudad/lugar opcionales (defaults «Torneo» / «Ciudad por definir»). Aviso al guardar. Migraciones `070`/`071`.
- **Torneos — inscripción y cancelación (jul. 2026):** `entry_fee` en torneo (default 0); flag `entry_fee_paid` por pareja; el organizador puede **cancelar** un torneo (`cancel_tournament`, mig. `073`–`076`). Mis partidas lista torneos donde el usuario organiza o juega (partidas de cuadro solo si participa).
-- **PostHog producto (jul. 2026):** eventos `user_signed_up`, `match_created`, `match_joined`, `match_completed` (este último solo al pasar a `finished`, idempotente por `match_id`). KPIs de panel deben usar estos eventos / `Application Opened`, no `$pageview`.
+- **PostHog producto (jul./ago. 2026):** eventos `user_signed_up`, `match_created`, `match_joined`, `match_completed` (este último solo al pasar a `finished`, idempotente por `match_id`); `friend_request_sent`, `friend_request_accepted` (auto-aceptación), `match_invite_sent`, `match_invite_accepted` (v1.8). KPIs de panel deben usar estos eventos / `Application Opened`, no `$pageview`.
- **Torneos — cuadro interactivo (jul. 2026):** cada pareja en tarjeta propia; tap en pareja para registrar resultado y avanzar (modal rápido o ficha de partido). Etiquetas de ronda (cuartos, semifinal, final). Confirmación destructiva al organizar cuadro. Al cancelar torneo se cancelan **todos** los partidos del cuadro (`082`). Fix auto-cancel al poblar la final (`081`). Notificación «Validar resultado» solo si el resultado queda `pending_validation` (`083`).
-- **Versión app (ago. 2026):** **1.7.1** (`app.json`, `package.json`). Hotfix de calidad: suite de characterization tests (services/hooks/utils/lib), umbrales de cobertura y proceso TDD (`docs/testing.md`).
+- **Amigos e invitaciones a partidas (ago. 2026, v1.8):** solicitudes de amistad con mensaje opcional (máx. 200) desde perfil ajeno o búsqueda por nombre; cooldown tras rechazo reciente; lista de amigos/solicitudes en perfil propio. Invitar amigos a pareja/rival al crear o editar equipo (solo partida standalone `planned`/`in_progress` del creador); pendientes ocupan plaza; compartir vía modal en ficha tras crear. Mis Partidas: Invitaciones; ficha: banner Aceptar/Rechazar. Rechazar en partida iniciada/finalizada (o con resultado confirmado) cancela y voidea resultado; rivales invitados fuerzan `pending_validation`. Migraciones `108`–`116`.
+- **Versión app (ago. 2026):** **1.8.0** (`app.json`, `package.json`). Incluye hotfix de calidad v1.7.1 (characterization tests, umbrales de cobertura ≥60%, proceso TDD en `docs/testing.md`).
- **Security hardening ligas/stats (ago. 2026, mig. `106`):** `process_league_lifecycle` solo vía pg_cron (REVOKE a PUBLIC/authenticated). `enqueue_player_stats_recompute` no ejecutable por clientes. `get_player_stats` recalcula ELO/agregados solo para el propio usuario o admin; el resto lee stats cacheadas (mitiga amplificación cross-user).
- **UI responsive (jul. 2026):** helpers `useResponsiveLayout` / `ScrollableModalBody` para escalado tipográfico y modales con scroll seguro en pantallas pequeñas.
-- **Crear partida — UX (jul.–ago. 2026):** título, ciudad y lugar **opcionales** al crear (defaults: «Partida», «Ciudad por definir», «Lugar por definir» si vacíos). Sin toggle «Lugar por definir» en creación. Fecha/hora por defecto **+10 min** respecto a ahora. Botón ✕ cierra a **Mis partidas** (no `back` a Descubrir). Sin autofocus/teclado al abrir el formulario. Etiquetas de campo en negrita; sin asteriscos ni «(opcional)» en labels de crear/editar. **Empezar partida:** sin modal de confirmación (sí aviso si plantilla incompleta). Edición de partida conserva validación anterior. Aviso si plantilla incompleta (auto-cancel al llegar `start_at`). **Partida ya jugada (ago. 2026):** si `start_at` es anterior a ahora, el formulario muestra el marcador al final (casillas vacías; nombres de equipo derivados como en ficha); exige plantilla completa; al crear registra resultado directo (`record_match_result_direct`, mig. `085` admite también `finished_no_result`) y abre la partida `finished`. Orden de pareja/equipo en formularios y tarjetas: **integrantes primero, nombre después**.
+- **Crear partida — UX (jul.–ago. 2026):** título, ciudad y lugar **opcionales** al crear (defaults: «Partida», «Ciudad por definir», «Lugar por definir» si vacíos). Sin toggle «Lugar por definir» en creación. Fecha/hora por defecto **+10 min** respecto a ahora. Botón ✕ cierra a **Mis partidas** (no `back` a Descubrir). Sin autofocus/teclado al abrir el formulario. Etiquetas de campo en negrita; sin asteriscos ni «(opcional)» en labels de crear/editar. **Empezar partida:** sin modal de confirmación (sí aviso si plantilla incompleta). Edición de partida conserva validación anterior. Aviso si plantilla incompleta (auto-cancel al llegar `start_at`). **Partida ya jugada (ago. 2026):** si `start_at` es anterior a ahora, el formulario muestra el marcador al final (casillas vacías; nombres de equipo derivados como en ficha); exige plantilla completa (nombres o amigos invitados); al crear registra resultado (directo o `pending_validation` si hay rivales invitados) y abre la partida. Orden de pareja/equipo en formularios y tarjetas: **integrantes primero, nombre después**. **Invitar amigos (v1.8):** selector de amigos como compañero/rival al crear; pestaña «Añadir amigo» en editar pareja.
- **Locales nativos (ago. 2026):** `locales/es.json` anida claves bajo `ios` (SDK 54+) para no generar `ExtraTranslation` en Android `lintVitalRelease`.
- **Apple Sign In (jun. 2026):** nombre de perfil legible con `fullName` de Apple y backfill para relay emails (`060`–`061`).
- **Plantilla y cron (may. 2026):** unirse solo en `planned`. Al llegar `start_at`, cron promueve a `in_progress` solo con roster completo (4 plazas); si no, `cancelled`. Partida con hora actual y plantilla llena queda `in_progress` al crear/unirse. Listas y fichas de partida/torneo se actualizan vía Realtime + refetch al foco.
@@ -113,7 +114,8 @@ Solo dos roles en el MVP:
- Foto de perfil (opcional, comprimida automáticamente a ≤ 500KB)
- Preferencias de notificación en pantalla de perfil:
- **Todas** (`notify_push`): master que enciende/apaga todos los eventos
- - **Por evento:** unión; inicio / edición / cancelación de partida o torneo; resultado pendiente de validar; resultado pendiente de enviar; recordatorios 24 h y 2 h (selección independiente) — columnas `notify_on_*` (migraciones `022`, `078`, `079`)
+ - **Por evento:** unión; inicio / edición / cancelación de partida o torneo; resultado pendiente de validar; resultado pendiente de enviar; recordatorios 24 h y 2 h (selección independiente); solicitudes de amistad; invitaciones a partidas — columnas `notify_on_*` (migraciones `022`, `078`, `079`, `110`)
+- **Amigos (v1.8):** sección desplegable en perfil propio (lista, solicitudes enviadas/recibidas, eliminar amigo); botón persona+plus en perfil ajeno para enviar solicitud con mensaje opcional (mig. `108` / `110`)
- Enlaces a términos y política de privacidad desde el perfil
- **Perfil de otro usuario (solo lectura):** nombre, ciudad, teléfono según permisos del servidor e historial de partidas visibles; navegación desde participantes registrados en detalle de partida
- **El teléfono solo es visible para participantes de la misma partida confirmada** (también en perfil ajeno vía RPC)
@@ -158,9 +160,18 @@ Solo dos roles en el MVP:
- Cada usuario tiene acceso a su historial de partidas pasadas
+**Amigos e invitaciones (v1.8)**
+
+- Al crear o editar la pareja de una partida standalone (`planned`/`in_progress`, creador), se pueden invitar amigos confirmados a equipo A o B (capacidad acotada; pestaña «Añadir amigo» solo si aplica)
+- Las invitaciones pendientes ocupan plaza de roster (la partida `planned` empieza a `start_at` aunque queden pendientes); al aceptar se revalida capacidad
+- Mis Partidas muestra sección «Invitaciones»; la ficha muestra banner Aceptar/Rechazar y modal de compartir (WhatsApp/enlace)
+- Rechazar (o cancelar) una invitación en partida `in_progress`/`finished`/`finished_no_result` (o con resultado confirmado) cancela la partida y voidea el resultado
+- Rivales invitados (pendientes o aceptados) fuerzan resultado `pending_validation` (también en partidas pasadas)
+- Solicitudes de amistad: mensaje ≤200; cooldown tras rechazo reciente; búsqueda por nombre (`111`); eliminar amigo cancela invitaciones pendientes entre el par (`114`)
+
### F5 - Descubrir y filtrar (Fase 1 + 6)
-- Listado de partidas, torneos y ligas públicas ordenado por fecha
+- Listado de partidas, torneos y ligas **públicas y privadas con contraseña** ordenado por fecha (filtro de visibilidad; las privadas aparecen en Descubrir y requieren contraseña para desbloquear la vista)
- Filtros: tipo (partidas / torneos / ligas), fecha, ciudad/pueblo, plazas libres, visibilidad, estado
- Búsqueda por texto en el campo título
- Paginación de 20 elementos por página
@@ -182,6 +193,7 @@ Solo dos roles en el MVP:
- Push cuando alguien se une a tu partida
- Push cuando la partida es editada o cancelada
- Push al ser expulsado o cuando alguien abandona
+- Push de solicitud de amistad e invitación a partida (preferencias propias; v1.8 / mig. `110`)
- Preferencias granulares en cliente (push + evento); `enqueue_notification` y Edge Function `process-notifications` respetan `notify_push` y `notify_on_*`
**Notificaciones automáticas (Fase 2)**
@@ -236,7 +248,7 @@ Solo dos roles en el MVP:
- Open Elo: retos entre parejas; ranking Elo de pareja; fecha de fin opcional
- Partidos de liga reutilizan `matches` con `league_id`; mismos flujos de resultado/marcador que partidas normales cuando aplica
- Privadas con contraseña + grants; admin puede leer sin contraseña
-- Listado en Descubrir (filtro Ligas); invitaciones/compartir según visibilidad
+- Listado en Descubrir (filtro Ligas): ligas públicas y privadas con contraseña; la contraseña desbloquea la vista
- Lifecycle y recordatorios vía cron (`process_league_lifecycle`, mig. `102`/`106`)
### F11 - Estadísticas, logros y clasificación (Fase 6)
@@ -621,7 +633,7 @@ Supabase (PostgreSQL + Auth + Storage)
- CA_AUTH2: Un usuario puede autenticarse con Google
- CA_AUTH3: Un usuario puede autenticarse con Apple ID
- CA_AUTH4: Un usuario puede recuperar su contraseña por email, fijar una nueva en `auth/update-password` e iniciar sesión después
-- CA_AUTH5: La sesión persiste entre cierres de la app (refresh token)
+- CA_AUTH5: La sesión persiste entre cierres de la app (refresh token); tras ≥6 h en segundo plano, al volver a primer plano se cierra la sesión con aviso
- CA_AUTH6: Un usuario con `status: suspended` no puede iniciar sesión
- CA_AUTH7: Un usuario puede eliminar su cuenta; se borra la identidad y se anonimiza su huella en partidas, ligas y torneos (perfil sentinel «Usuario eliminado»)
@@ -630,7 +642,9 @@ Supabase (PostgreSQL + Auth + Storage)
- CA_PROF1: El teléfono se valida en formato E.164 antes de guardar
- CA_PROF2: La foto de perfil se redimensiona automáticamente a ≤ 500KB al subir
- CA_PROF3: El teléfono solo aparece visible para participantes de la misma partida confirmada
-- CA_PROF4: Las preferencias de notificación (canal y por evento) se editan desde el perfil y persisten en `profiles`
+- CA_PROF4: Las preferencias de notificación (canal y por evento, incl. amistad e invitaciones a partida) se editan desde el perfil y persisten en `profiles`
+- CA_PROF8: Un usuario puede enviar, aceptar, rechazar o cancelar solicitudes de amistad y eliminar un amigo confirmado
+- CA_PROF9: Solo amigos confirmados pueden recibir invitaciones a partidas standalone (pareja o rival)
**Partidas**
@@ -715,7 +729,7 @@ Supabase (PostgreSQL + Auth + Storage)
**Contacto**
-- CA_CONTACT1: Con teléfono visible en perfil ajeno, en iOS/Android se puede abrir la ficha nativa de nuevo contacto
+- CA_CONTACT1: Con teléfono visible en perfil ajeno, en iOS/Android se puede abrir la ficha nativa de nuevo contacto; en web se puede copiar el número al portapapeles
---
@@ -723,19 +737,19 @@ Supabase (PostgreSQL + Auth + Storage)
Las siguientes funcionalidades están **fuera del MVP** y serán evaluadas para versiones futuras:
-| Funcionalidad | Motivo del aplazamiento |
-| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Ligas y clasificaciones | ~~Pospuesta~~ **Implementado (v1.7 / Fase 6):** round-robin, open Elo, stats, badges y leaderboard (mig. `086`+) |
-| Chat interno | Requiere moderación constante; se delega en WhatsApp |
-| Geolocalización GPS exacta | Sacrifica privacidad sin aportar valor suficiente en el MVP |
-| Autenticación con Apple ID | ~~Pospuesta~~ **Movida al MVP** (obligatorio para App Store) |
-| Soporte multilenguaje completo (i18n) | No prioritario hasta tener masa crítica de usuarios |
-| Notificaciones personalizadas avanzadas | ~~Fase 3~~ **Implementado:** toggles por canal y evento en perfil; `enqueue_notification` / `process-notifications` respetan `notify_on_*` (mig. `077`–`079`) |
-| Sistema de reputación y valoraciones | Necesita masa crítica de usuarios para ser útil |
-| Integración con redes sociales | No crítico para el MVP |
-| Modo offline/sincronización | Alta complejidad de sincronización; conexión asumida |
-| Verificación de teléfono por SMS | Añade coste (Twilio ~0.05€/SMS) y complejidad; valorar en v2 |
-| Compartir en otras redes (no WhatsApp) | WhatsApp HTTPS invites en producto (jul. 2026); otras redes fuera de alcance |
+| Funcionalidad | Motivo del aplazamiento |
+| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Ligas y clasificaciones | ~~Pospuesta~~ **Implementado (v1.7 / Fase 6):** round-robin, open Elo, stats, badges y leaderboard (mig. `086`+) |
+| Chat interno | Requiere moderación constante; se delega en WhatsApp |
+| Geolocalización GPS exacta | Sacrifica privacidad sin aportar valor suficiente en el MVP |
+| Autenticación con Apple ID | ~~Pospuesta~~ **Movida al MVP** (obligatorio para App Store) |
+| Soporte multilenguaje completo (i18n) | No prioritario hasta tener masa crítica de usuarios |
+| Notificaciones personalizadas avanzadas | ~~Fase 3~~ **Implementado:** toggles por canal y evento en perfil (incl. amistad e invitaciones a partida, mig. `110`); `enqueue_notification` / `process-notifications` respetan `notify_on_*` (mig. `077`–`079`, `110`) |
+| Sistema de reputación y valoraciones | Necesita masa crítica de usuarios para ser útil |
+| Integración con redes sociales | No crítico para el MVP |
+| Modo offline/sincronización | Alta complejidad de sincronización; conexión asumida |
+| Verificación de teléfono por SMS | Añade coste (Twilio ~0.05€/SMS) y complejidad; valorar en v2 |
+| Compartir en otras redes (no WhatsApp) | WhatsApp HTTPS invites en producto (jul. 2026); otras redes fuera de alcance |
---
diff --git a/TASKS.md b/TASKS.md
index 0d764c3..112cdce 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -1,6 +1,6 @@
# Tareas - jugaMUS
-> Actualizado: 11/08/2026 (hotfix v1.7.1: suite TDD + coverage gates)
+> Actualizado: 12/08/2026 (merge main hotfix v1.7.1; timeout sesión 6 h; invites `117`)
> Metodología: Kanban personal. Actualizar al inicio y al final de cada sesión de trabajo.
---
@@ -26,6 +26,7 @@
| Minor partidas pasadas | Completada | Crear partida con resultado si `start_at` pasado; orden parejas; locales; v1.5.0 |
| Release 1.7 | Completada | Contactos, stats/ELO/badges, ligas, CI permissions, deps; v1.7.0 |
| Hotfix tests TDD | Completada | Characterization tests + coverage gates + docs; v1.7.1 |
+| Release 1.8 | Completada | Amigos, invitaciones a partidas, prefs notif. friend/invite; v1.8.0 |
---
@@ -440,6 +441,7 @@ Las notificaciones push **no** funcionan en Expo Go; hace falta un build con cre
### Sesión, torneos, analytics y hotfix (v1.2.1) — 23/07/2026
- [x] Sesión caducada tras días sin abrir: `validateAuthSession` + `ensureSessionValid`; logout automático y mensaje en login; harden HMR
+- [x] Timeout al volver de segundo plano: **6 h** (`BACKGROUND_SESSION_TIMEOUT_MS`; antes 10 min)
- [x] Torneos: auto-cancel en `start_at` sin cuadro organizado (migraciones `070`/`071`); título/ciudad/lugar opcionales; aviso al guardar; «Organizar cuadro» deshabilitado con menos de 2 parejas completas
- [x] Partidas: aviso post-crear si plantilla incompleta (auto-cancel al `start_at`)
- [x] PostHog: eventos funnels 1–2 + idempotencia `match_completed` + tests
@@ -544,6 +546,27 @@ Las notificaciones push **no** funcionan en Expo Go; hace falta un build con cre
- [x] Versión app → **1.7.1** (`app.json`, `package.json`)
- [x] Commit + PR `hotfix/v1.7.1` → `main` (revisor/asignado Japegomez)
+### Release 1.8 — amigos e invitaciones a partidas (ago. 2026)
+
+- [x] Sistema de amigos: solicitudes con mensaje, aceptar/rechazar/cancelar, eliminar amistad — mig. `108` / `110`
+- [x] Invitaciones a partidas (pareja/rival) al crear o editar equipo; pendientes ocupan plaza de roster — mig. `109`
+- [x] Mis Partidas: sección Invitaciones; ficha: banner Aceptar/Rechazar + deeplink WhatsApp
+- [x] Resultado `pending_validation` cuando hay rivales invitados (también en partidas pasadas); rechazo cancela partida iniciada/finalizada
+- [x] Preferencias push: `notify_on_friend_request` / `notify_on_match_invitation` (perfil + `enqueue_notification` + Edge Function) — mig. `110`
+- [x] PostHog: `friend_request_sent`, `friend_request_accepted`, `match_invite_sent`, `match_invite_accepted`
+- [x] Versión app → **1.8.0** (`app.json`, `package.json`)
+- [x] Búsqueda de usuarios por `display_name` (RPC) — mig. `111`
+- [x] Listado de mis invitaciones excluye canceladas — mig. `112`
+- [x] Rechazar invitación con resultado confirmado: void + cancelar partida — mig. `113`
+- [x] Review follow-ups amigos/invites: cooldown tras rechazo, mensaje ≤200, capacidad al aceptar, auth en `list_match_invitations`, `match_effective_roster_filled` SECURITY DEFINER, grants `badge_showcase`, cancel invites al eliminar amigo — mig. `114`/`115`
+- [x] `send_friend_request` devuelve status; `get_profile_with_phone` sin prefs/badge sensibles — mig. `116`
+- [x] Review follow-ups 2: escape ILIKE + trgm, `match_status`, `friend_request_accepted`, unique_violation, slim phone RPC — mig. `117` + Edge `process-notifications`
+- [x] UX create/detail: capacidad de invites, `isSubmitting` de punta a punta, modal compartir en ficha, invite tab solo en partida standalone editable
+- [x] Timeout sesión en segundo plano → **6 h** (`BACKGROUND_SESSION_TIMEOUT_MS`)
+- [x] Contrato Descubrir: públicas + privadas con contraseña (docs README/REQUIREMENTS); CA_CONTACT1 incluye copia en web
+- [x] Documentación alineada (`REQUIREMENTS.md`, `TASKS.md`, `README.md`)
+- [x] Merge `main` (hotfix v1.7.1) en `develop` para desbloquear PR #158
+
---
## UI — Rediseño Ultra Limpio (may. 2026)
diff --git a/app.json b/app.json
index 93b4887..ce6607d 100644
--- a/app.json
+++ b/app.json
@@ -3,7 +3,7 @@
"name": "jugaMUS",
"slug": "musapp",
"scheme": "jugamus",
- "version": "1.7.1",
+ "version": "1.8.0",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
diff --git a/package-lock.json b/package-lock.json
index 416da8d..74823cc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "jugamus",
- "version": "1.7.1",
+ "version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jugamus",
- "version": "1.7.1",
+ "version": "1.8.0",
"dependencies": {
"@expo-google-fonts/dm-sans": "^0.4.2",
"@expo/vector-icons": "^15.1.1",
@@ -73,6 +73,7 @@
"jest-expo": "~54.0.17",
"lint-staged": "^16.4.0",
"prettier": "^3.9.6",
+ "react-test-renderer": "19.1.0",
"typescript": "~5.9.2"
}
},
diff --git a/package.json b/package.json
index cb24239..7ce3316 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "jugamus",
- "version": "1.7.1",
+ "version": "1.8.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
@@ -82,12 +82,14 @@
"jest-expo": "~54.0.17",
"lint-staged": "^16.4.0",
"prettier": "^3.9.6",
+ "react-test-renderer": "19.1.0",
"typescript": "~5.9.2"
},
"private": true,
"overrides": {
"brace-expansion": "$brace-expansion",
"postcss": "8.5.26",
+ "react-test-renderer": "19.1.0",
"tar": "7.5.22",
"uuid": "11.1.1",
"undici": "6.28.0",
diff --git a/src/app/(tabs)/matches/[id].tsx b/src/app/(tabs)/matches/[id].tsx
index bf5ec0f..509c8f0 100644
--- a/src/app/(tabs)/matches/[id].tsx
+++ b/src/app/(tabs)/matches/[id].tsx
@@ -26,6 +26,7 @@ import { MatchPasswordModal } from '@/components/matches/MatchPasswordModal'
import { CancelMatchModal } from '@/components/matches/CancelMatchModal'
import { IncompleteRosterStartModal } from '@/components/matches/IncompleteRosterStartModal'
import { LeaveMatchModal } from '@/components/matches/LeaveMatchModal'
+import { ShareMatchInviteModal } from '@/components/matches/ShareMatchInviteModal'
import { DisputeResultModal } from '@/components/matches/DisputeResultModal'
import { ResultCard } from '@/components/matches/ResultCard'
import { RecordResultModal } from '@/components/matches/RecordResultModal'
@@ -46,6 +47,12 @@ import {
useStartMatch,
useUpdateMatchTeam,
} from '@/hooks/useMatches'
+import {
+ useMatchInvitations,
+ useMyMatchInvitations,
+ useRespondMatchInvitation,
+ type MatchInvitationRow,
+} from '@/hooks/useMatchInvitations'
import { useLeague, useRecordLeagueMatchAsReferee } from '@/hooks/useLeagues'
import { useTournament, useRecordTournamentMatchAsReferee } from '@/hooks/useTournaments'
import { useMatchResult, useSubmitConfirmation, useSubmitResult } from '@/hooks/useResults'
@@ -283,6 +290,7 @@ interface TeamSectionProps {
teamLabel: string
freeSlots: number
participants: ParticipantWithProfile[]
+ pendingInvites: MatchInvitationRow[]
rosterText: {
team_a_player_1: string | null
team_a_player_2: string | null
@@ -305,11 +313,23 @@ function TextPlayerRow({ name }: { name: string }) {
)
}
+function PendingInviteRow({ name }: { name: string }) {
+ return (
+
+
+ {name}
+
+ Pendiente
+
+ )
+}
+
function TeamSection({
team,
teamLabel,
freeSlots,
participants,
+ pendingInvites,
rosterText,
matchId,
canRevealPhone,
@@ -319,6 +339,7 @@ function TeamSection({
onEdit,
}: TeamSectionProps) {
const entries = collectTeamRosterEntries(rosterText, participants, team)
+ const hasRoster = entries.length > 0 || pendingInvites.length > 0
return (
@@ -341,28 +362,36 @@ function TeamSection({
- {entries.length === 0 ? (
+ {!hasRoster ? (
Sin jugadores aún
) : (
- entries.map((entry) => {
- if (entry.kind === 'text') {
- return
- }
-
- // Guard defensivo: evita crash si el roster viene incompleto.
- if (!entry.participant) return null
-
- return (
-
+ {entries.map((entry) => {
+ if (entry.kind === 'text') {
+ return
+ }
+
+ // Guard defensivo: evita crash si el roster viene incompleto.
+ if (!entry.participant) return null
+
+ return (
+
+ )
+ })}
+ {pendingInvites.map((invite) => (
+
- )
- })
+ ))}
+ >
)}
)
@@ -396,6 +425,28 @@ const textPlayer_s = StyleSheet.create({
name: { fontSize: 15, fontFamily: Fonts.semiBold, color: Colors.textPrimary },
})
+const pendingInvite_s = StyleSheet.create({
+ row: {
+ backgroundColor: Colors.surface,
+ borderRadius: 10,
+ padding: 12,
+ marginBottom: 8,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ borderStyle: 'dashed',
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 10,
+ },
+ name: { flex: 1, fontSize: 15, fontFamily: Fonts.semiBold, color: Colors.textSecondary },
+ badge: {
+ fontSize: 12,
+ fontFamily: Fonts.semiBold,
+ color: Colors.statusPending,
+ },
+})
+
// ─── Join Modal ───────────────────────────────────────────────────────────────
interface JoinModalProps {
@@ -496,11 +547,13 @@ const jm = StyleSheet.create({
// ─── Screen ───────────────────────────────────────────────────────────────────
export default function MatchDetailScreen() {
- const { id, openResult, gamesA, gamesB } = useLocalSearchParams<{
+ const { id, openResult, gamesA, gamesB, confirmResult, shareInvite } = useLocalSearchParams<{
id: string
openResult?: string
gamesA?: string
gamesB?: string
+ confirmResult?: string
+ shareInvite?: string
}>()
const router = useRouter()
const insets = useSafeAreaInsets()
@@ -532,6 +585,14 @@ export default function MatchDetailScreen() {
isLoading: resultLoading,
refetch: refetchResult,
} = useMatchResult(id)
+ const { data: myInvitations } = useMyMatchInvitations()
+ const {
+ data: matchInvitations,
+ isPending: matchInvitationsPending,
+ isFetched: matchInvitationsFetched,
+ } = useMatchInvitations(id)
+ const respondInvitation = useRespondMatchInvitation()
+ const matchInvitationsReady = !matchInvitationsPending && matchInvitationsFetched
useFocusEffect(
useCallback(() => {
@@ -568,6 +629,7 @@ export default function MatchDetailScreen() {
const [editTeamVisible, setEditTeamVisible] = useState(false)
const [editingTeam, setEditingTeam] = useState(null)
const [passwordModalDismissed, setPasswordModalDismissed] = useState(false)
+ const [shareInviteVisible, setShareInviteVisible] = useState(false)
const needsPrivateAccess = Boolean(
match &&
@@ -650,11 +712,20 @@ export default function MatchDetailScreen() {
})
}
+ // Wait for invitation query so undefined is not treated as "no invites".
+ if (!matchInvitationsReady) return
+
const active = match.participants.filter((p) => p.left_at === null)
const myParticipation = active.find((p) => p.user_id === userId)
const otherRegistered = active.filter((p) => p.user_id !== userId)
+ const hasOutstandingAccountInvites = (matchInvitations ?? []).some(
+ (inv) => inv.status === 'pending'
+ )
const isPersonalMatch =
- !match.tournament_id && match.creator_id === userId && otherRegistered.length === 0
+ !match.tournament_id &&
+ match.creator_id === userId &&
+ otherRegistered.length === 0 &&
+ !hasOutstandingAccountInvites
const frame = requestAnimationFrame(() => {
setLockedScoreboardPrefill(prefill)
@@ -666,7 +737,48 @@ export default function MatchDetailScreen() {
})
return () => cancelAnimationFrame(frame)
- }, [match, scoreboardPrefill, submitResultVisible, recordResultVisible, userId, id, router])
+ }, [
+ match,
+ scoreboardPrefill,
+ submitResultVisible,
+ recordResultVisible,
+ userId,
+ id,
+ router,
+ matchInvitations,
+ matchInvitationsReady,
+ ])
+
+ // Deep link / redirect: ?confirmResult=1 opens the result confirmation modal
+ // when the viewer is on the rival team of a pending_validation result.
+ useEffect(() => {
+ const flag = Array.isArray(confirmResult) ? confirmResult[0] : confirmResult
+ if (flag !== '1' || !match || !userId) return
+ const myPart = match.participants.find((p) => p.user_id === userId && p.left_at === null)
+ const result = resultBundle?.result ?? null
+ const canValidate = Boolean(
+ myPart &&
+ result?.status === RESULT_STATUS.PENDING_VALIDATION &&
+ myPart.team !== result.submitted_by_team &&
+ !resultBundle?.myConfirmation
+ )
+ if (!canValidate) return
+ const frame = requestAnimationFrame(() => {
+ setApproveResultVisible(true)
+ router.setParams({ confirmResult: undefined } as never)
+ })
+ return () => cancelAnimationFrame(frame)
+ }, [confirmResult, match, resultBundle, userId, router])
+
+ useEffect(() => {
+ const flag = Array.isArray(shareInvite) ? shareInvite[0] : shareInvite
+ if (flag !== '1') return
+ const frame = requestAnimationFrame(() => {
+ setShareInviteVisible(true)
+ router.setParams({ shareInvite: undefined } as never)
+ })
+ return () => cancelAnimationFrame(frame)
+ }, [shareInvite, router])
if (isLoading) {
return (
@@ -696,7 +808,14 @@ export default function MatchDetailScreen() {
const slotsA = freeTeamSlots(match, match.participants, TEAM.A)
const slotsB = freeTeamSlots(match, match.participants, TEAM.B)
- const hasSlots = slotsA > 0 || slotsB > 0
+ const pendingInvites = (matchInvitations ?? []).filter(
+ (inv) => inv.status === 'pending' && match.status !== MATCH_STATUS.CANCELLED
+ )
+ const pendingInvitesA = pendingInvites.filter((inv) => inv.team === TEAM.A)
+ const pendingInvitesB = pendingInvites.filter((inv) => inv.team === TEAM.B)
+ const displaySlotsA = Math.max(0, slotsA - pendingInvitesA.length)
+ const displaySlotsB = Math.max(0, slotsB - pendingInvitesB.length)
+ const hasSlots = displaySlotsA > 0 || displaySlotsB > 0
const canJoin =
isPlanned &&
hasSlots &&
@@ -718,7 +837,25 @@ export default function MatchDetailScreen() {
const rivalHasRegisteredParticipants = activeParticipants.some(
(p) => p.user_id && p.team !== myParticipation?.team
)
- const isPersonalMatch = !match.tournament_id && isCreator && otherRegistered.length === 0
+ const rivalTeam =
+ myParticipation?.team === TEAM.A ? TEAM.B : myParticipation?.team === TEAM.B ? TEAM.A : null
+ const rivalHasPendingInvites =
+ rivalTeam === TEAM.A
+ ? pendingInvitesA.length > 0
+ : rivalTeam === TEAM.B
+ ? pendingInvitesB.length > 0
+ : false
+ // Pending account invites block direct close (record_match_result_direct);
+ // those matches must go through submit_match_result / pending_validation.
+ const hasOutstandingAccountInvites = (matchInvitations ?? []).some(
+ (inv) => inv.status === 'pending'
+ )
+ const isPersonalMatch =
+ matchInvitationsReady &&
+ !match.tournament_id &&
+ isCreator &&
+ otherRegistered.length === 0 &&
+ !hasOutstandingAccountInvites
// Las partidas de liga round-robin se quedan "planned" hasta que se juegan;
// el backend las auto-inicia al recibir el resultado.
@@ -786,6 +923,58 @@ export default function MatchDetailScreen() {
!myResultConfirmation
)
+ const matchId = Array.isArray(id) ? id[0] : id
+ // list_my_match_invitations only returns pending invites; `match_status` is match status.
+ const myPendingInvitation =
+ (myInvitations ?? []).find(
+ (inv) =>
+ inv.match_id === matchId &&
+ inv.match_status !== MATCH_STATUS.CANCELLED &&
+ match.status !== MATCH_STATUS.CANCELLED
+ ) ?? null
+
+ const handleAcceptInvitation = async () => {
+ if (!myPendingInvitation) return
+ try {
+ await respondInvitation.mutateAsync({
+ invitationId: myPendingInvitation.invitation_id,
+ accept: true,
+ matchId: myPendingInvitation.match_id,
+ team: myPendingInvitation.team,
+ })
+ const refreshed = await refetchMatch()
+ const refreshedResult = await refetchResult()
+ const refreshedMatch = refreshed.data
+ const myPart = refreshedMatch?.participants.find(
+ (p) => p.user_id === userId && p.left_at === null
+ )
+ const result = refreshedResult.data?.result ?? null
+ if (
+ myPart &&
+ result?.status === RESULT_STATUS.PENDING_VALIDATION &&
+ myPart.team !== result.submitted_by_team
+ ) {
+ setApproveResultVisible(true)
+ }
+ } catch (err) {
+ Alert.alert('No se pudo aceptar', err instanceof Error ? err.message : 'Error')
+ }
+ }
+
+ const handleRejectInvitation = async () => {
+ if (!myPendingInvitation) return
+ try {
+ await respondInvitation.mutateAsync({
+ invitationId: myPendingInvitation.invitation_id,
+ accept: false,
+ matchId: myPendingInvitation.match_id,
+ team: myPendingInvitation.team,
+ })
+ } catch (err) {
+ Alert.alert('No se pudo rechazar', err instanceof Error ? err.message : 'Error')
+ }
+ }
+
const awaitingRivalValidation = Boolean(
latestResult?.status === RESULT_STATUS.PENDING_VALIDATION &&
myParticipation?.team === latestResult.submitted_by_team
@@ -794,6 +983,12 @@ export default function MatchDetailScreen() {
const status = matchStatusDisplay(match)
const teamAName = resolveTeamName(match, TEAM.A, match.participants)
const teamBName = resolveTeamName(match, TEAM.B, match.participants)
+ const inviteTeamLabel =
+ myPendingInvitation?.team === TEAM.A
+ ? teamAName
+ : myPendingInvitation?.team === TEAM.B
+ ? teamBName
+ : null
const { canEdit: canEditTeam, teams: editableTeams } = canEditMatchTeam(
match,
@@ -1040,6 +1235,52 @@ export default function MatchDetailScreen() {
+ {myPendingInvitation ? (
+
+
+ {myPendingInvitation.inviter_name} te ha invitado a unirte a{' '}
+ {inviteTeamLabel ?? 'esta partida'}.
+
+
+
+
+ ) : null}
+
+ {canValidateResult && latestResult ? (
+
+
+ {submitterDisplayName(match.participants, latestResult.submitted_by_user_id)} ha
+ propuesto el resultado {latestResult.team_a_games}–{latestResult.team_b_games}.
+ Aprueba o disputa el marcador.
+
+
+
+ {
+ setApproveResultVisible(false)
+ setDisputeResultVisible(true)
+ }}
+ style={s.inviteBtn}
+ />
+
+
+ ) : null}
+
{/* Header */}
@@ -1130,8 +1371,9 @@ export default function MatchDetailScreen() {
) : null}
-
- {canValidateResult ? (
- <>
-
- {
- setApproveResultVisible(false)
- setDisputeResultVisible(true)
- }}
- style={s.actionBtn}
- />
- >
- ) : null}
{userId && !isCreator ? (
@@ -1349,8 +1573,8 @@ export default function MatchDetailScreen() {
visible={joinModalVisible}
onClose={() => setJoinModalVisible(false)}
onJoin={handleJoin}
- slotsA={slotsA}
- slotsB={slotsB}
+ slotsA={displaySlotsA}
+ slotsB={displaySlotsB}
teamAName={teamAName}
teamBName={teamBName}
loading={joinMatch.isPending}
@@ -1369,6 +1593,14 @@ export default function MatchDetailScreen() {
onClose={() => setIncompleteRosterStartVisible(false)}
/>
+ setShareInviteVisible(false)}
+ />
+
setLeaveMatchVisible(false)}
@@ -1381,6 +1613,16 @@ export default function MatchDetailScreen() {
teamLabel={editTeamLabel}
customTeamName={editCustomTeamName}
slots={editTeamSlots}
+ matchId={
+ !match.tournament_id && !match.league_id && isCreator && (isPlanned || isInProgress)
+ ? id
+ : undefined
+ }
+ team={editingTeam ?? undefined}
+ freeSlots={
+ editingTeam === TEAM.A ? displaySlotsA : editingTeam === TEAM.B ? displaySlotsB : 0
+ }
+ matchTitle={match.title}
onClose={closeEditTeam}
onSubmit={handleEditTeam}
loading={updateMatchTeam.isPending}
@@ -1404,7 +1646,7 @@ export default function MatchDetailScreen() {
teamAName={teamAName}
teamBName={teamBName}
durationTargetGames={match.duration_target_games}
- rivalAutoConfirms={!rivalHasRegisteredParticipants}
+ rivalAutoConfirms={!rivalHasRegisteredParticipants && !rivalHasPendingInvites}
loading={submitResultMut.isPending}
initialTeamAGames={resultPrefill?.teamAGames}
initialTeamBGames={resultPrefill?.teamBGames}
@@ -1563,6 +1805,18 @@ const s = StyleSheet.create({
actions: { gap: 10 },
actionBtn: {},
shareBtn: { marginBottom: 20 },
+ inviteBanner: {
+ backgroundColor: Colors.wonBackground,
+ borderRadius: 12,
+ padding: 14,
+ marginBottom: 16,
+ borderWidth: 1,
+ borderColor: Colors.primary,
+ gap: 10,
+ },
+ inviteBannerText: { fontSize: 14, color: Colors.textPrimary, lineHeight: 20 },
+ inviteBannerActions: { flexDirection: 'row', gap: 10 },
+ inviteBtn: { flex: 1 },
reportMatchRow: { alignItems: 'center', marginTop: 8, marginBottom: 8 },
reportMatchLink: {
fontSize: 14,
diff --git a/src/app/(tabs)/matches/create.tsx b/src/app/(tabs)/matches/create.tsx
index 08cca9a..1816e92 100644
--- a/src/app/(tabs)/matches/create.tsx
+++ b/src/app/(tabs)/matches/create.tsx
@@ -3,7 +3,16 @@ import { useFocusEffect } from '@react-navigation/native'
import { useRouter, type Href } from 'expo-router'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
-import { Alert, Keyboard, Pressable, StyleSheet, Text, View } from 'react-native'
+import {
+ Alert,
+ Keyboard,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { z } from 'zod'
@@ -19,9 +28,13 @@ import {
validateMatchScores,
} from '@/components/matches/MatchScorePicker'
import { MunicipalityPicker } from '@/components/ui/MunicipalityPicker'
+import { AvatarCircle } from '@/components/profile/AvatarCircle'
import { useAuthStore } from '@/hooks/useAuth'
import { useCreateMatch, useRecordMatchResultDirect } from '@/hooks/useMatches'
+import { useInviteFriendToMatch } from '@/hooks/useMatchInvitations'
+import { useMyFriends } from '@/hooks/useFriends'
import { useProfile } from '@/hooks/useProfile'
+import { useSubmitResult } from '@/hooks/useResults'
import { MATCH_STATUS, MATCH_VISIBILITY, TEAM } from '@/constants'
import { Colors } from '@/theme/colors'
import { Fonts } from '@/theme/typography'
@@ -153,7 +166,14 @@ export default function CreateMatchScreen() {
const { data: profile } = useProfile(sessionUserId)
const createMatch = useCreateMatch()
const recordMatchResult = useRecordMatchResultDirect()
+ const submitResult = useSubmitResult()
+ const inviteFriend = useInviteFriendToMatch()
+ const { data: friends } = useMyFriends()
const [pastResult, setPastResult] = useState(null)
+ const [invitesA, setInvitesA] = useState([])
+ const [invitesB, setInvitesB] = useState([])
+ const [friendSearch, setFriendSearch] = useState('')
+ const [isSubmitting, setIsSubmitting] = useState(false)
const closeToPrevious = useCallback(() => {
if (router.canGoBack()) {
@@ -179,6 +199,9 @@ export default function CreateMatchScreen() {
useCallback(() => {
reset(createDefaultFormValues())
setPastResult(null)
+ setInvitesA([])
+ setInvitesB([])
+ setFriendSearch('')
Keyboard.dismiss()
}, [reset])
)
@@ -193,14 +216,63 @@ export default function CreateMatchScreen() {
const teamBPlayer2 = watch('team_b_player_2')
const isPastResultMode = isMatchStartAtPast(startAtValue)
+ // Invite capacity is fixed; text roster slots unlock for leftover seats.
+ const inviteCapacityA = 1
+ const inviteCapacityB = 2
+ const textSlotsA = Math.max(0, inviteCapacityA - invitesA.length)
+ const textSlotsB = Math.max(0, inviteCapacityB - invitesB.length)
+
+ const toggleInvite = (friendId: string, team: 'A' | 'B') => {
+ if (team === 'A') {
+ setInvitesA((prev) => {
+ if (prev.includes(friendId)) return prev.filter((id) => id !== friendId)
+ if (prev.length >= inviteCapacityA) return prev
+ return [...prev, friendId]
+ })
+ setInvitesB((prev) => prev.filter((id) => id !== friendId))
+ if (!invitesA.includes(friendId) && invitesA.length < inviteCapacityA) {
+ setValue('team_a_player_2', '')
+ }
+ } else {
+ const alreadyInB = invitesB.includes(friendId)
+ const nextLen = alreadyInB
+ ? invitesB.filter((id) => id !== friendId).length
+ : invitesB.length >= inviteCapacityB
+ ? invitesB.length
+ : invitesB.length + 1
+ const willAdd = !alreadyInB && invitesB.length < inviteCapacityB
+ setInvitesB((prev) => {
+ if (prev.includes(friendId)) return prev.filter((id) => id !== friendId)
+ if (prev.length >= inviteCapacityB) return prev
+ return [...prev, friendId]
+ })
+ setInvitesA((prev) => prev.filter((id) => id !== friendId))
+ if (willAdd) {
+ if (nextLen >= 2) {
+ setValue('team_b_player_1', '')
+ setValue('team_b_player_2', '')
+ } else if (nextLen === 1) {
+ setValue('team_b_player_2', '')
+ }
+ }
+ }
+ }
+
+ const filteredFriends = useMemo(() => {
+ const list = friends ?? []
+ const q = friendSearch.trim().toLowerCase()
+ if (!q) return list
+ return list.filter((f) => f.display_name.toLowerCase().includes(q))
+ }, [friends, friendSearch])
+
const previewTeamNames = useMemo(() => {
const draft = {
team_a_name: teamANameValue ?? '',
team_b_name: teamBNameValue ?? '',
team_a_player_1: null,
- team_a_player_2: teamAPlayer2?.trim() || null,
- team_b_player_1: teamBPlayer1?.trim() || null,
- team_b_player_2: teamBPlayer2?.trim() || null,
+ team_a_player_2: invitesA.length > 0 ? null : teamAPlayer2?.trim() || null,
+ team_b_player_1: invitesB.length >= 2 ? null : teamBPlayer1?.trim() || null,
+ team_b_player_2: invitesB.length >= 1 ? null : teamBPlayer2?.trim() || null,
}
const creatorParticipant = profile?.display_name
? [
@@ -223,6 +295,8 @@ export default function CreateMatchScreen() {
teamBNameValue,
teamBPlayer1,
teamBPlayer2,
+ invitesA.length,
+ invitesB.length,
])
useEffect(() => {
@@ -230,6 +304,7 @@ export default function CreateMatchScreen() {
}, [durationValue, isPastResultMode])
const onSubmit = async (values: CreateMatchValues) => {
+ if (isSubmitting) return
if (isPastResultMode && !pastResult) {
showAlert('Resultado pendiente', 'Selecciona el marcador de la partida antes de crearla.')
return
@@ -245,31 +320,37 @@ export default function CreateMatchScreen() {
return
}
}
- if (
- isPastResultMode &&
- hasIncompleteMatchRoster({
- team_a_player_2: values.team_a_player_2,
- team_b_player_1: values.team_b_player_1,
- team_b_player_2: values.team_b_player_2,
- })
- ) {
+ const effectiveRoster = {
+ team_a_player_2: invitesA.length > 0 ? 'invited' : values.team_a_player_2,
+ team_b_player_1: invitesB.length >= 1 ? 'invited' : values.team_b_player_1,
+ team_b_player_2:
+ invitesB.length >= 2
+ ? 'invited'
+ : invitesB.length === 1
+ ? values.team_b_player_1
+ : values.team_b_player_2,
+ }
+ if (isPastResultMode && hasIncompleteMatchRoster(effectiveRoster)) {
showAlert(
'Faltan jugadores',
- 'Para registrar una partida ya jugada debes completar los cuatro jugadores de los dos equipos.'
+ 'Para registrar una partida ya jugada debes completar los cuatro jugadores de los dos equipos (por nombre o invitando a amigos).'
)
return
}
if (
!isPastResultMode &&
- requiresFutureStartAtForIncompleteRoster(values.start_at, {
- team_a_player_2: values.team_a_player_2,
- team_b_player_1: values.team_b_player_1,
- team_b_player_2: values.team_b_player_2,
- })
+ requiresFutureStartAtForIncompleteRoster(values.start_at, effectiveRoster)
) {
showAlert(PAST_DATE_INCOMPLETE_ROSTER_ALERT.title, PAST_DATE_INCOMPLETE_ROSTER_ALERT.message)
return
}
+
+ const inviteTargets: { fid: string; team: (typeof TEAM)[keyof typeof TEAM] }[] = [
+ ...invitesA.slice(0, inviteCapacityA).map((fid) => ({ fid, team: TEAM.A })),
+ ...invitesB.slice(0, inviteCapacityB).map((fid) => ({ fid, team: TEAM.B })),
+ ]
+
+ setIsSubmitting(true)
try {
const match = await createMatch.mutateAsync({
data: {
@@ -281,41 +362,92 @@ export default function CreateMatchScreen() {
duration_target_games: values.duration_target_games,
visibility: values.visibility,
location_privacy: 'participants_only',
- // Past matches with a result are created already in progress so closing
- // via record_match_result_direct does not depend on post-join promotion.
...(isPastResultMode ? { status: MATCH_STATUS.IN_PROGRESS } : {}),
team_a_name: (values.team_a_name ?? '').trim(),
team_b_name: (values.team_b_name ?? '').trim(),
team_a_player_1: null,
- team_a_player_2: textPlayerOrNull(values.team_a_player_2),
- team_b_player_1: textPlayerOrNull(values.team_b_player_1),
- team_b_player_2: textPlayerOrNull(values.team_b_player_2),
+ team_a_player_2: invitesA.length > 0 ? null : textPlayerOrNull(values.team_a_player_2),
+ team_b_player_1: invitesB.length >= 2 ? null : textPlayerOrNull(values.team_b_player_1),
+ team_b_player_2: invitesB.length >= 1 ? null : textPlayerOrNull(values.team_b_player_2),
},
password: values.visibility === MATCH_VISIBILITY.PRIVATE ? values.password : undefined,
})
- if (
- !isPastResultMode &&
- hasIncompleteMatchRoster({
- team_a_player_2: values.team_a_player_2,
- team_b_player_1: values.team_b_player_1,
- team_b_player_2: values.team_b_player_2,
- })
- ) {
+ if (!isPastResultMode && hasIncompleteMatchRoster(effectiveRoster)) {
await acknowledgeAlert(
AUTO_CANCEL_INCOMPLETE_ROSTER_ALERT.title,
AUTO_CANCEL_INCOMPLETE_ROSTER_ALERT.message
)
}
+
+ const failedInvites: { fid: string; team: string }[] = []
+ if (inviteTargets.length > 0) {
+ await Promise.all(
+ inviteTargets.map(async ({ fid, team }) => {
+ try {
+ await inviteFriend.mutateAsync({ matchId: match.id, inviteeId: fid, team })
+ } catch (err) {
+ console.warn('invite_friend_failed', err)
+ failedInvites.push({ fid, team })
+ }
+ })
+ )
+ }
+
+ const successfulInviteCount = inviteTargets.length - failedInvites.length
+ const rivalInviteTargets = inviteTargets.filter((t) => t.team === TEAM.B)
+ const rivalInvitesOk =
+ rivalInviteTargets.length === 0 ||
+ failedInvites.filter((f) => f.team === TEAM.B).length === 0
+ const hasRivalInvites = rivalInviteTargets.length > 0
+
if (isPastResultMode && pastResult) {
- await recordMatchResult.mutateAsync({
- matchId: match.id,
- teamAGames: pastResult.teamAGames,
- teamBGames: pastResult.teamBGames,
+ // Direct path only when there are no rival-team invitations (team A invites are not rivals).
+ if (hasRivalInvites && rivalInvitesOk) {
+ await submitResult.mutateAsync({
+ matchId: match.id,
+ submittedByUserId: sessionUserId!,
+ submittedByTeam: TEAM.A,
+ teamAGames: pastResult.teamAGames,
+ teamBGames: pastResult.teamBGames,
+ })
+ } else if (!hasRivalInvites) {
+ await recordMatchResult.mutateAsync({
+ matchId: match.id,
+ teamAGames: pastResult.teamAGames,
+ teamBGames: pastResult.teamBGames,
+ })
+ } else {
+ showAlert(
+ 'Invitaciones incompletas',
+ 'No se pudo enviar alguna invitación rival. El resultado no se ha registrado como pendiente de validación. Revisa las invitaciones en la ficha de la partida.'
+ )
+ }
+ }
+
+ if (failedInvites.length > 0) {
+ const names = failedInvites
+ .map((f) => friends?.find((fr) => fr.user_id === f.fid)?.display_name ?? 'Amigo')
+ .join(', ')
+ await new Promise((resolve) => {
+ Alert.alert(
+ 'Algunas invitaciones fallaron',
+ `No se pudo invitar a: ${names}. Puedes reintentarlo desde la ficha de la partida.`,
+ [{ text: 'Entendido', onPress: () => resolve() }]
+ )
})
}
- router.replace(`/(tabs)/matches/${match.id}`)
+
+ router.replace({
+ pathname: '/(tabs)/matches/[id]',
+ params: {
+ id: match.id,
+ ...(successfulInviteCount > 0 && failedInvites.length === 0 ? { shareInvite: '1' } : {}),
+ },
+ } as Href)
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'No se pudo crear la partida')
+ } finally {
+ setIsSubmitting(false)
}
}
@@ -482,23 +614,131 @@ export default function CreateMatchScreen() {
Equipos y jugadores
- Te unirás automáticamente como jugador 1 del primer equipo. El resto puede ser por nombre
- (sin cuenta en la app).
+ Te unirás automáticamente como jugador 1 del primer equipo. Invita amigos primero; las
+ plazas libres se pueden completar con nombres (sin cuenta).
- (
-
- )}
- />
+
+ {friends && friends.length > 0 ? (
+ <>
+ Invitar amigos
+
+ Compañero = tu equipo. Rival = equipo contrario. Las plazas de texto se desbloquean
+ según las invitaciones pendientes.
+
+
+
+ {friendSearch.length > 0 ? (
+ setFriendSearch('')}
+ accessibilityRole="button"
+ accessibilityLabel="Limpiar búsqueda de amigos"
+ hitSlop={8}
+ style={s.friendSearchClear}>
+ ✕
+
+ ) : null}
+
+ {filteredFriends.length === 0 ? (
+ No hay amigos con ese nombre.
+ ) : (
+
+ {filteredFriends.map((f) => {
+ const inA = invitesA.includes(f.user_id)
+ const inB = invitesB.includes(f.user_id)
+ return (
+
+
+
+
+ {f.display_name}
+
+ {f.city ? (
+
+ {f.city}
+
+ ) : null}
+
+ toggleInvite(f.user_id, 'A')}
+ disabled={!inA && inviteCapacityA - invitesA.length <= 0}
+ style={[
+ s.inviteChip,
+ inA && s.inviteChipActive,
+ !inA && inviteCapacityA - invitesA.length <= 0 && s.inviteChipDisabled,
+ ]}
+ accessibilityRole="button"
+ accessibilityState={{ selected: inA }}>
+
+ {inA ? 'Compañero ✓' : 'Compañero'}
+
+
+ toggleInvite(f.user_id, 'B')}
+ disabled={!inB && inviteCapacityB - invitesB.length <= 0}
+ style={[
+ s.inviteChip,
+ inB && s.inviteChipActive,
+ !inB && inviteCapacityB - invitesB.length <= 0 && s.inviteChipDisabled,
+ ]}
+ accessibilityRole="button"
+ accessibilityState={{ selected: inB }}>
+
+ {inB ? 'Rival ✓' : 'Rival'}
+
+
+
+ )
+ })}
+
+ )}
+ {invitesA.length + invitesB.length > 0 ? (
+
+ {invitesA.length + invitesB.length}{' '}
+ {invitesA.length + invitesB.length === 1
+ ? 'amigo seleccionado'
+ : 'amigos seleccionados'}
+ {' · '}
+ {textSlotsA + textSlotsB}{' '}
+ {textSlotsA + textSlotsB === 1 ? 'plaza de texto libre' : 'plazas de texto libres'}
+
+ ) : null}
+ >
+ ) : null}
+
+ Nombres (sin cuenta)
+ {textSlotsA > 0 ? (
+ (
+
+ )}
+ />
+ ) : (
+ Compañero cubierto por invitación.
+ )}
)}
/>
- (
-
- )}
- />
- (
-
- )}
- />
+ {textSlotsB >= 1 ? (
+ (
+
+ )}
+ />
+ ) : null}
+ {textSlotsB >= 2 ? (
+ (
+
+ )}
+ />
+ ) : textSlotsB === 0 ? (
+ Rivales cubiertos por invitación.
+ ) : null}
)
}
+const FRIEND_ROW_HEIGHT = 56
+const FRIEND_LIST_VISIBLE_ROWS = 5
+
const s = StyleSheet.create({
scroll: { flex: 1, backgroundColor: Colors.background },
container: { padding: 20, paddingBottom: 40 },
@@ -638,6 +893,86 @@ const s = StyleSheet.create({
marginHorizontal: -4,
},
hint: { fontSize: 13, color: Colors.textSecondary, marginBottom: 12, lineHeight: 18 },
+ subLabel: {
+ fontSize: 14,
+ fontFamily: Fonts.semiBold,
+ color: Colors.textPrimary,
+ marginTop: 8,
+ marginBottom: 6,
+ },
+ slotLockedHint: {
+ fontSize: 13,
+ color: Colors.textSecondary,
+ fontStyle: 'italic',
+ marginBottom: 10,
+ lineHeight: 18,
+ },
+ friendSearchWrap: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: Colors.border,
+ borderRadius: 10,
+ backgroundColor: Colors.surface,
+ paddingHorizontal: 12,
+ minHeight: 44,
+ marginBottom: 10,
+ },
+ friendSearchInput: {
+ flex: 1,
+ fontSize: 15,
+ fontFamily: Fonts.regular,
+ color: Colors.textPrimary,
+ paddingVertical: 10,
+ },
+ friendSearchClear: { padding: 4 },
+ friendSearchClearText: { fontSize: 14, color: Colors.textSecondary },
+ friendSearchEmpty: {
+ fontSize: 13,
+ color: Colors.textSecondary,
+ paddingVertical: 8,
+ },
+ friendListScroll: {
+ // ~5 friend rows visible; scroll for the rest.
+ maxHeight: FRIEND_LIST_VISIBLE_ROWS * FRIEND_ROW_HEIGHT,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ borderRadius: 10,
+ backgroundColor: Colors.background,
+ },
+ friendListContent: {
+ paddingHorizontal: 8,
+ },
+ friendSelectionHint: {
+ marginTop: 8,
+ fontSize: 12,
+ color: Colors.textSecondary,
+ lineHeight: 16,
+ },
+ friendRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ minHeight: FRIEND_ROW_HEIGHT,
+ paddingVertical: 8,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: Colors.border,
+ },
+ friendInfo: { flex: 1, minWidth: 0, gap: 2 },
+ friendName: { fontSize: 15, fontFamily: Fonts.medium, color: Colors.textPrimary },
+ friendCity: { fontSize: 13, color: Colors.textSecondary },
+ inviteChip: {
+ paddingVertical: 8,
+ paddingHorizontal: 12,
+ borderRadius: 10,
+ borderWidth: 1.5,
+ borderColor: Colors.border,
+ backgroundColor: Colors.surface,
+ },
+ inviteChipActive: { borderColor: Colors.primary, backgroundColor: Colors.wonBackground },
+ inviteChipDisabled: { opacity: 0.4 },
+ inviteChipText: { fontSize: 13, fontFamily: Fonts.semiBold, color: Colors.textSecondary },
+ inviteChipTextActive: { color: Colors.primary },
teamLabel: {
fontSize: 14,
fontFamily: Fonts.bold,
diff --git a/src/app/(tabs)/matches/index.tsx b/src/app/(tabs)/matches/index.tsx
index ed67bc3..17bfcc9 100644
--- a/src/app/(tabs)/matches/index.tsx
+++ b/src/app/(tabs)/matches/index.tsx
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState } from 'react'
import {
ActivityIndicator,
+ Alert,
FlatList,
Pressable,
RefreshControl,
@@ -13,12 +14,15 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { formatDisplay } from '@/components/ui/dateTimePickerUtils'
import { CreateFab } from '@/components/ui/CreateFab'
+import { Button } from '@/components/ui/Button'
import { ScreenHeader } from '@/components/ui/ScreenHeader'
import { StatusDot, type StatusDotTone } from '@/components/ui/StatusDot'
-import { TOURNAMENT_STATUS } from '@/constants'
+import { MATCH_STATUS, TOURNAMENT_STATUS } from '@/constants'
import { useAuthStore } from '@/hooks/useAuth'
import { useMyMatchesDashboard } from '@/hooks/useMatches'
+import { useMyMatchInvitations, useRespondMatchInvitation } from '@/hooks/useMatchInvitations'
import type { MyMatchesDashboard } from '@/services/matches.service'
+import type { MyMatchInvitationRow } from '@/services/matchInvitations.service'
import type { UserTournamentSummary } from '@/services/tournaments.service'
import { leagueFormatDisplay, leagueStatusDisplay } from '@/utils/leagueDisplay'
import { Colors } from '@/theme/colors'
@@ -89,6 +93,11 @@ function MatchListRow({
type MatchesListItem =
| { key: string; kind: 'empty' }
| { key: string; kind: 'section'; title: string }
+ | {
+ key: string
+ kind: 'invitation'
+ invitation: MyMatchInvitationRow
+ }
| {
key: string
kind: 'row'
@@ -110,7 +119,10 @@ function tournamentStatusLabel(t: UserTournamentSummary): string {
return 'Inscripción abierta'
}
-function buildMatchesListItems(data: MyMatchesDashboard): MatchesListItem[] {
+function buildMatchesListItems(
+ data: MyMatchesDashboard,
+ invitations: MyMatchInvitationRow[]
+): MatchesListItem[] {
const awaitingIds = new Set(data.awaitingResultValidation.map((m) => m.id))
const inProgressDeduped = data.inProgress.filter((m) => !awaitingIds.has(m.id))
const tournamentsUpcoming = data.tournamentsUpcoming ?? []
@@ -118,6 +130,7 @@ function buildMatchesListItems(data: MyMatchesDashboard): MatchesListItem[] {
const leaguesUpcoming = data.leaguesUpcoming ?? []
const leaguesInProgress = data.leaguesInProgress ?? []
const hasAny =
+ invitations.length > 0 ||
data.upcoming.length > 0 ||
inProgressDeduped.length > 0 ||
data.awaitingResultValidation.length > 0 ||
@@ -138,6 +151,15 @@ function buildMatchesListItems(data: MyMatchesDashboard): MatchesListItem[] {
items.push(...rows)
}
+ pushSection(
+ 'Invitaciones',
+ invitations.map((inv) => ({
+ key: `invitation-${inv.invitation_id}`,
+ kind: 'invitation' as const,
+ invitation: inv,
+ }))
+ )
+
pushSection(
'Pendiente: validar resultado',
data.awaitingResultValidation.map((m) => ({
@@ -254,6 +276,8 @@ export default function MatchesScreen() {
const insets = useSafeAreaInsets()
const userId = useAuthStore((s) => s.session?.user.id)
const { data, isPending, isError, refetch, isFetched } = useMyMatchesDashboard()
+ const { data: invitations } = useMyMatchInvitations()
+ const respondInvitation = useRespondMatchInvitation()
const [isUserRefreshing, setIsUserRefreshing] = useState(false)
const onUserRefresh = useCallback(async () => {
@@ -267,7 +291,12 @@ export default function MatchesScreen() {
const createFab =
const topPadding = screenTopPadding(insets.top)
- const listItems = useMemo(() => (data ? buildMatchesListItems(data) : []), [data])
+ const listItems = useMemo(() => {
+ const activeInvites = (invitations ?? []).filter(
+ (inv) => inv.match_status !== MATCH_STATUS.CANCELLED
+ )
+ return data ? buildMatchesListItems(data, activeInvites) : []
+ }, [data, invitations])
if (!userId) {
return (
@@ -328,6 +357,46 @@ export default function MatchesScreen() {
if (item.kind === 'section') {
return {item.title}
}
+ if (item.kind === 'invitation') {
+ return (
+ router.push(`/(tabs)/matches/${item.invitation.match_id}` as Href)}
+ onAccept={() =>
+ void respondInvitation
+ .mutateAsync({
+ invitationId: item.invitation.invitation_id,
+ accept: true,
+ matchId: item.invitation.match_id,
+ team: item.invitation.team,
+ })
+ .then(() => router.push(`/(tabs)/matches/${item.invitation.match_id}` as Href))
+ .catch((err) => {
+ Alert.alert(
+ 'No se pudo aceptar',
+ err instanceof Error ? err.message : 'Error'
+ )
+ })
+ }
+ onReject={() =>
+ void respondInvitation
+ .mutateAsync({
+ invitationId: item.invitation.invitation_id,
+ accept: false,
+ matchId: item.invitation.match_id,
+ team: item.invitation.team,
+ })
+ .catch((err) => {
+ Alert.alert(
+ 'No se pudo rechazar',
+ err instanceof Error ? err.message : 'Error'
+ )
+ })
+ }
+ />
+ )
+ }
return (
void
+ onAccept: () => void
+ onReject: () => void
+}) {
+ const teamLabel = invitation.team === 'A' ? 'equipo A' : 'equipo rival'
+ return (
+
+
+
+
+ Invitación · {teamLabel}
+
+ {invitation.title}
+
+
+ {invitation.inviter_name} te ha invitado
+
+
+
+
+
+
+
+
+ )
+}
+
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: Colors.background },
listContent: { paddingHorizontal: 20 },
@@ -453,4 +575,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontFamily: Fonts.semiBold,
},
+ invitationRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.border,
+ },
+ invitationBody: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0 },
+ invitationActions: { flexDirection: 'row', gap: 8 },
+ smallBtn: { minHeight: 36, paddingHorizontal: 12 },
+ smallBtnText: { fontSize: 13 },
})
diff --git a/src/app/(tabs)/profile/[userId].tsx b/src/app/(tabs)/profile/[userId].tsx
index ecdce70..4e0bc86 100644
--- a/src/app/(tabs)/profile/[userId].tsx
+++ b/src/app/(tabs)/profile/[userId].tsx
@@ -14,9 +14,16 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { MatchHistoryList } from '@/components/profile/MatchHistoryList'
import { AvatarCircle } from '@/components/profile/AvatarCircle'
+import { SendFriendRequestModal } from '@/components/profile/SendFriendRequestModal'
import { ProfileStatsCard } from '@/components/stats/ProfileStatsCard'
import { Button } from '@/components/ui/Button'
import { useAuthStore } from '@/hooks/useAuth'
+import {
+ useCancelFriendRequest,
+ useFriendshipWithUser,
+ useRespondFriendRequest,
+ type FriendshipStatus as FriendshipStatusRow,
+} from '@/hooks/useFriends'
import { useViewableUserMatches } from '@/hooks/useMatches'
import { useViewableUserProfile } from '@/hooks/useProfile'
import { Colors } from '@/theme/colors'
@@ -65,6 +72,10 @@ export default function UserProfileScreen() {
const { data: profile, isPending, isError } = useViewableUserProfile(userId)
const { data: matches, isPending: matchesPending } = useViewableUserMatches(userId)
+ const { data: friendship, isPending: friendshipPending } = useFriendshipWithUser(userId)
+ const respond = useRespondFriendRequest()
+ const cancel = useCancelFriendRequest()
+ const [sendModalOpen, setSendModalOpen] = useState(false)
useEffect(() => {
if (userId && sessionUserId && userId === sessionUserId) {
@@ -123,13 +134,47 @@ export default function UserProfileScreen() {
const phone = profile.phone_e164?.trim() ?? ''
+ const friendshipStatus = friendship?.status ?? null
+ const friendshipDirection = friendship?.direction ?? null
+
return (
-
+ setSendModalOpen(true)}
+ onAccept={() =>
+ friendship?.friendship_id
+ ? void respond
+ .mutateAsync({ friendshipId: friendship.friendship_id, accept: true })
+ .catch((err) =>
+ Alert.alert('No se pudo aceptar', err instanceof Error ? err.message : 'Error')
+ )
+ : undefined
+ }
+ onReject={() =>
+ friendship?.friendship_id
+ ? void respond
+ .mutateAsync({ friendshipId: friendship.friendship_id, accept: false })
+ .catch((err) =>
+ Alert.alert('No se pudo rechazar', err instanceof Error ? err.message : 'Error')
+ )
+ : undefined
+ }
+ onCancel={() =>
+ friendship?.friendship_id
+ ? void cancel
+ .mutateAsync(friendship.friendship_id)
+ .catch((err) =>
+ Alert.alert('No se pudo cancelar', err instanceof Error ? err.message : 'Error')
+ )
+ : undefined
+ }
+ />
+
✕
@@ -141,9 +186,7 @@ export default function UserProfileScreen() {
{profile.display_name}
- {phone ? (
-
- ) : null}
+ {phone ? : null}
{profile.city ? {profile.city} : null}
@@ -162,20 +205,119 @@ export default function UserProfileScreen() {
loading={matchesPending}
emptyMessage="Sin partidas visibles en su historial"
onMatchPress={(matchId) =>
- router.push(
- buildMatchDetailHref(matchId, { from: 'profile', profileUserId: userId })
- )
+ router.push(buildMatchDetailHref(matchId, { from: 'profile', profileUserId: userId }))
}
/>
+
+ setSendModalOpen(false)}
+ />
)
}
+type FriendshipStatusValue = FriendshipStatusRow['status']
+
+function FriendActionButton({
+ status,
+ direction,
+ pending,
+ busy,
+ onSend,
+ onAccept,
+ onReject,
+ onCancel,
+}: {
+ status: FriendshipStatusValue
+ direction: 'sent' | 'received' | null
+ pending: boolean
+ busy: boolean
+ onSend: () => void
+ onAccept: () => void
+ onReject: () => void
+ onCancel: () => void
+}) {
+ if (pending) {
+ return (
+
+
+
+ )
+ }
+ if (status === 'accepted') {
+ return (
+
+
+
+ )
+ }
+ if (status === 'pending' && direction === 'received') {
+ return (
+
+ [styles.friendBtn, pressed && styles.friendBtnPressed]}>
+
+
+ [styles.friendBtn, pressed && styles.friendBtnPressed]}>
+
+
+
+ )
+ }
+ if (status === 'pending' && direction === 'sent') {
+ return (
+ [styles.friendBtn, pressed && styles.friendBtnPressed]}>
+
+
+ )
+ }
+ // No friendship yet (rejected/cancelled/none): send a request.
+ return (
+ [styles.friendBtn, pressed && styles.friendBtnPressed]}>
+
+
+ )
+}
+
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: Colors.background },
- topBar: { flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 16 },
+ topBar: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ },
+ friendBtn: { padding: 8 },
+ friendBtnRow: { flexDirection: 'row', alignItems: 'center', gap: 4 },
+ friendBtnPlaceholder: { padding: 8 },
+ friendBtnPressed: { opacity: 0.6 },
closeWrap: { alignSelf: 'flex-end', padding: 8 },
close: { fontSize: 22, color: Colors.textSecondary, padding: 8 },
scroll: {
diff --git a/src/app/(tabs)/profile/index.tsx b/src/app/(tabs)/profile/index.tsx
index ae57da7..3aa09c1 100644
--- a/src/app/(tabs)/profile/index.tsx
+++ b/src/app/(tabs)/profile/index.tsx
@@ -16,6 +16,7 @@ import { requestAppStoreRating } from '@/lib/storeReview'
import { DeleteAccountModal } from '@/components/DeleteAccountModal'
import { FeedbackModal } from '@/components/FeedbackModal'
import { AvatarCircle } from '@/components/profile/AvatarCircle'
+import { FriendsSection } from '@/components/profile/FriendsSection'
import { MatchHistoryList } from '@/components/profile/MatchHistoryList'
import { ProfileStatsCard } from '@/components/stats/ProfileStatsCard'
import { BadgeUnlockPopup } from '@/components/stats/BadgeUnlockPopup'
@@ -50,6 +51,8 @@ type NotifField = Pick<
| 'notify_on_reminder_24h'
| 'notify_on_reminder_2h'
| 'notify_on_reminder_in_progress'
+ | 'notify_on_friend_request'
+ | 'notify_on_match_invitation'
>
export default function ProfileScreen() {
@@ -154,207 +157,225 @@ export default function ProfileScreen() {
const notifDisabled = updateProfile.isPending
return (
-
-
-
-
- router.push('/(tabs)/profile/edit' as Href)}
- accessibilityRole="button"
- accessibilityLabel="Editar perfil"
- style={({ pressed }) => [styles.avatarPencil, pressed && styles.avatarPencilPressed]}>
-
-
+
+
+
+
+
+ router.push('/(tabs)/profile/edit' as Href)}
+ accessibilityRole="button"
+ accessibilityLabel="Editar perfil"
+ style={({ pressed }) => [styles.avatarPencil, pressed && styles.avatarPencilPressed]}>
+
+
+
+ {profile.display_name}
+ {profile.phone_e164 ? (
+ {formatPhone(profile.phone_e164)}
+ ) : null}
+ {profile.city ? {profile.city} : null}
- {profile.display_name}
- {profile.phone_e164 ? (
- {formatPhone(profile.phone_e164)}
+
+ {sessionUserId ? (
+ router.push(`/(tabs)/profile/stats/${sessionUserId}` as Href)}
+ onPressRanking={() => router.push('/(tabs)/leaderboard' as Href)}
+ />
) : null}
- {profile.city ? {profile.city} : null}
-
- {sessionUserId ? (
- router.push(`/(tabs)/profile/stats/${sessionUserId}` as Href)}
- onPressRanking={() => router.push('/(tabs)/leaderboard' as Href)}
- />
- ) : null}
-
-
- Historial
-
- router.push(buildMatchDetailHref(matchId, { from: 'profile' }))
- }
- />
-
+
+ Historial
+
+ router.push(buildMatchDetailHref(matchId, { from: 'profile' }))
+ }
+ />
+
-
- Notificaciones
+
+ Notificaciones
- void onNotifChange('notify_push', value)}
- />
+ void onNotifChange('notify_push', value)}
+ />
- Por evento
- void onNotifChange('notify_on_join', value)}
- />
- void onNotifChange('notify_on_match_start', value)}
- />
- void onNotifChange('notify_on_match_edit', value)}
- />
- void onNotifChange('notify_on_match_cancel', value)}
- />
- void onNotifChange('notify_on_result', value)}
- />
- void onNotifChange('notify_on_reminder_in_progress', value)}
- />
-
- Recordatorio antes de la partida
-
- void onReminderTimingChange('24h', !profile.notify_on_reminder_24h)}
- />
- void onReminderTimingChange('2h', !profile.notify_on_reminder_2h)}
- />
+ Por evento
+ void onNotifChange('notify_on_join', value)}
+ />
+ void onNotifChange('notify_on_match_start', value)}
+ />
+ void onNotifChange('notify_on_match_edit', value)}
+ />
+ void onNotifChange('notify_on_match_cancel', value)}
+ />
+ void onNotifChange('notify_on_result', value)}
+ />
+ void onNotifChange('notify_on_reminder_in_progress', value)}
+ />
+ void onNotifChange('notify_on_friend_request', value)}
+ />
+ void onNotifChange('notify_on_match_invitation', value)}
+ />
+
+ Recordatorio antes de la partida
+
+ void onReminderTimingChange('24h', !profile.notify_on_reminder_24h)}
+ />
+ void onReminderTimingChange('2h', !profile.notify_on_reminder_2h)}
+ />
+
-
- {isRatingPromptSupported() ? (
+ {isRatingPromptSupported() ? (
+
+ Ayuda
+ void onRateApp()} isLast />
+
+ ) : null}
+
- Ayuda
- void onRateApp()} isLast />
+ Legal
+ router.push('/(auth)/terms' as Href)}
+ />
+ router.push('/(auth)/privacy' as Href)}
+ isLast
+ />
- ) : null}
-
- Legal
- router.push('/(auth)/terms' as Href)}
- />
- router.push('/(auth)/privacy' as Href)}
- isLast
- />
-
+ {profile.role === 'admin' ? (
+ router.push('/(admin)' as Href)}
+ accessibilityRole="button">
+ Panel de administración
+
+ ) : null}
- {profile.role === 'admin' ? (
router.push('/(admin)' as Href)}
+ style={styles.editButton}
+ onPress={() => router.push('/(tabs)/profile/edit')}
accessibilityRole="button">
- Panel de administración
+ Editar perfil
- ) : null}
-
- router.push('/(tabs)/profile/edit')}
- accessibilityRole="button">
- Editar perfil
-
-
- setShowFeedbackModal(true)}
- accessibilityRole="button">
- Enviar feedback
-
-
- setShowSignOutModal(true)}
- style={styles.signOutBtn}
- textStyle={styles.signOutLabel}
- />
- setShowDeleteModal(true)}
- style={styles.deleteAccountBtn}
- />
+ setShowFeedbackModal(true)}
+ accessibilityRole="button">
+ Enviar feedback
+
- setShowSignOutModal(false)}
- loading={signingOut}
- onConfirm={onSignOut}
- />
+ setShowSignOutModal(true)}
+ style={styles.signOutBtn}
+ textStyle={styles.signOutLabel}
+ />
- setShowFeedbackModal(false)} />
+ setShowDeleteModal(true)}
+ style={styles.deleteAccountBtn}
+ />
- setShowDeleteModal(false)}
- loading={deletingAccount}
- onConfirm={onDeleteAccount}
- />
+ setShowSignOutModal(false)}
+ loading={signingOut}
+ onConfirm={onSignOut}
+ />
-
-
+ setShowFeedbackModal(false)} />
+
+ setShowDeleteModal(false)}
+ loading={deletingAccount}
+ onConfirm={onDeleteAccount}
+ />
+
+
+
+
+
+
)
}
@@ -443,6 +464,10 @@ function LinkRow({
}
const styles = StyleSheet.create({
+ root: {
+ flex: 1,
+ backgroundColor: Colors.background,
+ },
scroll: {
flexGrow: 1,
paddingHorizontal: 20,
diff --git a/src/components/matches/EditMatchTeamModal.tsx b/src/components/matches/EditMatchTeamModal.tsx
index 91d1c96..26330b0 100644
--- a/src/components/matches/EditMatchTeamModal.tsx
+++ b/src/components/matches/EditMatchTeamModal.tsx
@@ -12,8 +12,13 @@ import {
View,
} from 'react-native'
+import { AvatarCircle } from '@/components/profile/AvatarCircle'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
+import { useInviteFriendToMatch } from '@/hooks/useMatchInvitations'
+import { useMyFriends } from '@/hooks/useFriends'
+import { buildMatchHttpsInviteUrl } from '@/lib/inviteLinks'
+import { buildInviteShareMessage, shareInviteViaWhatsApp } from '@/lib/shareInvite'
import type { MatchTeamEditSlot, TextPlayerFields } from '@/services/matches.service'
import { Colors } from '@/theme/colors'
import { Fonts } from '@/theme/typography'
@@ -28,6 +33,14 @@ type EditMatchTeamModalProps = {
teamLabel: string
customTeamName: string
slots: MatchTeamEditSlot[]
+ /** Match being edited (standalone planned match). When provided, the
+ * "Añadir amigo" tab is shown so the creator can invite friends. */
+ matchId?: string
+ team?: string
+ /** Free slots on this team (registered + text + pending < 2). */
+ freeSlots?: number
+ /** Used in the WhatsApp invite message when inviting from this modal. */
+ matchTitle?: string
onClose: () => void
onSubmit: (values: EditMatchTeamFormValues) => void | Promise
loading?: boolean
@@ -49,6 +62,10 @@ type EditMatchTeamFormProps = {
teamLabel: string
customTeamName: string
slots: MatchTeamEditSlot[]
+ matchId?: string
+ team?: string
+ freeSlots?: number
+ matchTitle?: string
onClose: () => void
onSubmit: (values: EditMatchTeamFormValues) => void | Promise
loading?: boolean
@@ -58,15 +75,22 @@ function EditMatchTeamForm({
teamLabel,
customTeamName,
slots,
+ matchId,
+ team,
+ freeSlots,
+ matchTitle,
onClose,
onSubmit,
loading,
}: EditMatchTeamFormProps) {
+ const [tab, setTab] = useState<'edit' | 'friends'>('edit')
const [teamName, setTeamName] = useState(customTeamName)
const [textByField, setTextByField] = useState(() => initialTextByField(slots))
const textSlots = useMemo(() => slots.filter((s) => s.kind === 'text'), [slots])
+ const canInviteFriends = Boolean(matchId && team)
+
const handleSubmit = async () => {
for (const slot of textSlots) {
const initial = slot.value.trim()
@@ -94,6 +118,28 @@ function EditMatchTeamForm({
✕
+ {canInviteFriends ? (
+
+ setTab('edit')}
+ accessibilityRole="button"
+ accessibilityState={{ selected: tab === 'edit' }}
+ style={[styles.tab, tab === 'edit' && styles.tabActive]}>
+
+ Editar pareja
+
+
+ setTab('friends')}
+ accessibilityRole="button"
+ accessibilityState={{ selected: tab === 'friends' }}
+ style={[styles.tab, tab === 'friends' && styles.tabActive]}>
+
+ Añadir amigo
+
+
+
+ ) : null}
@@ -101,50 +147,165 @@ function EditMatchTeamForm({
contentContainerStyle={styles.body}
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets>
- {teamLabel}
-
- {slots.map((slot, index) => (
-
- Jugador {index + 1}
- {slot.kind === 'registered' ? (
-
- {slot.displayName}
- Inscrito con cuenta (no editable)
+ {tab === 'friends' && canInviteFriends ? (
+
+ ) : (
+ <>
+ {teamLabel}
+
+ {slots.map((slot, index) => (
+
+ Jugador {index + 1}
+ {slot.kind === 'registered' ? (
+
+ {slot.displayName}
+ Inscrito con cuenta (no editable)
+
+ ) : (
+
+ setTextByField((prev) => ({ ...prev, [slot.field]: value }))
+ }
+ autoCapitalize="words"
+ />
+ )}
- ) : (
-
- setTextByField((prev) => ({ ...prev, [slot.field]: value }))
- }
- autoCapitalize="words"
- />
- )}
-
- ))}
+ ))}
-
+
- void handleSubmit()} loading={loading} />
+ void handleSubmit()}
+ loading={loading}
+ />
+ >
+ )}
)
}
+function InviteFriendsTab({
+ matchId,
+ team,
+ teamLabel,
+ matchTitle,
+ freeSlots,
+}: {
+ matchId: string
+ team: string
+ teamLabel: string
+ matchTitle?: string
+ freeSlots: number
+}) {
+ const { data: friends, isLoading } = useMyFriends()
+ const invite = useInviteFriendToMatch()
+ const [invitedIds, setInvitedIds] = useState([])
+ const [invitingId, setInvitingId] = useState(null)
+
+ const remainingSlots = Math.max(0, freeSlots)
+
+ const handleInvite = async (friendId: string) => {
+ if (remainingSlots <= 0 || invitedIds.includes(friendId)) return
+ setInvitingId(friendId)
+ try {
+ await invite.mutateAsync({ matchId, inviteeId: friendId, team })
+ setInvitedIds((prev) => (prev.includes(friendId) ? prev : [...prev, friendId]))
+ const url = buildMatchHttpsInviteUrl(matchId)
+ const message = buildInviteShareMessage({
+ kind: 'match',
+ title: matchTitle?.trim() || 'Partida',
+ url,
+ meta: `Te he invitado a unirte a ${teamLabel} en esta partida`,
+ })
+ try {
+ await shareInviteViaWhatsApp(message)
+ } catch (shareErr) {
+ console.warn('share_invite_failed', shareErr)
+ }
+ } catch (err) {
+ Alert.alert('No se pudo invitar', err instanceof Error ? err.message : 'Error')
+ } finally {
+ setInvitingId(null)
+ }
+ }
+
+ if (remainingSlots <= 0) {
+ return {teamLabel} ya está completo.
+ }
+
+ if (isLoading) {
+ return Cargando amigos…
+ }
+
+ if (!friends || friends.length === 0) {
+ return (
+
+ Aún no tienes amigos. Envía una solicitud desde el perfil de un usuario para poder
+ invitarle.
+
+ )
+ }
+
+ return (
+
+ Invita a un amigo a unirse a {teamLabel}
+ {friends.map((f) => {
+ const alreadyInvited = invitedIds.includes(f.user_id)
+ return (
+
+
+
+
+ {f.display_name}
+
+ {f.city ? (
+
+ {f.city}
+
+ ) : null}
+
+ void handleInvite(f.user_id)}
+ loading={invitingId === f.user_id}
+ disabled={alreadyInvited || remainingSlots <= 0 || invitingId !== null}
+ style={styles.inviteBtn}
+ textStyle={styles.inviteBtnText}
+ />
+
+ )
+ })}
+
+ )
+}
+
export function EditMatchTeamModal({
visible,
teamLabel,
customTeamName,
slots,
+ matchId,
+ team,
+ freeSlots,
+ matchTitle,
onClose,
onSubmit,
loading,
@@ -161,6 +322,10 @@ export function EditMatchTeamModal({
teamLabel={teamLabel}
customTeamName={customTeamName}
slots={slots}
+ matchId={matchId}
+ team={team}
+ freeSlots={freeSlots}
+ matchTitle={matchTitle}
onClose={onClose}
onSubmit={onSubmit}
loading={loading}
@@ -184,6 +349,16 @@ const styles = StyleSheet.create({
},
title: { fontSize: 17, fontFamily: Fonts.bold, color: Colors.textPrimary },
close: { fontSize: 18, color: Colors.textSecondary, padding: 4 },
+ tabs: {
+ flexDirection: 'row',
+ backgroundColor: Colors.surface,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: Colors.border,
+ },
+ tab: { flex: 1, paddingVertical: 14, alignItems: 'center' },
+ tabActive: { borderBottomWidth: 2, borderBottomColor: Colors.primary },
+ tabText: { fontSize: 14, fontFamily: Fonts.semiBold, color: Colors.textSecondary },
+ tabTextActive: { color: Colors.primary },
body: { padding: 20, paddingBottom: 40 },
teamHint: { fontSize: 14, color: Colors.textSecondary, marginBottom: 16 },
slot: { marginBottom: 16 },
@@ -198,4 +373,12 @@ const styles = StyleSheet.create({
},
lockedName: { fontSize: 15, fontFamily: Fonts.semiBold, color: Colors.textPrimary },
lockedHint: { fontSize: 12, color: Colors.textSecondary, marginTop: 4 },
+ friendsList: { gap: 12 },
+ friendRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
+ friendInfo: { flex: 1, minWidth: 0, gap: 2 },
+ friendName: { fontSize: 15, fontFamily: Fonts.medium, color: Colors.textPrimary },
+ friendCity: { fontSize: 13, color: Colors.textSecondary },
+ inviteBtn: { minHeight: 36, paddingHorizontal: 14 },
+ inviteBtnText: { fontSize: 13 },
+ empty: { fontSize: 14, color: Colors.textSecondary, lineHeight: 20 },
})
diff --git a/src/components/matches/ShareMatchInviteModal.tsx b/src/components/matches/ShareMatchInviteModal.tsx
new file mode 100644
index 0000000..c22d51c
--- /dev/null
+++ b/src/components/matches/ShareMatchInviteModal.tsx
@@ -0,0 +1,160 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Alert, Modal, Pressable, StyleSheet, Text, View } from 'react-native'
+import * as Clipboard from 'expo-clipboard'
+
+import { IconButton } from '@/components/ui/IconButton'
+import { buildMatchHttpsInviteUrl } from '@/lib/inviteLinks'
+import { buildInviteShareMessage, shareInviteViaWhatsApp } from '@/lib/shareInvite'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+export type ShareMatchInviteModalProps = {
+ visible: boolean
+ matchId: string
+ title: string
+ meta?: string
+ onClose: () => void
+}
+
+export function ShareMatchInviteModal({
+ visible,
+ matchId,
+ title,
+ meta,
+ onClose,
+}: ShareMatchInviteModalProps) {
+ const [copied, setCopied] = useState(false)
+ const [sharing, setSharing] = useState(false)
+ const copiedResetRef = useRef | null>(null)
+ const inviteUrl = buildMatchHttpsInviteUrl(matchId)
+ const shareMessage = useMemo(
+ () =>
+ buildInviteShareMessage({
+ kind: 'match',
+ title,
+ url: inviteUrl,
+ meta: meta ?? 'Te he invitado a participar en esta partida',
+ }),
+ [inviteUrl, meta, title]
+ )
+
+ useEffect(() => {
+ return () => {
+ if (copiedResetRef.current) clearTimeout(copiedResetRef.current)
+ }
+ }, [])
+
+ const handleCopy = async () => {
+ try {
+ await Clipboard.setStringAsync(shareMessage)
+ setCopied(true)
+ if (copiedResetRef.current) clearTimeout(copiedResetRef.current)
+ copiedResetRef.current = setTimeout(() => setCopied(false), 2000)
+ } catch {
+ Alert.alert('Enlace', 'No se pudo copiar la invitación.')
+ }
+ }
+
+ const handleShare = async () => {
+ setSharing(true)
+ try {
+ await shareInviteViaWhatsApp(shareMessage)
+ } catch (err) {
+ Alert.alert(
+ 'Compartir',
+ err instanceof Error ? err.message : 'No se pudo abrir el menú de compartir.'
+ )
+ } finally {
+ setSharing(false)
+ }
+ }
+
+ return (
+ setCopied(false)}>
+
+ e.stopPropagation()}>
+
+ Comparte la invitación
+
+ ✕
+
+
+
+ Copia el enlace o compártelo para que tus amigos invitados puedan unirse a la partida.
+
+
+
+ {inviteUrl}
+
+
+ void handleCopy()}
+ color={Colors.primary}
+ />
+ void handleShare()}
+ />
+
+
+ {copied ? Invitación copiada : null}
+
+
+
+ )
+}
+
+const s = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.45)',
+ justifyContent: 'center',
+ padding: 24,
+ },
+ card: {
+ backgroundColor: Colors.background,
+ borderRadius: 16,
+ padding: 20,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ gap: 12,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 12,
+ },
+ title: { flex: 1, fontSize: 17, fontFamily: Fonts.bold, color: Colors.textPrimary },
+ close: { fontSize: 18, color: Colors.textSecondary, padding: 4 },
+ message: { fontSize: 14, color: Colors.textSecondary, lineHeight: 20 },
+ urlBox: {
+ backgroundColor: Colors.surface,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ padding: 12,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+ url: {
+ flex: 1,
+ fontSize: 13,
+ color: Colors.primary,
+ fontFamily: Fonts.medium,
+ },
+ actions: { flexDirection: 'row', alignItems: 'center', gap: 8 },
+ copiedHint: { fontSize: 13, color: Colors.primary, fontFamily: Fonts.semiBold },
+})
diff --git a/src/components/matches/SubmitResultModal.tsx b/src/components/matches/SubmitResultModal.tsx
index 4ade70b..0f0a60c 100644
--- a/src/components/matches/SubmitResultModal.tsx
+++ b/src/components/matches/SubmitResultModal.tsx
@@ -32,6 +32,8 @@ export function SubmitResultModal({
const hintParts = [`Tu equipo: ${viewerTeamLabel}`]
if (rivalAutoConfirms) {
hintParts.push('El rival no tiene cuenta: el marcador se confirmará al guardar.')
+ } else {
+ hintParts.push('El rival deberá validar el marcador.')
}
return (
diff --git a/src/components/profile/FriendsSection.tsx b/src/components/profile/FriendsSection.tsx
new file mode 100644
index 0000000..7728bf6
--- /dev/null
+++ b/src/components/profile/FriendsSection.tsx
@@ -0,0 +1,678 @@
+import { useEffect, useState } from 'react'
+import {
+ ActivityIndicator,
+ Alert,
+ Animated,
+ Dimensions,
+ Modal,
+ Platform,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+ type ViewStyle,
+} from 'react-native'
+import { Ionicons } from '@expo/vector-icons'
+import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'
+import { useRouter } from 'expo-router'
+import type { Href } from 'expo-router'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+
+import { AvatarCircle } from '@/components/profile/AvatarCircle'
+import { SendFriendRequestModal } from '@/components/profile/SendFriendRequestModal'
+import { Button } from '@/components/ui/Button'
+import {
+ useCancelFriendRequest,
+ useMyFriendRequests,
+ useMyFriends,
+ useRemoveFriend,
+ useRespondFriendRequest,
+ useSearchUsersByDisplayName,
+ type FriendRequestRow,
+ type FriendSummary,
+ type UserSearchHit,
+} from '@/hooks/useFriends'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+const FAB_SIZE = 56
+const FAB_GAP_ABOVE_TAB_BAR = 6
+const PANEL_WIDTH = Math.min(360, Math.round(Dimensions.get('window').width * 0.86))
+const SEARCH_DEBOUNCE_MS = 300
+
+type FriendsSectionProps = {
+ bottom?: number
+ right?: number
+}
+
+type InviteTarget = { userId: string; displayName: string } | null
+
+export function FriendsSection({ bottom, right = 20 }: FriendsSectionProps) {
+ const router = useRouter()
+ const insets = useSafeAreaInsets()
+ const tabBarHeight = useBottomTabBarHeight()
+ const [open, setOpen] = useState(false)
+ const [searchText, setSearchText] = useState('')
+ const [debouncedQuery, setDebouncedQuery] = useState('')
+ const [inviteTarget, setInviteTarget] = useState(null)
+ const [slide] = useState(() => new Animated.Value(PANEL_WIDTH))
+ const bottomOffset = bottom ?? tabBarHeight + FAB_GAP_ABOVE_TAB_BAR
+
+ const { data: friends, isPending: friendsPending } = useMyFriends()
+ const { data: received, isPending: receivedPending } = useMyFriendRequests('received')
+ const { data: sent, isPending: sentPending } = useMyFriendRequests('sent')
+ const {
+ data: searchHits,
+ isFetching: searchFetching,
+ isError: searchError,
+ } = useSearchUsersByDisplayName(debouncedQuery)
+
+ const receivedCount = received?.length ?? 0
+ const sentCount = sent?.length ?? 0
+ const friendsCount = friends?.length ?? 0
+ const totalPending = receivedCount + sentCount
+ const showSearchResults = debouncedQuery.trim().length >= 2
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedQuery(searchText.trim()), SEARCH_DEBOUNCE_MS)
+ return () => clearTimeout(timer)
+ }, [searchText])
+
+ useEffect(() => {
+ if (!open) return
+ slide.setValue(PANEL_WIDTH)
+ Animated.timing(slide, {
+ toValue: 0,
+ duration: 240,
+ useNativeDriver: true,
+ }).start()
+ }, [open, slide])
+
+ const clearDrawerState = () => {
+ setSearchText('')
+ setDebouncedQuery('')
+ }
+
+ const animateDrawerClosed = (onClosed?: () => void) => {
+ Animated.timing(slide, {
+ toValue: PANEL_WIDTH,
+ duration: 200,
+ useNativeDriver: true,
+ }).start(() => {
+ clearDrawerState()
+ setOpen(false)
+ onClosed?.()
+ })
+ }
+
+ const closeDrawer = () => {
+ animateDrawerClosed(() => setInviteTarget(null))
+ }
+
+ /** iOS cannot present a second RN Modal on top of the friends drawer Modal. */
+ const openInviteFromSearch = (hit: UserSearchHit) => {
+ const target = { userId: hit.user_id, displayName: hit.display_name }
+ animateDrawerClosed(() => {
+ const showInvite = () => setInviteTarget(target)
+ // Give iOS time to dismiss the drawer Modal before presenting pageSheet.
+ if (Platform.OS === 'ios') {
+ setTimeout(showInvite, 120)
+ } else {
+ showInvite()
+ }
+ })
+ }
+
+ const openFriendProfile = (uid: string) => {
+ animateDrawerClosed(() => {
+ setInviteTarget(null)
+ router.push(`/(tabs)/profile/${uid}` as Href)
+ })
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ Amigos
+
+ {friendsCount} {friendsCount === 1 ? 'amigo' : 'amigos'}
+ {totalPending > 0 ? ` · ${totalPending} pendientes` : ''}
+
+
+
+
+
+
+
+
+
+
+ {searchText.length > 0 ? (
+ setSearchText('')}
+ accessibilityRole="button"
+ accessibilityLabel="Limpiar búsqueda"
+ hitSlop={8}>
+
+
+ ) : null}
+
+
+
+ {showSearchResults ? (
+
+ ) : (
+ <>
+ Escribe al menos 2 letras para buscar usuarios.
+
+
+
+ >
+ )}
+
+
+
+
+
+ setInviteTarget(null)}
+ />
+
+
+ [s.fab, pressed && s.fabPressed]}
+ onPress={() => setOpen(true)}
+ accessibilityRole="button"
+ accessibilityLabel={
+ totalPending > 0
+ ? `Amigos, ${totalPending} solicitudes pendientes`
+ : 'Abrir menú de amigos'
+ }>
+
+ {totalPending > 0 ? (
+
+ {totalPending > 9 ? '9+' : String(totalPending)}
+
+ ) : null}
+
+
+ >
+ )
+}
+
+function UserSearchResults({
+ hits,
+ loading,
+ error,
+ onInvite,
+ onOpenProfile,
+}: {
+ hits: UserSearchHit[]
+ loading: boolean
+ error: boolean
+ onInvite: (hit: UserSearchHit) => void
+ onOpenProfile: (uid: string) => void
+}) {
+ if (loading && hits.length === 0) {
+ return (
+
+
+ Buscando…
+
+ )
+ }
+ if (error) {
+ return No se pudo buscar usuarios. Inténtalo de nuevo.
+ }
+ if (hits.length === 0) {
+ return No hay usuarios con ese nombre.
+ }
+
+ return (
+
+ Resultados
+
+ {hits.map((hit) => {
+ const canInvite = hit.friendship_status == null
+ const actionLabel =
+ hit.friendship_status === 'accepted'
+ ? 'Amigos'
+ : hit.friendship_status === 'pending'
+ ? 'Pendiente'
+ : 'Invitar'
+
+ return (
+
+ onOpenProfile(hit.user_id)}
+ accessibilityRole="button"
+ accessibilityLabel={`Ver perfil de ${hit.display_name}`}>
+
+
+
+ {hit.display_name}
+
+ {hit.city ? (
+
+ {hit.city}
+
+ ) : null}
+
+
+ onInvite(hit)}
+ disabled={!canInvite}
+ style={s.inviteBtn}
+ textStyle={s.smallBtnText}
+ />
+
+ )
+ })}
+
+
+ )
+}
+
+function FriendsList({
+ friends,
+ pending,
+ onOpenProfile,
+}: {
+ friends: FriendSummary[]
+ pending: boolean
+ onOpenProfile: (uid: string) => void
+}) {
+ const removeFriend = useRemoveFriend()
+
+ const handleRemove = (userId: string, displayName: string) => {
+ Alert.alert('Eliminar amigo', `¿Seguro que quieres eliminar a ${displayName} de tus amigos?`, [
+ { text: 'Cancelar', style: 'cancel' },
+ {
+ text: 'Eliminar',
+ style: 'destructive',
+ onPress: () => {
+ void removeFriend
+ .mutateAsync(userId)
+ .catch((err) =>
+ Alert.alert('No se pudo eliminar', err instanceof Error ? err.message : 'Error')
+ )
+ },
+ },
+ ])
+ }
+
+ if (pending && friends.length === 0) {
+ return Cargando amigos…
+ }
+ if (friends.length === 0) {
+ return (
+
+ Aún no tienes amigos. Busca por nombre o envía una solicitud desde el perfil de un usuario.
+
+ )
+ }
+ return (
+
+ {friends.map((f) => (
+
+ onOpenProfile(f.user_id)}
+ accessibilityRole="button"
+ accessibilityLabel={`Ver perfil de ${f.display_name}`}>
+
+
+
+ {f.display_name}
+
+ {f.city ? (
+
+ {f.city}
+
+ ) : null}
+
+
+ handleRemove(f.user_id, f.display_name)}
+ accessibilityRole="button"
+ accessibilityLabel={`Eliminar a ${f.display_name} de tus amigos`}
+ disabled={removeFriend.isPending}
+ style={s.removeBtn}>
+
+
+
+ ))}
+
+ )
+}
+
+function RequestsList({
+ title,
+ requests,
+ pending,
+ variant,
+}: {
+ title: string
+ requests: FriendRequestRow[]
+ pending: boolean
+ variant: 'received' | 'sent'
+}) {
+ const respond = useRespondFriendRequest()
+ const cancel = useCancelFriendRequest()
+ const [busyActions, setBusyActions] = useState