From 35f484ea2866fb5e18c9c8a7b668e7e2d54a5677 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 5 May 2026 20:39:19 +0200 Subject: [PATCH] =?UTF-8?q?Nexo=20P1+P2=20=E2=80=94=20user-facing=20surfac?= =?UTF-8?q?e:=20auth,=20onboarding,=20discover,=20matches,=20chat,=20profi?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the actual product the user sees, on top of the P0 bootstrap. The previous landing was a phase-card grid for navigating the plan; that's been replaced with a real auth-aware router and the routes the cards used to point at now exist as functional pages. Layout / context (`_lib/context.ts`, `+layout.svelte`): - Layout owns the session cell. `getNexoContext()` exposes `session()` / `setSession()` / `refreshSession()` so pages don't each issue their own `/api/session` and so a successful `login` / `register` propagates the user to every gate without a round-trip. - Logout clears the cell even when `api.logout()` rejects, so the UI never gets stuck on "authenticated" after a server hiccup. Landing (`+page.svelte`): - Pure router: anonymous → `/dating/login`, authenticated without a completed profile → `/dating/onboarding`, otherwise → `/dating/discover`. Renders a minimal pulse during the initial session probe. Auth (`login`, `register`, `reset`): - `AuthLayout` + `Field` shared shell so every form has the same centered card, focus ring and per-field error wiring. - Login maps server error codes (`invalid_credentials`, `account_restricted`, `mfa_required`) to localised messages and redirects to `/dating/onboarding` when the user has no profile yet. - Register validates locally (display name, email shape, ≥8-char password, password match, adult + rules confirm) and surfaces server codes (`email_already_used`, `weak_password`, `password_mismatch`, `adult_confirmation_required`) on the right field. Live password-strength meter. - Reset is anti-enumeration: same UI whether the email exists or not (matches the contract documented in `auth-y-fotos.md`). Onboarding (`/dating/onboarding`): - Four-step flow (Identity → About → Preferences → Review) with a progress stepper. Each step validates its own fields; clicking "Publicar" re-runs validation across every step so the user lands back on the broken one if anything regressed. On success the page PUTs `/api/profile/me` with `completed: true` and refreshes the session cell so guards downstream see the published profile. Discover (`/dating/discover`): - Filter sidebar (intent + min/max age) + card stack of profiles. - `ProfileCard` component renders the canonical profile card (photo, name + age, intent pill, location, bio, interests). - Like / pass actions call `/api/likes`; a match opens a small modal offering "Open chat" → `/dating/chat/{id}`. - Auto-fetches the next page when the queue drops to two cards. Matches (`/dating/matches`): - List of active matches with avatar, name, last update, and the start of the counterpart's bio. Each row links to the chat thread. Chat (`/dating/chat/[matchId]`): - Header with counterpart name + match state, scrollable timeline, composer at the bottom. - Optimistic send: the message lands as `queued` immediately, swapped for the server response on success. On failure the bubble flips to `failed` with retry/discard inline actions. Each provisional message carries a `clientNonce` for idempotency on the server side. Profile editor (`/dating/profile`): - Full-form editor mirroring onboarding's fields plus visibility. Tracks dirty state, surfaces server errors per-field, refreshes the session cell after saving so the cache and Discover stay coherent. - Header links to the photo manager. Photo manager (`/dating/profile/photos`): - Drop-zone + click-to-upload. Client validates format (JPEG/PNG/WebP), 5 MB cap, and the 6-photo ceiling before sending. Photos render in a grid with primary badge, order pills and per-photo actions (set primary, move up/down, delete). Each mutation reuses the API client and re-syncs the session cell. All pages use the typed `DatingApiClient` from P0 — no ad-hoc fetch calls — and route through `getNexoContext()` so the session is the single source of truth across the demo. Gates: 1695 tests + check (0 errors / 0 warnings) + build + bundle smoke (22.52 KB gzip) + aliases — all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/web/routes/dating/+layout.svelte | 52 +- src/web/routes/dating/+page.svelte | 184 +++---- .../dating/_components/AuthLayout.svelte | 91 ++++ .../routes/dating/_components/Field.svelte | 72 +++ .../dating/_components/ProfileCard.svelte | 157 ++++++ src/web/routes/dating/_lib/context.ts | 48 +- .../routes/dating/chat/[matchId]/+page.svelte | 345 ++++++++++++++ src/web/routes/dating/discover/+page.svelte | 409 ++++++++++++++++ src/web/routes/dating/login/+page.svelte | 156 ++++++ src/web/routes/dating/matches/+page.svelte | 241 ++++++++++ src/web/routes/dating/onboarding/+page.svelte | 448 ++++++++++++++++++ src/web/routes/dating/profile/+page.svelte | 326 +++++++++++++ .../routes/dating/profile/photos/+page.svelte | 440 +++++++++++++++++ src/web/routes/dating/register/+page.svelte | 285 +++++++++++ src/web/routes/dating/reset/+page.svelte | 142 ++++++ 15 files changed, 3240 insertions(+), 156 deletions(-) create mode 100644 src/web/routes/dating/_components/AuthLayout.svelte create mode 100644 src/web/routes/dating/_components/Field.svelte create mode 100644 src/web/routes/dating/_components/ProfileCard.svelte create mode 100644 src/web/routes/dating/chat/[matchId]/+page.svelte create mode 100644 src/web/routes/dating/discover/+page.svelte create mode 100644 src/web/routes/dating/login/+page.svelte create mode 100644 src/web/routes/dating/matches/+page.svelte create mode 100644 src/web/routes/dating/onboarding/+page.svelte create mode 100644 src/web/routes/dating/profile/+page.svelte create mode 100644 src/web/routes/dating/profile/photos/+page.svelte create mode 100644 src/web/routes/dating/register/+page.svelte create mode 100644 src/web/routes/dating/reset/+page.svelte diff --git a/src/web/routes/dating/+layout.svelte b/src/web/routes/dating/+layout.svelte index 714cb11..a40f152 100644 --- a/src/web/routes/dating/+layout.svelte +++ b/src/web/routes/dating/+layout.svelte @@ -2,7 +2,7 @@ import { onDestroy } from 'svelte'; import { page } from '$app/state'; import { createDatingApp } from './_lib/app'; - import { setNexoApp } from './_lib/context'; + import { setNexoContext } from './_lib/context'; import type { DatingSessionResponse } from './_lib/types'; let { children } = $props(); @@ -14,27 +14,36 @@ const handle = createDatingApp({ frontendTarget: () => appRoot }); - setNexoApp(handle); const { App, api } = handle; - // Eagerly probe `/api/session` once the layout mounts so nested - // pages don't each issue their own request. The result lives on - // `session` and feeds the nav's authenticated branch. + async function refreshSession(): Promise { + try { + const next = await api.session(); + session = next; + loadError = null; + return next; + } catch (error) { + loadError = error instanceof Error ? error.message : String(error); + return null; + } + } + + setNexoContext({ + ...handle, + session: () => session, + setSession: (next) => { + session = next; + loadError = null; + }, + refreshSession + }); + + // Eagerly probe `/api/session` once on mount so nested pages don't + // each issue their own request. Pages call `nexo.refreshSession()` + // after a login / logout to update the cell. $effect(() => { - let cancelled = false; - api - .session() - .then((next) => { - if (!cancelled) session = next; - }) - .catch((error) => { - if (cancelled) return; - loadError = error instanceof Error ? error.message : String(error); - }); - return () => { - cancelled = true; - }; + void refreshSession(); }); onDestroy(() => handle.dispose()); @@ -93,8 +102,11 @@ type="button" class="ghost" onclick={async () => { - await api.logout(); - session = { authenticated: false }; + try { + await api.logout(); + } finally { + session = { authenticated: false }; + } }} > {t('nexo.auth.logout')} diff --git a/src/web/routes/dating/+page.svelte b/src/web/routes/dating/+page.svelte index 8eb9ae1..5b379d1 100644 --- a/src/web/routes/dating/+page.svelte +++ b/src/web/routes/dating/+page.svelte @@ -1,137 +1,77 @@ -
-

{t('nexo.title')}

-

{t('nexo.tagline')}

-

- El servidor de demo vive aislado en servers/dating. Arráncalo con - npm run dating:server antes de explorar las rutas. -

-
- -
- {#each phases as phase (phase.title)} -
-

{phase.title}

-

{phase.description}

-
    - {#each phase.links as link (link.href)} -
  • {link.label}
  • - {/each} -
-
- {/each} -
+
+ + + + Cargando Nexo… +
diff --git a/src/web/routes/dating/_components/AuthLayout.svelte b/src/web/routes/dating/_components/AuthLayout.svelte new file mode 100644 index 0000000..c6231cc --- /dev/null +++ b/src/web/routes/dating/_components/AuthLayout.svelte @@ -0,0 +1,91 @@ + + + +
+
+
+

Nexo

+

{title}

+ {#if subtitle}

{subtitle}

{/if} +
+
+ {@render children()} +
+ {#if footer} +
+ {@render footer()} +
+ {/if} +
+
+ + diff --git a/src/web/routes/dating/_components/Field.svelte b/src/web/routes/dating/_components/Field.svelte new file mode 100644 index 0000000..a631b78 --- /dev/null +++ b/src/web/routes/dating/_components/Field.svelte @@ -0,0 +1,72 @@ + + + +
+ + {@render children()} + {#if error} + + {:else if hint} +

{hint}

+ {/if} +
+ + diff --git a/src/web/routes/dating/_components/ProfileCard.svelte b/src/web/routes/dating/_components/ProfileCard.svelte new file mode 100644 index 0000000..0008bf8 --- /dev/null +++ b/src/web/routes/dating/_components/ProfileCard.svelte @@ -0,0 +1,157 @@ + + + +
+
+ {#if main} + {`Foto + {:else} + + {/if} + {intentLabel} +
+ +
+
+

+ {profile.displayName} + {profile.age} +

+ {#if profile.approxLocation !== ''} +

{profile.approxLocation}

+ {/if} +
+ + {#if profile.bio !== ''} +

{profile.bio}

+ {/if} + + {#if profile.interests.length > 0} +
    + {#each profile.interests.slice(0, 8) as interest (interest)} +
  • {interest}
  • + {/each} +
+ {/if} +
+
+ + diff --git a/src/web/routes/dating/_lib/context.ts b/src/web/routes/dating/_lib/context.ts index 789f617..3b0d5b3 100644 --- a/src/web/routes/dating/_lib/context.ts +++ b/src/web/routes/dating/_lib/context.ts @@ -1,26 +1,46 @@ /** - * Svelte context bridge for `DatingAppHandle`. The shell layout calls - * `setNexoApp` once on mount; nested pages and components retrieve the - * app via `getNexoApp` instead of rebuilding it themselves. + * Svelte context bridge for the Nexo runtime: the `DatingAppHandle` + * (App + API) plus a session cell owned by the shell layout. Nested + * pages and components consume both via `getNexoContext()` instead of + * each rebuilding their own. * - * Using a symbol-keyed context (over `setContext('app', ...)`) avoids - * collisions with whatever the rest of the SvelteKit app might register - * under that string. + * Symbol-keyed (instead of `setContext('app', ...)`) so it can't + * collide with whatever else the surrounding SvelteKit app registers. + * + * `session` is exposed as a getter — pages call `nexo.session()` so + * `$derived(...)` and `$effect(...)` track session-aware UI without + * each page re-issuing its own `/api/session` probe. */ import { getContext, setContext } from 'svelte'; import type { DatingAppHandle } from './app.ts'; +import type { DatingSessionResponse } from './types.ts'; + +export interface NexoContext extends DatingAppHandle { + /** Current session snapshot. `null` while the initial probe is in flight. */ + session(): DatingSessionResponse | null; + /** Replace the session cell after a login / logout / refresh. */ + setSession(next: DatingSessionResponse | null): void; + /** Re-issue `/api/session`; layout uses it on mount, pages on demand. */ + refreshSession(): Promise; +} -const NEXO_APP_KEY = Symbol('nexo.app'); +const NEXO_CONTEXT_KEY = Symbol('nexo.context'); -export function setNexoApp(handle: DatingAppHandle): DatingAppHandle { - return setContext(NEXO_APP_KEY, handle); +export function setNexoContext(value: NexoContext): NexoContext { + return setContext(NEXO_CONTEXT_KEY, value); } -export function getNexoApp(): DatingAppHandle { - const handle = getContext(NEXO_APP_KEY); - if (handle === undefined) { - throw new Error('Nexo App is not in context. Did you mount /dating without its layout?'); +export function getNexoContext(): NexoContext { + const value = getContext(NEXO_CONTEXT_KEY); + if (value === undefined) { + throw new Error('Nexo context is not set. Did you mount /dating without its layout?'); } - return handle; + return value; } + +// Back-compat aliases — pages that only need the App handle keep the +// terser names. New code uses `getNexoContext()` for the session +// surface too. +export const setNexoApp = setNexoContext; +export const getNexoApp = getNexoContext; diff --git a/src/web/routes/dating/chat/[matchId]/+page.svelte b/src/web/routes/dating/chat/[matchId]/+page.svelte new file mode 100644 index 0000000..fb84e9a --- /dev/null +++ b/src/web/routes/dating/chat/[matchId]/+page.svelte @@ -0,0 +1,345 @@ + + + + {counterpartName} — Nexo + + +
+
+ ← +
+ {counterpartName} + {#if match !== null} + match {match.state} + {/if} +
+
+ + {#if topError !== null} + + {/if} + +
+ {#if loading} +

Cargando conversación…

+ {:else if allMessages.length === 0} +

+ Aún no hay mensajes. Rompe el hielo: pregunta por algo que viste en su perfil. +

+ {:else} + {#each allMessages as message (message.id)} + {@const mine = message.senderId === myUserId} +
+

{message.body}

+
+ + {#if message.state === 'queued'} + enviando… + {:else if message.state === 'failed'} + no enviado + + + {/if} +
+
+ {/each} + {/if} +
+ +
{ event.preventDefault(); void send(); }}> + + +
+
+ + diff --git a/src/web/routes/dating/discover/+page.svelte b/src/web/routes/dating/discover/+page.svelte new file mode 100644 index 0000000..f9fe79a --- /dev/null +++ b/src/web/routes/dating/discover/+page.svelte @@ -0,0 +1,409 @@ + + + + Descubre — Nexo + + +
+ + +
+ {#if topError !== null} + + {/if} + + {#if loading && profiles.length === 0} +
Buscando perfiles…
+ {:else if top === null} +
+

No hay más perfiles por ahora.

+

+ Prueba a ampliar los filtros o vuelve más tarde. Los perfiles se generan a partir + de las semillas locales. +

+ +
+ {:else} +
+ + {#if profiles.length > 1} +

{profiles.length - 1} perfiles más en cola

+ {/if} +
+ +
+ + +
+ {/if} +
+
+ +{#if lastMatch !== null} + +{/if} + + diff --git a/src/web/routes/dating/login/+page.svelte b/src/web/routes/dating/login/+page.svelte new file mode 100644 index 0000000..d9e1686 --- /dev/null +++ b/src/web/routes/dating/login/+page.svelte @@ -0,0 +1,156 @@ + + + + Iniciar sesión — Nexo + + + +
+ {#if error} + + {/if} + + + + + + + + + + +
+ + {#snippet footer()} + ¿Has olvidado la contraseña? + Crear cuenta + {/snippet} +
+ + diff --git a/src/web/routes/dating/matches/+page.svelte b/src/web/routes/dating/matches/+page.svelte new file mode 100644 index 0000000..5bc4bb6 --- /dev/null +++ b/src/web/routes/dating/matches/+page.svelte @@ -0,0 +1,241 @@ + + + + Matches — Nexo + + +
+
+

Tus matches

+

Cuando dos personas se gustan en Discover, aparecen aquí.

+
+ + {#if error !== null} + + {/if} + + {#if loading} +

Cargando matches…

+ {:else if matches.length === 0} +
+

Aún no tienes matches.

+

Da likes en Discover para empezar a conectar con otras personas.

+ Ir a Discover +
+ {:else} + + {/if} +
+ + diff --git a/src/web/routes/dating/onboarding/+page.svelte b/src/web/routes/dating/onboarding/+page.svelte new file mode 100644 index 0000000..477d9a0 --- /dev/null +++ b/src/web/routes/dating/onboarding/+page.svelte @@ -0,0 +1,448 @@ + + + + Onboarding — Nexo + + +
+
+

Nexo

+

Crea tu perfil

+

{STEPS[stepIndex].hint}

+
+ +
    + {#each STEPS as step, i (step.id)} +
  1. + {i + 1} + {step.label} +
  2. + {/each} +
+ + {#if topError} + + {/if} + +
+ {#if currentStep === 'identity'} + + + + + + + {:else if currentStep === 'about'} + + + + + + + {#if interests.length > 0} +
    + {#each interests as tag (tag)} +
  • {tag}
  • + {/each} +
+ {/if} + {:else if currentStep === 'preferences'} + + + + + + + + + + {:else if currentStep === 'review'} +
+
Nombre
{displayName}
+
Edad
{age}
+
Bio
{bio || '—'}
+
+
Intereses
+
{interests.length === 0 ? '—' : interests.join(', ')}
+
+
Buscando
{intent}
+
Ubicación
{approxLocation || '—'}
+
Visibilidad
{visibility}
+
+ {/if} +
+ +
+ + {#if isLast} + + {:else} + + {/if} +
+
+ + diff --git a/src/web/routes/dating/profile/+page.svelte b/src/web/routes/dating/profile/+page.svelte new file mode 100644 index 0000000..025990c --- /dev/null +++ b/src/web/routes/dating/profile/+page.svelte @@ -0,0 +1,326 @@ + + + + Perfil — Nexo + + +
+
+
+

Mi perfil

+

Edita tu información pública y la visibilidad del perfil.

+
+ Gestionar fotos +
+ + {#if topError !== null} + + {/if} + {#if savedAt !== null && !dirty} + + {/if} + +
{ + event.preventDefault(); + void save(); + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + diff --git a/src/web/routes/dating/profile/photos/+page.svelte b/src/web/routes/dating/profile/photos/+page.svelte new file mode 100644 index 0000000..9f4f242 --- /dev/null +++ b/src/web/routes/dating/profile/photos/+page.svelte @@ -0,0 +1,440 @@ + + + + Fotos — Nexo + + +
+
+ ← +
+

Fotos del perfil

+

+ Hasta {MAX_PHOTOS} fotos en JPEG / PNG / WebP, 5 MB cada una. La foto principal es la + que se muestra en Discover. +

+
+
+ + {#if topError !== null} + + {/if} + {#if perFileError !== null} + + {/if} + {#if info !== null && topError === null} + + {/if} + + + + {#if profile === null} +

Cargando perfil…

+ {:else if profile.photos.length === 0} +

Aún no tienes fotos. Sube la primera para empezar.

+ {:else} +
    + {#each profile.photoUrls as photo, index (photo.filename)} + {@const isPrimary = photo.filename === profile.primaryPhoto} +
  • + {`Foto +
    + {#if isPrimary} + Principal + {/if} + #{index + 1} +
    +
    + {#if !isPrimary} + + {/if} + + + +
    +
  • + {/each} +
+ {/if} +
+ + diff --git a/src/web/routes/dating/register/+page.svelte b/src/web/routes/dating/register/+page.svelte new file mode 100644 index 0000000..78497a9 --- /dev/null +++ b/src/web/routes/dating/register/+page.svelte @@ -0,0 +1,285 @@ + + + + Crear cuenta — Nexo + + + +
+ {#if topError} + + {/if} + + + + + + + + + + + + + {#if password !== ''} +
+ +
+ {/if} + + + + + + + {#if fieldErrors.adultConfirmed} + + {/if} + + + {#if fieldErrors.acceptedRules} + + {/if} + + +
+ + {#snippet footer()} + ¿Ya tienes cuenta? + Iniciar sesión + {/snippet} +
+ + diff --git a/src/web/routes/dating/reset/+page.svelte b/src/web/routes/dating/reset/+page.svelte new file mode 100644 index 0000000..4cf4c16 --- /dev/null +++ b/src/web/routes/dating/reset/+page.svelte @@ -0,0 +1,142 @@ + + + + Recuperar acceso — Nexo + + + + {#if sent} +
+

+ Si {email} tiene cuenta en Nexo, hemos preparado un proceso de recuperación + simulado. La demo no envía emails reales: revisa los logs del servidor para ver el + registro de la solicitud. +

+ +
+ {:else} +
+ {#if topError} + + {/if} + + + + +
+ {/if} + + {#snippet footer()} + Volver a iniciar sesión + {/snippet} +
+ +