Nexo P1+P2 — user-facing surface: auth, onboarding, discover, matches, chat, profile

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) <noreply@anthropic.com>
master
dev 5 months ago
parent 0edbb80a4f
commit 35f484ea28

@ -2,7 +2,7 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { createDatingApp } from './_lib/app'; import { createDatingApp } from './_lib/app';
import { setNexoApp } from './_lib/context'; import { setNexoContext } from './_lib/context';
import type { DatingSessionResponse } from './_lib/types'; import type { DatingSessionResponse } from './_lib/types';
let { children } = $props(); let { children } = $props();
@ -14,27 +14,36 @@
const handle = createDatingApp({ const handle = createDatingApp({
frontendTarget: () => appRoot frontendTarget: () => appRoot
}); });
setNexoApp(handle);
const { App, api } = handle; const { App, api } = handle;
// Eagerly probe `/api/session` once the layout mounts so nested async function refreshSession(): Promise<DatingSessionResponse | null> {
// pages don't each issue their own request. The result lives on try {
// `session` and feeds the nav's authenticated branch. 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(() => { $effect(() => {
let cancelled = false; void refreshSession();
api
.session()
.then((next) => {
if (!cancelled) session = next;
})
.catch((error) => {
if (cancelled) return;
loadError = error instanceof Error ? error.message : String(error);
});
return () => {
cancelled = true;
};
}); });
onDestroy(() => handle.dispose()); onDestroy(() => handle.dispose());
@ -93,8 +102,11 @@
type="button" type="button"
class="ghost" class="ghost"
onclick={async () => { onclick={async () => {
await api.logout(); try {
session = { authenticated: false }; await api.logout();
} finally {
session = { authenticated: false };
}
}} }}
> >
{t('nexo.auth.logout')} {t('nexo.auth.logout')}

@ -1,137 +1,77 @@
<script lang="ts"> <script lang="ts">
import { getNexoApp } from './_lib/context'; import { goto } from '$app/navigation';
import { getNexoContext } from './_lib/context';
const { App } = getNexoApp(); const nexo = getNexoContext();
const t = (key: Parameters<typeof App.lang.t>[0]) => App.lang.t(key);
// Phase 0 landing — links the routes that the plan says will exist // Landing is purely a router. The shell layout has already kicked
// across the full demo so `/dating` is navigable end-to-end before // off the `/api/session` probe; once it resolves we send the user
// the sub-pages exist. Plain hrefs (not the typed `resolve(...)`) // to the right surface and never render content here. While the
// because the targets aren't built yet; clicking them 404s, same // probe is in flight we show a minimal pulse so the page isn't
// UX as opening any unbuilt section of an SPA. // blank during a slow network hop.
const phases = [ $effect(() => {
{ const session = nexo.session();
title: 'Auth & Session', if (session === null) return;
description: 'Login, register, reset, MFA simulado.', if (!session.authenticated) {
links: [ void goto('/dating/login', { replaceState: true });
{ href: '/dating/login', label: 'Login' }, return;
{ href: '/dating/register', label: 'Register' },
{ href: '/dating/reset', label: 'Reset' },
{ href: '/dating/mfa', label: 'MFA' }
]
},
{
title: 'Profile',
description: 'Onboarding, perfil, fotos, preferencias.',
links: [
{ href: '/dating/onboarding', label: 'Onboarding' },
{ href: '/dating/profile', label: 'Profile' },
{ href: '/dating/profile/photos', label: 'Photos' }
]
},
{
title: 'Discover & Match',
description: 'Feed, like/pass, matches, chat realtime.',
links: [
{ href: '/dating/discover', label: 'Discover' },
{ href: '/dating/matches', label: 'Matches' }
]
},
{
title: 'Safety & Moderación',
description: 'Bloqueo, reporte, bandeja de moderación.',
links: [
{ href: '/dating/safety', label: 'Safety center' },
{ href: '/dating/admin', label: 'Admin' }
]
},
{
title: 'Diagnóstico',
description: 'Inspector de servicios y trazas del ecosistema.',
links: [{ href: '/dating/devtools', label: 'Devtools' }]
} }
]; if (session.profile === null || !session.profile.completed) {
void goto('/dating/onboarding', { replaceState: true });
return;
}
void goto('/dating/discover', { replaceState: true });
});
</script> </script>
<section class="hero"> <div class="splash" role="status" aria-live="polite">
<h1>{t('nexo.title')}</h1> <span class="dot"></span>
<p class="lead">{t('nexo.tagline')}</p> <span class="dot"></span>
<p class="hint"> <span class="dot"></span>
El servidor de demo vive aislado en <code>servers/dating</code>. Arráncalo con <span class="sr">Cargando Nexo…</span>
<code>npm run dating:server</code> antes de explorar las rutas. </div>
</p>
</section>
<section class="phases">
{#each phases as phase (phase.title)}
<article class="phase-card">
<h2>{phase.title}</h2>
<p>{phase.description}</p>
<ul>
{#each phase.links as link (link.href)}
<li><a href={link.href}>{link.label}</a></li>
{/each}
</ul>
</article>
{/each}
</section>
<style> <style>
.hero { .splash {
max-width: 56rem; display: flex;
margin: 0 auto 2rem; gap: 0.4rem;
} align-items: center;
.hero h1 { justify-content: center;
font-size: 2.25rem; min-height: 40vh;
margin-bottom: 0.25rem;
}
.lead {
font-size: 1.1rem;
opacity: 0.85;
margin: 0 0 0.75rem;
}
.hint {
font-size: 0.9rem;
opacity: 0.7;
margin: 0;
}
.hint code {
background: color-mix(in srgb, currentColor 10%, transparent);
padding: 0.1rem 0.35rem;
border-radius: 4px;
}
.phases {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 1rem;
max-width: 64rem;
margin: 0 auto;
} }
.phase-card { .dot {
padding: 1rem 1.1rem; width: 8px;
border: 1px solid color-mix(in srgb, currentColor 14%, transparent); height: 8px;
border-radius: 10px; border-radius: 999px;
background: color-mix(in srgb, canvas 96%, currentColor 4%); background: color-mix(in srgb, currentColor 60%, transparent);
animation: pulse 1.2s ease-in-out infinite;
} }
.phase-card h2 { .dot:nth-child(2) {
font-size: 1.05rem; animation-delay: 0.2s;
margin: 0 0 0.25rem;
} }
.phase-card p { .dot:nth-child(3) {
margin: 0 0 0.6rem; animation-delay: 0.4s;
opacity: 0.8;
font-size: 0.92rem;
} }
.phase-card ul { .sr {
margin: 0; position: absolute;
padding-left: 1rem; width: 1px;
display: flex; height: 1px;
flex-direction: column; padding: 0;
gap: 0.15rem; overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
} }
.phase-card a { @keyframes pulse {
color: inherit; 0%,
text-decoration: underline; 80%,
text-underline-offset: 2px; 100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
} }
</style> </style>

@ -0,0 +1,91 @@
<!--
Shared auth shell. Centers a card on the page, hosts a brand line on
top, and renders the consumer's form via children. Used by login,
register, reset and mfa so visual rhythm stays consistent and the
pages stay focused on their own form/state.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
subtitle?: string;
children: Snippet;
footer?: Snippet;
}
let { title, subtitle, children, footer }: Props = $props();
</script>
<section class="auth-frame">
<div class="auth-card">
<header>
<p class="brand">Nexo</p>
<h1>{title}</h1>
{#if subtitle}<p class="subtitle">{subtitle}</p>{/if}
</header>
<div class="body">
{@render children()}
</div>
{#if footer}
<footer>
{@render footer()}
</footer>
{/if}
</div>
</section>
<style>
.auth-frame {
min-height: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 2rem 1rem 4rem;
}
.auth-card {
width: 100%;
max-width: 26rem;
background: color-mix(in srgb, canvas 96%, currentColor 4%);
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
border-radius: 14px;
padding: 1.5rem 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
box-shadow: 0 1px 0 color-mix(in srgb, currentColor 6%, transparent);
}
header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.brand {
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
opacity: 0.6;
margin: 0;
}
h1 {
font-size: 1.4rem;
margin: 0;
}
.subtitle {
margin: 0;
opacity: 0.75;
font-size: 0.92rem;
}
.body {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
footer {
font-size: 0.85rem;
opacity: 0.8;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
</style>

@ -0,0 +1,72 @@
<!--
Form field primitive. Wraps a `<label>` + control + per-field error
in a single block so every form across the demo has the same focus
ring, spacing and error wiring without each page re-implementing it.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
id: string;
label: string;
hint?: string;
error?: string;
children: Snippet;
}
let { id, label, hint, error, children }: Props = $props();
const errorId = $derived(error ? `${id}-error` : undefined);
</script>
<div class="field" class:has-error={Boolean(error)}>
<label for={id}>{label}</label>
{@render children()}
{#if error}
<p id={errorId} class="error" role="alert">{error}</p>
{:else if hint}
<p class="hint">{hint}</p>
{/if}
</div>
<style>
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
label {
font-size: 0.85rem;
font-weight: 600;
}
:global(.field input),
:global(.field textarea),
:global(.field select) {
font: inherit;
font-size: 0.95rem;
padding: 0.55rem 0.7rem;
border-radius: 8px;
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
background: canvas;
color: inherit;
}
:global(.field input:focus-visible),
:global(.field textarea:focus-visible),
:global(.field select:focus-visible) {
outline: 2px solid color-mix(in srgb, #0366d6 70%, transparent);
outline-offset: 1px;
border-color: transparent;
}
.has-error :global(input),
.has-error :global(textarea),
.has-error :global(select) {
border-color: color-mix(in srgb, #d73a49 50%, transparent);
}
.hint {
font-size: 0.78rem;
opacity: 0.7;
margin: 0;
}
.error {
font-size: 0.82rem;
color: color-mix(in srgb, #d73a49 70%, currentColor);
margin: 0;
}
</style>

@ -0,0 +1,157 @@
<!--
Tarjeta canónica de perfil para Discover. Muestra foto principal,
nombre+edad, intent, ubicación aproximada, intereses y bio. Es
intencionalmente "dating-specific": vivirá bajo `_components/`
hasta que el plan-implementacion la promocione a `uix`.
-->
<script lang="ts">
import type { DatingProfile } from '../_lib/types';
interface Props {
profile: DatingProfile;
}
let { profile }: Props = $props();
const main = $derived(
profile.photoUrls.find((entry) => entry.filename === profile.primaryPhoto) ??
profile.photoUrls[0]
);
const intentLabel = $derived(
({
dating: 'Conocer gente',
long_term: 'Algo serio',
casual: 'Algo casual',
friends: 'Amistad',
unsure: 'Sin etiqueta'
} as const)[profile.intent]
);
</script>
<article class="card" data-intent={profile.intent}>
<div class="photo">
{#if main}
<img src={main.card} alt={`Foto de ${profile.displayName}`} loading="lazy" />
{:else}
<div class="photo-placeholder" aria-hidden="true">
<span>{profile.displayName.slice(0, 1).toUpperCase()}</span>
</div>
{/if}
<span class="intent-pill">{intentLabel}</span>
</div>
<div class="body">
<header>
<h2>
<span class="name">{profile.displayName}</span>
<span class="age">{profile.age}</span>
</h2>
{#if profile.approxLocation !== ''}
<p class="location">{profile.approxLocation}</p>
{/if}
</header>
{#if profile.bio !== ''}
<p class="bio">{profile.bio}</p>
{/if}
{#if profile.interests.length > 0}
<ul class="chips" aria-label="Intereses">
{#each profile.interests.slice(0, 8) as interest (interest)}
<li>{interest}</li>
{/each}
</ul>
{/if}
</div>
</article>
<style>
.card {
display: flex;
flex-direction: column;
border-radius: 14px;
overflow: hidden;
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: color-mix(in srgb, canvas 96%, currentColor 4%);
box-shadow: 0 1px 0 color-mix(in srgb, currentColor 6%, transparent);
max-width: 24rem;
width: 100%;
}
.photo {
position: relative;
aspect-ratio: 4 / 5;
background: color-mix(in srgb, currentColor 6%, transparent);
}
.photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-placeholder {
width: 100%;
height: 100%;
display: grid;
place-items: center;
font-size: 4rem;
font-weight: 600;
opacity: 0.4;
}
.intent-pill {
position: absolute;
bottom: 0.6rem;
left: 0.6rem;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
border-radius: 999px;
background: color-mix(in srgb, canvas 80%, transparent);
backdrop-filter: blur(6px);
font-weight: 600;
}
.body {
padding: 1rem 1.1rem 1.2rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
header h2 {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 1.25rem;
margin: 0;
}
header .age {
font-weight: 400;
opacity: 0.7;
}
.location {
margin: 0;
font-size: 0.85rem;
opacity: 0.7;
}
.bio {
margin: 0;
font-size: 0.92rem;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.chips {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
padding: 0;
margin: 0;
}
.chips li {
font-size: 0.78rem;
padding: 0.18rem 0.55rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
}
</style>

@ -1,26 +1,46 @@
/** /**
* Svelte context bridge for `DatingAppHandle`. The shell layout calls * Svelte context bridge for the Nexo runtime: the `DatingAppHandle`
* `setNexoApp` once on mount; nested pages and components retrieve the * (App + API) plus a session cell owned by the shell layout. Nested
* app via `getNexoApp` instead of rebuilding it themselves. * pages and components consume both via `getNexoContext()` instead of
* each rebuilding their own.
* *
* Using a symbol-keyed context (over `setContext('app', ...)`) avoids * Symbol-keyed (instead of `setContext('app', ...)`) so it can't
* collisions with whatever the rest of the SvelteKit app might register * collide with whatever else the surrounding SvelteKit app registers.
* under that string. *
* `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 { getContext, setContext } from 'svelte';
import type { DatingAppHandle } from './app.ts'; 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<DatingSessionResponse | null>;
}
const NEXO_APP_KEY = Symbol('nexo.app'); const NEXO_CONTEXT_KEY = Symbol('nexo.context');
export function setNexoApp(handle: DatingAppHandle): DatingAppHandle { export function setNexoContext(value: NexoContext): NexoContext {
return setContext(NEXO_APP_KEY, handle); return setContext(NEXO_CONTEXT_KEY, value);
} }
export function getNexoApp(): DatingAppHandle { export function getNexoContext(): NexoContext {
const handle = getContext<DatingAppHandle | undefined>(NEXO_APP_KEY); const value = getContext<NexoContext | undefined>(NEXO_CONTEXT_KEY);
if (handle === undefined) { if (value === undefined) {
throw new Error('Nexo App is not in context. Did you mount /dating without its layout?'); 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;

@ -0,0 +1,345 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { tick } from 'svelte';
import { getNexoContext } from '../../_lib/context';
import { DatingApiError } from '../../_lib/api';
import type { DatingMatch, DatingMessage, DatingProfile } from '../../_lib/types';
const nexo = getNexoContext();
const matchId = $derived(page.params.matchId ?? '');
let match = $state<DatingMatch | null>(null);
let messages = $state<DatingMessage[]>([]);
let pending = $state<DatingMessage[]>([]);
let loading = $state(true);
let composer = $state('');
let sending = $state(false);
let topError = $state<string | null>(null);
let timeline: HTMLElement | null = null;
$effect(() => {
const session = nexo.session();
if (session === null) return;
if (!session.authenticated) {
void goto('/dating/login', { replaceState: true });
return;
}
if (matchId !== '') void load();
});
async function load() {
loading = true;
topError = null;
try {
const [allMatches, response] = await Promise.all([
nexo.api.matches(),
nexo.api.messages(matchId)
]);
match = allMatches.find((entry) => entry.id === matchId) ?? null;
messages = [...response.messages];
pending = [];
await tick();
scrollToEnd();
} catch (caught) {
if (caught instanceof DatingApiError && caught.code === 'match_not_found') {
await goto('/dating/matches', { replaceState: true });
return;
}
topError = caught instanceof Error ? caught.message : String(caught);
} finally {
loading = false;
}
}
function counterpart(): DatingProfile | null {
const session = nexo.session();
if (session?.authenticated !== true || match === null) return null;
return match.profiles.find((profile) => profile.userId !== session.user.id) ?? null;
}
const myUserId = $derived.by(() => {
const session = nexo.session();
return session?.authenticated === true ? session.user.id : '';
});
const counterpartName = $derived(counterpart()?.displayName || 'Match');
function scrollToEnd() {
if (timeline === null) return;
timeline.scrollTop = timeline.scrollHeight;
}
function newNonce(): string {
return `nx-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
async function send() {
const body = composer.trim();
if (body === '' || sending) return;
const nonce = newNonce();
const provisional: DatingMessage = {
id: nonce,
matchId,
senderId: myUserId,
body,
state: 'queued',
clientNonce: nonce,
deliveredAt: '',
metadata: {},
created: new Date().toISOString(),
updated: new Date().toISOString()
};
pending = [...pending, provisional];
composer = '';
sending = true;
await tick();
scrollToEnd();
try {
const sent = await nexo.api.sendMessage(matchId, { body, clientNonce: nonce });
messages = [...messages, sent];
pending = pending.filter((entry) => entry.clientNonce !== nonce);
await tick();
scrollToEnd();
} catch (caught) {
pending = pending.map((entry) =>
entry.clientNonce === nonce ? { ...entry, state: 'failed' } : entry
);
topError = caught instanceof DatingApiError ? caught.message : String(caught);
} finally {
sending = false;
}
}
function retry(message: DatingMessage) {
pending = pending.filter((entry) => entry.clientNonce !== message.clientNonce);
composer = message.body;
void send();
}
function dropFailed(message: DatingMessage) {
pending = pending.filter((entry) => entry.clientNonce !== message.clientNonce);
}
function timeOf(iso: string): string {
if (iso === '') return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
const allMessages = $derived([...messages, ...pending]);
</script>
<svelte:head>
<title>{counterpartName} — Nexo</title>
</svelte:head>
<section class="chat">
<header class="chat-header">
<a href="/dating/matches" class="back" aria-label="Volver a matches">←</a>
<div class="who">
<strong>{counterpartName}</strong>
{#if match !== null}
<small>match {match.state}</small>
{/if}
</div>
</header>
{#if topError !== null}
<p class="banner error" role="alert">{topError}</p>
{/if}
<div class="timeline" bind:this={timeline}>
{#if loading}
<p class="empty pulse">Cargando conversación…</p>
{:else if allMessages.length === 0}
<p class="empty">
Aún no hay mensajes. Rompe el hielo: pregunta por algo que viste en su perfil.
</p>
{:else}
{#each allMessages as message (message.id)}
{@const mine = message.senderId === myUserId}
<article class="bubble" class:mine class:pending={message.state === 'queued'}
class:failed={message.state === 'failed'}>
<p>{message.body}</p>
<footer>
<time datetime={message.created}>{timeOf(message.created)}</time>
{#if message.state === 'queued'}
<span class="state">enviando…</span>
{:else if message.state === 'failed'}
<span class="state state-error">no enviado</span>
<button type="button" class="link" onclick={() => retry(message)}>reintentar</button>
<button type="button" class="link" onclick={() => dropFailed(message)}>descartar</button>
{/if}
</footer>
</article>
{/each}
{/if}
</div>
<form class="composer" onsubmit={(event) => { event.preventDefault(); void send(); }}>
<input
type="text"
placeholder="Escribe un mensaje…"
autocomplete="off"
bind:value={composer}
disabled={loading}
/>
<button type="submit" class="send" disabled={sending || composer.trim() === ''}>
Enviar
</button>
</form>
</section>
<style>
.chat {
max-width: 40rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: calc(100vh - 5rem);
}
.chat-header {
display: flex;
gap: 0.85rem;
align-items: center;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
}
.back {
font-size: 1.25rem;
text-decoration: none;
color: inherit;
padding: 0.2rem 0.45rem;
border-radius: 6px;
}
.back:hover {
background: color-mix(in srgb, currentColor 8%, transparent);
}
.who {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.who small {
opacity: 0.6;
font-size: 0.78rem;
}
.timeline {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 0.25rem;
min-height: 12rem;
max-height: calc(100vh - 14rem);
}
.bubble {
max-width: 78%;
padding: 0.5rem 0.7rem;
border-radius: 12px;
background: color-mix(in srgb, currentColor 6%, transparent);
align-self: flex-start;
}
.bubble.mine {
align-self: flex-end;
background: color-mix(in srgb, #c44a78 18%, canvas);
}
.bubble.pending {
opacity: 0.65;
}
.bubble.failed {
border: 1px solid color-mix(in srgb, #d73a49 50%, transparent);
background: color-mix(in srgb, #d73a49 8%, canvas);
}
.bubble p {
margin: 0;
font-size: 0.95rem;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
}
.bubble footer {
display: flex;
gap: 0.5rem;
font-size: 0.7rem;
opacity: 0.75;
margin-top: 0.25rem;
align-items: center;
}
.state-error {
color: color-mix(in srgb, #d73a49 70%, currentColor);
font-weight: 600;
}
.link {
font: inherit;
font-size: 0.7rem;
background: none;
border: none;
color: inherit;
cursor: pointer;
text-decoration: underline;
padding: 0;
}
.composer {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.25rem;
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: canvas;
}
.composer input {
font: inherit;
flex: 1;
padding: 0.6rem 0.8rem;
border-radius: 8px;
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
background: canvas;
color: inherit;
}
.composer input:focus-visible {
outline: 2px solid color-mix(in srgb, #0366d6 70%, transparent);
outline-offset: 1px;
border-color: transparent;
}
.composer .send {
font: inherit;
font-weight: 600;
padding: 0.55rem 1rem;
border-radius: 8px;
border: none;
background: color-mix(in srgb, currentColor 88%, transparent);
color: canvas;
cursor: pointer;
}
.composer .send:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.banner.error {
padding: 0.55rem 0.85rem;
border-radius: 8px;
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
font-size: 0.9rem;
margin: 0;
}
.empty {
text-align: center;
opacity: 0.7;
padding: 2rem 1rem;
}
.empty.pulse {
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
</style>

@ -0,0 +1,409 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import {
DATING_INTENTS,
type DatingDiscoverFilters,
type DatingIntent,
type DatingMatch,
type DatingProfile
} from '../_lib/types';
import ProfileCard from '../_components/ProfileCard.svelte';
const nexo = getNexoContext();
// Auth + onboarding gate.
$effect(() => {
const session = nexo.session();
if (session === null) return;
if (!session.authenticated) {
void goto('/dating/login', { replaceState: true });
return;
}
if (session.profile === null || !session.profile.completed) {
void goto('/dating/onboarding', { replaceState: true });
}
});
// Filters — kept simple for Phase 1: intent + age range. The API
// also accepts `q` and `cursor` but they're not surfaced yet.
let intent = $state<'all' | DatingIntent>('all');
let minAge = $state<number | null>(null);
let maxAge = $state<number | null>(null);
let profiles = $state<DatingProfile[]>([]);
let cursor = $state<string | null>(null);
let loading = $state(false);
let loadingMore = $state(false);
let topError = $state<string | null>(null);
let actionPending = $state(false);
let lastMatch = $state<DatingMatch | null>(null);
function buildFilters(extra: Partial<DatingDiscoverFilters> = {}): DatingDiscoverFilters {
const filters: { -readonly [K in keyof DatingDiscoverFilters]?: DatingDiscoverFilters[K] } =
{};
if (intent !== 'all') filters.intent = intent;
if (minAge !== null) filters.minAge = minAge;
if (maxAge !== null) filters.maxAge = maxAge;
Object.assign(filters, extra);
return filters;
}
async function load(reset = false) {
if (loading) return;
loading = true;
topError = null;
try {
const response = await nexo.api.discover(buildFilters());
profiles = reset ? [...response.profiles] : [...profiles, ...response.profiles];
cursor = response.nextCursor;
} catch (caught) {
topError = friendlyError(caught);
} finally {
loading = false;
}
}
async function loadMore() {
if (cursor === null || loadingMore) return;
loadingMore = true;
try {
const response = await nexo.api.discover(buildFilters({ cursor }));
profiles = [...profiles, ...response.profiles];
cursor = response.nextCursor;
} catch (caught) {
topError = friendlyError(caught);
} finally {
loadingMore = false;
}
}
function friendlyError(caught: unknown): string {
if (caught instanceof DatingApiError) {
if (caught.code === 'network_error') return 'Sin conexión con Nexo.';
if (caught.code === 'session_required') return 'Tu sesión ha caducado.';
return caught.message;
}
return caught instanceof Error ? caught.message : String(caught);
}
$effect(() => {
const session = nexo.session();
if (session?.authenticated && session.profile?.completed) {
void load(true);
}
});
const top = $derived(profiles[0] ?? null);
async function decide(state: 'like' | 'pass') {
if (top === null || actionPending) return;
actionPending = true;
topError = null;
try {
if (state === 'like') {
const result = await nexo.api.like({ targetUserId: top.userId, state: 'like' });
if (result.match !== null) lastMatch = result.match;
} else {
await nexo.api.like({ targetUserId: top.userId, state: 'pass' });
}
profiles = profiles.slice(1);
if (profiles.length <= 2 && cursor !== null) {
void loadMore();
}
} catch (caught) {
topError = friendlyError(caught);
} finally {
actionPending = false;
}
}
function dismissMatch() {
lastMatch = null;
}
async function viewMatch() {
if (lastMatch === null) return;
await goto(`/dating/chat/${lastMatch.id}`);
}
</script>
<svelte:head>
<title>Descubre — Nexo</title>
</svelte:head>
<section class="layout">
<aside class="filters" aria-label="Filtros">
<h2>Filtros</h2>
<label>
<span>Buscando</span>
<select bind:value={intent} onchange={() => load(true)}>
<option value="all">Cualquiera</option>
{#each DATING_INTENTS as option (option)}
<option value={option}>{option}</option>
{/each}
</select>
</label>
<label>
<span>Edad mínima</span>
<input
type="number"
min="18"
max="120"
placeholder="—"
bind:value={minAge}
onchange={() => load(true)}
/>
</label>
<label>
<span>Edad máxima</span>
<input
type="number"
min="18"
max="120"
placeholder="—"
bind:value={maxAge}
onchange={() => load(true)}
/>
</label>
<button type="button" class="ghost" onclick={() => load(true)}>Refrescar</button>
</aside>
<div class="stack">
{#if topError !== null}
<p class="banner error" role="alert">{topError}</p>
{/if}
{#if loading && profiles.length === 0}
<div class="empty pulse">Buscando perfiles…</div>
{:else if top === null}
<div class="empty">
<h3>No hay más perfiles por ahora.</h3>
<p>
Prueba a ampliar los filtros o vuelve más tarde. Los perfiles se generan a partir
de las semillas locales.
</p>
<button type="button" class="ghost" onclick={() => load(true)}>
Volver a empezar
</button>
</div>
{:else}
<div class="card-stack">
<ProfileCard profile={top} />
{#if profiles.length > 1}
<p class="queue-meta">{profiles.length - 1} perfiles más en cola</p>
{/if}
</div>
<div class="actions">
<button
type="button"
class="action pass"
onclick={() => decide('pass')}
disabled={actionPending}
aria-label="Pasar"
title="Pasar"
>
✕
</button>
<button
type="button"
class="action like"
onclick={() => decide('like')}
disabled={actionPending}
aria-label="Me gusta"
title="Me gusta"
>
♥
</button>
</div>
{/if}
</div>
</section>
{#if lastMatch !== null}
<div class="match-toast" role="dialog" aria-modal="true" aria-labelledby="match-title">
<div class="match-card">
<h3 id="match-title">¡Es match!</h3>
<p>Acabáis de hacer match. Puedes empezar la conversación cuando quieras.</p>
<div class="match-actions">
<button type="button" class="ghost" onclick={dismissMatch}>Seguir mirando</button>
<button type="button" class="primary" onclick={viewMatch}>Abrir chat</button>
</div>
</div>
</div>
{/if}
<style>
.layout {
display: grid;
grid-template-columns: 14rem 1fr;
gap: 1.5rem;
max-width: 60rem;
margin: 0 auto;
}
@media (max-width: 700px) {
.layout {
grid-template-columns: 1fr;
}
}
.filters {
display: flex;
flex-direction: column;
gap: 0.7rem;
padding: 1rem;
border-radius: 12px;
border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
background: color-mix(in srgb, canvas 96%, currentColor 4%);
height: fit-content;
position: sticky;
top: 0;
}
.filters h2 {
margin: 0;
font-size: 1rem;
}
.filters label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.filters select,
.filters input {
font: inherit;
padding: 0.4rem 0.55rem;
border-radius: 7px;
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
background: canvas;
color: inherit;
}
.ghost {
font: inherit;
font-size: 0.85rem;
padding: 0.45rem 0.7rem;
border-radius: 7px;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
background: transparent;
color: inherit;
cursor: pointer;
}
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.card-stack {
display: flex;
flex-direction: column;
gap: 0.4rem;
align-items: center;
}
.queue-meta {
font-size: 0.78rem;
opacity: 0.6;
margin: 0;
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 0.25rem;
}
.action {
font: inherit;
width: 56px;
height: 56px;
border-radius: 999px;
font-size: 1.6rem;
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
background: canvas;
cursor: pointer;
transition: transform 0.1s ease;
}
.action:hover {
transform: translateY(-2px);
}
.action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.action.pass {
color: #d73a49;
}
.action.like {
color: #c44a78;
}
.banner.error {
width: 100%;
max-width: 24rem;
padding: 0.6rem 0.85rem;
border-radius: 8px;
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
font-size: 0.9rem;
margin: 0;
}
.empty {
max-width: 24rem;
padding: 2rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.6rem;
opacity: 0.85;
}
.empty.pulse {
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.4;
}
}
.match-toast {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: color-mix(in srgb, canvas 70%, transparent);
z-index: 50;
padding: 1rem;
}
.match-card {
max-width: 24rem;
width: 100%;
padding: 1.5rem;
border-radius: 14px;
background: canvas;
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.match-card h3 {
margin: 0;
font-size: 1.4rem;
}
.match-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.match-actions .primary {
font: inherit;
padding: 0.55rem 1rem;
border-radius: 8px;
border: none;
font-weight: 600;
background: color-mix(in srgb, currentColor 88%, transparent);
color: canvas;
cursor: pointer;
}
</style>

@ -0,0 +1,156 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import AuthLayout from '../_components/AuthLayout.svelte';
import Field from '../_components/Field.svelte';
const nexo = getNexoContext();
let email = $state('');
let password = $state('');
let submitting = $state(false);
let error = $state<string | null>(null);
let fieldErrors = $state<{ email?: string; password?: string }>({});
// Local validation: keep it minimal — the server is authoritative
// and returns structured codes (`weak_password`, etc.) that we map
// to localised messages below. Doing the heavy lifting here would
// just duplicate the server's contract.
function validate(): boolean {
const next: typeof fieldErrors = {};
if (email.trim() === '') next.email = 'Introduce tu email.';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
next.email = 'Email no válido.';
if (password === '') next.password = 'Introduce tu contraseña.';
fieldErrors = next;
return Object.keys(next).length === 0;
}
function messageFor(error: DatingApiError): string {
switch (error.code) {
case 'invalid_credentials':
return 'Email o contraseña incorrectos.';
case 'account_restricted':
return 'Tu cuenta está restringida. Contacta con soporte.';
case 'mfa_required':
return 'Esta cuenta requiere verificación adicional.';
case 'network_error':
return 'No se ha podido conectar con el servidor. Vuelve a intentarlo.';
default:
return error.message || 'Algo ha ido mal. Vuelve a intentarlo.';
}
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (submitting) return;
if (!validate()) return;
submitting = true;
error = null;
try {
const response = await nexo.api.login({ email: email.trim(), password });
nexo.setSession({
authenticated: true,
user: response.user,
profile: response.profile,
token: response.token
});
const next =
response.profile === null || !response.profile.completed
? '/dating/onboarding'
: '/dating/discover';
await goto(next, { replaceState: true });
} catch (caught) {
if (caught instanceof DatingApiError && caught.code === 'mfa_required') {
await goto('/dating/mfa');
return;
}
error =
caught instanceof DatingApiError
? messageFor(caught)
: caught instanceof Error
? caught.message
: String(caught);
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Iniciar sesión — Nexo</title>
</svelte:head>
<AuthLayout title="Iniciar sesión" subtitle="Bienvenido de vuelta a Nexo.">
<form onsubmit={submit} novalidate>
{#if error}
<p class="form-error" role="alert">{error}</p>
{/if}
<Field id="login-email" label="Email" error={fieldErrors.email}>
<input
id="login-email"
type="email"
autocomplete="email"
inputmode="email"
required
bind:value={email}
disabled={submitting}
/>
</Field>
<Field id="login-password" label="Contraseña" error={fieldErrors.password}>
<input
id="login-password"
type="password"
autocomplete="current-password"
required
bind:value={password}
disabled={submitting}
/>
</Field>
<button type="submit" class="primary" disabled={submitting}>
{submitting ? 'Comprobando…' : 'Entrar'}
</button>
</form>
{#snippet footer()}
<a href="/dating/reset">¿Has olvidado la contraseña?</a>
<a href="/dating/register">Crear cuenta</a>
{/snippet}
</AuthLayout>
<style>
form {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.form-error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
color: inherit;
padding: 0.6rem 0.8rem;
border-radius: 8px;
margin: 0;
font-size: 0.9rem;
}
.primary {
font: inherit;
padding: 0.7rem 1rem;
border-radius: 8px;
border: none;
background: color-mix(in srgb, currentColor 88%, transparent);
color: canvas;
font-weight: 600;
cursor: pointer;
margin-top: 0.25rem;
}
.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

@ -0,0 +1,241 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import type { DatingMatch, DatingProfile } from '../_lib/types';
const nexo = getNexoContext();
let matches = $state<DatingMatch[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
$effect(() => {
const session = nexo.session();
if (session === null) return;
if (!session.authenticated) {
void goto('/dating/login', { replaceState: true });
return;
}
void load();
});
async function load() {
loading = true;
error = null;
try {
matches = [...(await nexo.api.matches())];
} catch (caught) {
if (caught instanceof DatingApiError && caught.code === 'network_error') {
error = 'Sin conexión con Nexo.';
} else {
error = caught instanceof Error ? caught.message : String(caught);
}
} finally {
loading = false;
}
}
function counterpart(match: DatingMatch): DatingProfile | null {
const session = nexo.session();
if (session?.authenticated !== true) return null;
const me = session.user.id;
return match.profiles.find((profile) => profile.userId !== me) ?? null;
}
function avatar(match: DatingMatch): string | null {
const profile = counterpart(match);
if (!profile) return null;
const main =
profile.photoUrls.find((entry) => entry.filename === profile.primaryPhoto) ??
profile.photoUrls[0];
return main?.thumb ?? null;
}
function name(match: DatingMatch): string {
return counterpart(match)?.displayName || 'Perfil';
}
function summary(match: DatingMatch): string {
const profile = counterpart(match);
if (!profile) return '';
if (profile.approxLocation === '') return profile.bio.slice(0, 80);
return `${profile.approxLocation} · ${profile.bio.slice(0, 60)}`;
}
function relative(iso: string): string {
if (iso === '') return '';
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '';
const diffMs = Date.now() - then;
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 1) return 'ahora';
if (minutes < 60) return `hace ${minutes} min`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `hace ${hours} h`;
const days = Math.floor(hours / 24);
return `hace ${days} d`;
}
</script>
<svelte:head>
<title>Matches — Nexo</title>
</svelte:head>
<section class="wrap">
<header>
<h1>Tus matches</h1>
<p class="sub">Cuando dos personas se gustan en Discover, aparecen aquí.</p>
</header>
{#if error !== null}
<p class="banner error" role="alert">{error}</p>
{/if}
{#if loading}
<p class="empty pulse">Cargando matches…</p>
{:else if matches.length === 0}
<div class="empty">
<h2>Aún no tienes matches.</h2>
<p>Da likes en Discover para empezar a conectar con otras personas.</p>
<a class="primary" href="/dating/discover">Ir a Discover</a>
</div>
{:else}
<ul class="list">
{#each matches as match (match.id)}
<li>
<a class="row" href={`/dating/chat/${match.id}`}>
<span class="avatar" aria-hidden="true">
{#if avatar(match)}
<img src={avatar(match)} alt="" />
{:else}
<span>{name(match).slice(0, 1).toUpperCase()}</span>
{/if}
</span>
<span class="meta">
<span class="who">
<strong>{name(match)}</strong>
<small>{relative(match.updated || match.created)}</small>
</span>
<span class="summary">{summary(match)}</span>
</span>
</a>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.wrap {
max-width: 36rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
header h1 {
font-size: 1.5rem;
margin: 0;
}
header .sub {
margin: 0;
opacity: 0.75;
}
.banner.error {
padding: 0.6rem 0.85rem;
border-radius: 8px;
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
font-size: 0.9rem;
margin: 0;
}
.empty {
display: flex;
flex-direction: column;
gap: 0.6rem;
align-items: center;
text-align: center;
padding: 2rem 1rem;
opacity: 0.85;
}
.empty.pulse {
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.4;
}
}
.empty .primary {
font: inherit;
font-weight: 600;
padding: 0.55rem 1rem;
border-radius: 8px;
background: color-mix(in srgb, currentColor 88%, transparent);
color: canvas;
text-decoration: none;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.row {
display: flex;
gap: 0.85rem;
padding: 0.7rem 0.9rem;
border-radius: 12px;
background: color-mix(in srgb, canvas 96%, currentColor 4%);
border: 1px solid color-mix(in srgb, currentColor 8%, transparent);
text-decoration: none;
color: inherit;
transition: background 0.15s ease;
align-items: center;
}
.row:hover {
background: color-mix(in srgb, canvas 90%, currentColor 10%);
}
.avatar {
width: 48px;
height: 48px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 12%, transparent);
display: grid;
place-items: center;
overflow: hidden;
flex: none;
font-weight: 600;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.who {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: baseline;
}
.who small {
opacity: 0.6;
font-size: 0.78rem;
}
.summary {
opacity: 0.75;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

@ -0,0 +1,448 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import { DATING_INTENTS, type DatingIntent } from '../_lib/types';
import Field from '../_components/Field.svelte';
const nexo = getNexoContext();
// Guard the route — anonymous users go to login. The layout has
// already kicked off the session probe, so we just react to the
// cell.
$effect(() => {
const session = nexo.session();
if (session === null) return;
if (!session.authenticated) {
void goto('/dating/login', { replaceState: true });
return;
}
if (session.profile?.completed === true) {
void goto('/dating/discover', { replaceState: true });
}
});
type StepId = 'identity' | 'about' | 'preferences' | 'review';
const STEPS: ReadonlyArray<{ id: StepId; label: string; hint: string }> = [
{
id: 'identity',
label: 'Identidad',
hint: 'Cómo te ven los demás. Puedes cambiarlo más tarde.'
},
{ id: 'about', label: 'Sobre ti', hint: 'Una bio breve y tus intereses.' },
{
id: 'preferences',
label: 'Preferencias',
hint: '¿Qué buscas ahora mismo en Nexo?'
},
{ id: 'review', label: 'Revisar', hint: 'Comprueba y publica tu perfil.' }
];
let stepIndex = $state(0);
const currentStep = $derived(STEPS[stepIndex].id);
const isLast = $derived(stepIndex === STEPS.length - 1);
const initialSession = nexo.session();
const initialProfile =
initialSession?.authenticated === true ? initialSession.profile : null;
const initialUser =
initialSession?.authenticated === true ? initialSession.user : null;
let displayName = $state(
initialProfile?.displayName || initialUser?.displayName || ''
);
let age = $state(initialProfile?.age && initialProfile.age >= 18 ? initialProfile.age : 25);
let bio = $state(initialProfile?.bio ?? '');
let interestsRaw = $state(initialProfile?.interests.join(', ') ?? '');
let intent = $state<DatingIntent>(initialProfile?.intent ?? 'unsure');
let approxLocation = $state(initialProfile?.approxLocation ?? '');
let visibility = $state<'visible' | 'hidden' | 'paused'>(
initialProfile?.visibility === 'visible' ? 'visible' : 'visible'
);
let submitting = $state(false);
let topError = $state<string | null>(null);
let fieldErrors = $state<Record<string, string>>({});
const interests = $derived(
interestsRaw
.split(',')
.map((entry: string) => entry.trim())
.filter((entry: string) => entry !== '')
.slice(0, 30)
);
function validateStep(step: StepId): boolean {
const next: Record<string, string> = {};
if (step === 'identity') {
if (displayName.trim().length < 2) next.displayName = 'Al menos 2 caracteres.';
else if (displayName.length > 80) next.displayName = 'Máximo 80 caracteres.';
if (!Number.isInteger(age) || age < 18 || age > 120) next.age = 'Edad entre 18 y 120.';
}
if (step === 'about') {
if (bio.length > 500) next.bio = 'Máximo 500 caracteres.';
if (interests.length === 0) next.interestsRaw = 'Añade al menos un interés.';
if (interests.some((entry: string) => entry.length > 60))
next.interestsRaw = 'Cada interés debe ocupar 60 caracteres como máximo.';
}
if (step === 'preferences') {
if (!DATING_INTENTS.includes(intent)) next.intent = 'Selecciona una opción válida.';
if (approxLocation.length > 120) next.approxLocation = 'Máximo 120 caracteres.';
}
fieldErrors = next;
return Object.keys(next).length === 0;
}
function next() {
if (!validateStep(currentStep)) return;
stepIndex = Math.min(stepIndex + 1, STEPS.length - 1);
}
function back() {
stepIndex = Math.max(0, stepIndex - 1);
}
function messageFor(error: DatingApiError): string {
switch (error.code) {
case 'missing_display_name':
return 'Falta el nombre visible.';
case 'invalid_age':
return 'La edad debe estar entre 18 y 120.';
case 'profile_required':
return 'No se ha encontrado el perfil. Vuelve a iniciar sesión.';
case 'network_error':
return 'No se ha podido contactar con el servidor.';
default:
return error.message;
}
}
async function publish() {
if (submitting) return;
// Validate every step in order so errors land where they belong.
for (const step of STEPS) {
if (!validateStep(step.id)) {
stepIndex = STEPS.findIndex((s) => s.id === step.id);
return;
}
}
submitting = true;
topError = null;
try {
const updated = await nexo.api.updateMyProfile({
displayName: displayName.trim(),
age,
bio: bio.trim(),
interests,
intent,
approxLocation: approxLocation.trim(),
visibility,
completed: true
});
const session = nexo.session();
if (session?.authenticated === true) {
nexo.setSession({ ...session, profile: updated });
}
await goto('/dating/discover', { replaceState: true });
} catch (caught) {
topError =
caught instanceof DatingApiError
? messageFor(caught)
: caught instanceof Error
? caught.message
: String(caught);
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Onboarding — Nexo</title>
</svelte:head>
<section class="wrap">
<header class="head">
<p class="brand">Nexo</p>
<h1>Crea tu perfil</h1>
<p class="sub">{STEPS[stepIndex].hint}</p>
</header>
<ol class="stepper" aria-label="Progreso de onboarding">
{#each STEPS as step, i (step.id)}
<li
class:active={i === stepIndex}
class:done={i < stepIndex}
aria-current={i === stepIndex ? 'step' : undefined}
>
<span class="dot">{i + 1}</span>
<span class="label">{step.label}</span>
</li>
{/each}
</ol>
{#if topError}
<p class="form-error" role="alert">{topError}</p>
{/if}
<div class="step">
{#if currentStep === 'identity'}
<Field id="ob-name" label="Nombre visible" error={fieldErrors.displayName}>
<input
id="ob-name"
type="text"
autocomplete="nickname"
maxlength="80"
required
bind:value={displayName}
/>
</Field>
<Field
id="ob-age"
label="Edad"
hint="Solo personas adultas. La demo no permite menores."
error={fieldErrors.age}
>
<input id="ob-age" type="number" min="18" max="120" required bind:value={age} />
</Field>
{:else if currentStep === 'about'}
<Field
id="ob-bio"
label="Bio"
hint="Cuéntanos algo en 2-3 frases. Máximo 500 caracteres."
error={fieldErrors.bio}
>
<textarea id="ob-bio" rows={4} maxlength="500" bind:value={bio}></textarea>
</Field>
<Field
id="ob-interests"
label="Intereses"
hint="Sepáralos por comas. Hasta 30 entradas."
error={fieldErrors.interestsRaw}
>
<input
id="ob-interests"
type="text"
placeholder="cine, senderismo, jazz, café"
bind:value={interestsRaw}
/>
</Field>
{#if interests.length > 0}
<ul class="chips" aria-label="Vista previa de intereses">
{#each interests as tag (tag)}
<li>{tag}</li>
{/each}
</ul>
{/if}
{:else if currentStep === 'preferences'}
<Field id="ob-intent" label="¿Qué buscas?" error={fieldErrors.intent}>
<select id="ob-intent" bind:value={intent}>
<option value="dating">Algo serio</option>
<option value="long_term">Relación a largo plazo</option>
<option value="casual">Algo casual</option>
<option value="friends">Amistad</option>
<option value="unsure">Aún no lo sé</option>
</select>
</Field>
<Field
id="ob-location"
label="Ubicación aproximada"
hint="Texto libre — no se guarda tu ubicación exacta."
error={fieldErrors.approxLocation}
>
<input
id="ob-location"
type="text"
maxlength="120"
placeholder="Madrid, ES"
bind:value={approxLocation}
/>
</Field>
<Field
id="ob-visibility"
label="Visibilidad inicial"
hint="Puedes ocultar el perfil en cualquier momento desde Profile."
>
<select id="ob-visibility" bind:value={visibility}>
<option value="visible">Visible para otros</option>
<option value="hidden">Oculto (solo tú lo ves)</option>
<option value="paused">Pausado temporalmente</option>
</select>
</Field>
{:else if currentStep === 'review'}
<dl class="review">
<div><dt>Nombre</dt><dd>{displayName}</dd></div>
<div><dt>Edad</dt><dd>{age}</dd></div>
<div><dt>Bio</dt><dd>{bio || '—'}</dd></div>
<div>
<dt>Intereses</dt>
<dd>{interests.length === 0 ? '—' : interests.join(', ')}</dd>
</div>
<div><dt>Buscando</dt><dd>{intent}</dd></div>
<div><dt>Ubicación</dt><dd>{approxLocation || '—'}</dd></div>
<div><dt>Visibilidad</dt><dd>{visibility}</dd></div>
</dl>
{/if}
</div>
<footer class="actions">
<button type="button" class="ghost" onclick={back} disabled={submitting || stepIndex === 0}>
Atrás
</button>
{#if isLast}
<button type="button" class="primary" onclick={publish} disabled={submitting}>
{submitting ? 'Publicando…' : 'Publicar perfil'}
</button>
{:else}
<button type="button" class="primary" onclick={next} disabled={submitting}>
Siguiente
</button>
{/if}
</footer>
</section>
<style>
.wrap {
max-width: 38rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.head {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.brand {
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
opacity: 0.6;
margin: 0;
}
h1 {
font-size: 1.5rem;
margin: 0;
}
.sub {
margin: 0;
opacity: 0.75;
}
.stepper {
list-style: none;
padding: 0;
margin: 0;
display: flex;
gap: 0.4rem;
}
.stepper li {
flex: 1;
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 0.6rem;
border-radius: 8px;
font-size: 0.85rem;
background: color-mix(in srgb, currentColor 4%, transparent);
opacity: 0.65;
}
.stepper li.active {
opacity: 1;
background: color-mix(in srgb, currentColor 12%, transparent);
font-weight: 600;
}
.stepper li.done {
opacity: 0.85;
}
.stepper .dot {
width: 22px;
height: 22px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 18%, transparent);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.78rem;
}
.stepper li.active .dot {
background: color-mix(in srgb, currentColor 80%, transparent);
color: canvas;
}
.step {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1.25rem;
border-radius: 12px;
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: color-mix(in srgb, canvas 96%, currentColor 4%);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
.chips li {
font-size: 0.78rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
}
.review {
margin: 0;
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.4rem 1rem;
}
.review div {
display: contents;
}
.review dt {
font-weight: 600;
opacity: 0.7;
font-size: 0.85rem;
}
.review dd {
margin: 0;
font-size: 0.95rem;
}
.actions {
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
.actions .primary {
font: inherit;
font-weight: 600;
padding: 0.6rem 1.1rem;
border-radius: 8px;
border: none;
background: color-mix(in srgb, currentColor 88%, transparent);
color: canvas;
cursor: pointer;
}
.actions .ghost {
font: inherit;
padding: 0.6rem 1rem;
border-radius: 8px;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
background: transparent;
color: inherit;
cursor: pointer;
}
.actions button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.form-error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
padding: 0.6rem 0.8rem;
border-radius: 8px;
margin: 0;
font-size: 0.9rem;
}
</style>

@ -0,0 +1,326 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import {
DATING_INTENTS,
DATING_VISIBILITIES,
type DatingIntent,
type DatingProfile,
type DatingVisibility
} from '../_lib/types';
import Field from '../_components/Field.svelte';
const nexo = getNexoContext();
let profile = $state<DatingProfile | null>(null);
let displayName = $state('');
let age = $state(25);
let bio = $state('');
let interestsRaw = $state('');
let intent = $state<DatingIntent>('unsure');
let approxLocation = $state('');
let visibility = $state<DatingVisibility>('hidden');
let dirty = $state(false);
let saving = $state(false);
let savedAt = $state<number | null>(null);
let topError = $state<string | null>(null);
let fieldErrors = $state<Record<string, string>>({});
$effect(() => {
const session = nexo.session();
if (session === null) return;
if (!session.authenticated) {
void goto('/dating/login', { replaceState: true });
return;
}
if (!session.profile?.completed) {
void goto('/dating/onboarding', { replaceState: true });
return;
}
hydrate(session.profile);
});
function hydrate(next: DatingProfile) {
profile = next;
displayName = next.displayName;
age = next.age;
bio = next.bio;
interestsRaw = next.interests.join(', ');
intent = next.intent;
approxLocation = next.approxLocation;
visibility = next.visibility;
dirty = false;
fieldErrors = {};
topError = null;
}
const interests = $derived(
interestsRaw
.split(',')
.map((entry: string) => entry.trim())
.filter((entry: string) => entry !== '')
.slice(0, 30)
);
function markDirty() {
dirty = true;
savedAt = null;
}
function validate(): boolean {
const next: Record<string, string> = {};
if (displayName.trim().length < 2) next.displayName = 'Al menos 2 caracteres.';
if (!Number.isInteger(age) || age < 18 || age > 120) next.age = 'Entre 18 y 120.';
if (bio.length > 500) next.bio = 'Máximo 500 caracteres.';
if (interests.some((entry: string) => entry.length > 60))
next.interestsRaw = 'Cada interés debe tener 60 caracteres como máximo.';
if (approxLocation.length > 120) next.approxLocation = 'Máximo 120 caracteres.';
fieldErrors = next;
return Object.keys(next).length === 0;
}
async function save() {
if (saving || !dirty) return;
if (!validate()) return;
saving = true;
topError = null;
try {
const updated = await nexo.api.updateMyProfile({
displayName: displayName.trim(),
age,
bio: bio.trim(),
interests,
intent,
approxLocation: approxLocation.trim(),
visibility,
completed: true
});
const session = nexo.session();
if (session?.authenticated === true) {
nexo.setSession({ ...session, profile: updated });
}
hydrate(updated);
savedAt = Date.now();
} catch (caught) {
topError =
caught instanceof DatingApiError
? caught.message
: caught instanceof Error
? caught.message
: String(caught);
} finally {
saving = false;
}
}
function resetChanges() {
if (profile !== null) hydrate(profile);
}
</script>
<svelte:head>
<title>Perfil — Nexo</title>
</svelte:head>
<section class="wrap">
<header class="head">
<div>
<h1>Mi perfil</h1>
<p class="sub">Edita tu información pública y la visibilidad del perfil.</p>
</div>
<a class="ghost" href="/dating/profile/photos">Gestionar fotos</a>
</header>
{#if topError !== null}
<p class="banner error" role="alert">{topError}</p>
{/if}
{#if savedAt !== null && !dirty}
<p class="banner ok" role="status">Cambios guardados.</p>
{/if}
<form
onsubmit={(event) => {
event.preventDefault();
void save();
}}
>
<Field id="prof-name" label="Nombre visible" error={fieldErrors.displayName}>
<input
id="prof-name"
type="text"
maxlength="80"
bind:value={displayName}
oninput={markDirty}
disabled={saving}
/>
</Field>
<Field id="prof-age" label="Edad" error={fieldErrors.age}>
<input
id="prof-age"
type="number"
min="18"
max="120"
bind:value={age}
oninput={markDirty}
disabled={saving}
/>
</Field>
<Field id="prof-bio" label="Bio" hint="Hasta 500 caracteres." error={fieldErrors.bio}>
<textarea
id="prof-bio"
rows={4}
maxlength="500"
bind:value={bio}
oninput={markDirty}
disabled={saving}
></textarea>
</Field>
<Field
id="prof-interests"
label="Intereses"
hint="Sepáralos por comas. Hasta 30."
error={fieldErrors.interestsRaw}
>
<input
id="prof-interests"
type="text"
bind:value={interestsRaw}
oninput={markDirty}
disabled={saving}
/>
</Field>
<Field id="prof-intent" label="¿Qué buscas?">
<select id="prof-intent" bind:value={intent} onchange={markDirty} disabled={saving}>
{#each DATING_INTENTS as option (option)}
<option value={option}>{option}</option>
{/each}
</select>
</Field>
<Field
id="prof-location"
label="Ubicación aproximada"
error={fieldErrors.approxLocation}
>
<input
id="prof-location"
type="text"
maxlength="120"
bind:value={approxLocation}
oninput={markDirty}
disabled={saving}
/>
</Field>
<Field
id="prof-visibility"
label="Visibilidad"
hint="`hidden` te oculta de Discover; `paused` mantiene matches pero pausa el feed."
>
<select
id="prof-visibility"
bind:value={visibility}
onchange={markDirty}
disabled={saving}
>
{#each DATING_VISIBILITIES as option (option)}
<option value={option}>{option}</option>
{/each}
</select>
</Field>
<div class="actions">
<button
type="button"
class="ghost"
onclick={resetChanges}
disabled={!dirty || saving}
>
Descartar cambios
</button>
<button type="submit" class="primary" disabled={!dirty || saving}>
{saving ? 'Guardando…' : 'Guardar cambios'}
</button>
</div>
</form>
</section>
<style>
.wrap {
max-width: 36rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.head h1 {
font-size: 1.5rem;
margin: 0;
}
.head .sub {
margin: 0;
opacity: 0.75;
}
form {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.primary {
font: inherit;
padding: 0.6rem 1.1rem;
border-radius: 8px;
border: none;
background: color-mix(in srgb, currentColor 88%, transparent);
color: canvas;
font-weight: 600;
cursor: pointer;
}
.ghost {
font: inherit;
padding: 0.55rem 1rem;
border-radius: 8px;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
background: transparent;
color: inherit;
cursor: pointer;
text-decoration: none;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.banner {
padding: 0.55rem 0.85rem;
border-radius: 8px;
font-size: 0.9rem;
margin: 0;
}
.banner.error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
}
.banner.ok {
background: color-mix(in srgb, #2da44e 14%, canvas);
border: 1px solid color-mix(in srgb, #2da44e 35%, transparent);
}
</style>

@ -0,0 +1,440 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from '../../_lib/context';
import { DatingApiError } from '../../_lib/api';
import type { DatingProfile } from '../../_lib/types';
const nexo = getNexoContext();
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5 MB per the auth-y-fotos.md spec
const MAX_PHOTOS = 6;
let profile = $state<DatingProfile | null>(null);
let dragOver = $state(false);
let working = $state(false);
let topError = $state<string | null>(null);
let perFileError = $state<string | null>(null);
let info = $state<string | null>(null);
$effect(() => {
const session = nexo.session();
if (session === null) return;
if (!session.authenticated) {
void goto('/dating/login', { replaceState: true });
return;
}
if (!session.profile?.completed) {
void goto('/dating/onboarding', { replaceState: true });
return;
}
profile = session.profile;
});
async function syncSession(updated: DatingProfile) {
profile = updated;
const session = nexo.session();
if (session?.authenticated === true) {
nexo.setSession({ ...session, profile: updated });
}
}
function validate(file: File): string | null {
if (!ALLOWED_TYPES.includes(file.type as (typeof ALLOWED_TYPES)[number])) {
return `Formato no permitido (${file.type || 'desconocido'}). Solo JPEG, PNG o WebP.`;
}
if (file.size > MAX_FILE_BYTES) {
return `${file.name} pesa más de 5 MB.`;
}
return null;
}
async function uploadFiles(files: FileList | File[]) {
if (profile === null || working) return;
const list = Array.from(files);
topError = null;
perFileError = null;
info = null;
const remainingSlots = MAX_PHOTOS - profile.photos.length;
if (remainingSlots <= 0) {
topError = `Has llegado al límite de ${MAX_PHOTOS} fotos. Borra alguna antes de subir más.`;
return;
}
const accepted: File[] = [];
for (const file of list) {
if (accepted.length >= remainingSlots) {
perFileError = `Solo puedes subir ${remainingSlots} foto(s) más en esta sesión.`;
break;
}
const error = validate(file);
if (error !== null) {
perFileError = perFileError ? `${perFileError} ${error}` : error;
continue;
}
accepted.push(file);
}
if (accepted.length === 0) return;
working = true;
try {
let next = profile;
for (const file of accepted) {
next = await nexo.api.uploadPhoto(file);
}
await syncSession(next);
info = `Subidas ${accepted.length} foto(s).`;
} catch (caught) {
topError = friendly(caught);
} finally {
working = false;
}
}
function friendly(caught: unknown): string {
if (caught instanceof DatingApiError) {
if (caught.code === 'too_many_photos')
return `Has llegado al límite de ${MAX_PHOTOS} fotos.`;
if (caught.code === 'invalid_photo')
return 'El servidor ha rechazado la foto. Prueba con otro archivo.';
if (caught.code === 'permission_denied')
return 'No tienes permiso para gestionar fotos.';
if (caught.code === 'network_error') return 'Sin conexión con Nexo.';
return caught.message;
}
return caught instanceof Error ? caught.message : String(caught);
}
async function deletePhoto(filename: string) {
if (working) return;
if (!confirm('¿Eliminar esta foto?')) return;
working = true;
topError = null;
try {
const updated = await nexo.api.deletePhoto(filename);
await syncSession(updated);
} catch (caught) {
topError = friendly(caught);
} finally {
working = false;
}
}
async function setPrimary(filename: string) {
if (working || profile === null || profile.primaryPhoto === filename) return;
working = true;
topError = null;
try {
const updated = await nexo.api.setPrimaryPhoto(filename);
await syncSession(updated);
} catch (caught) {
topError = friendly(caught);
} finally {
working = false;
}
}
async function move(filename: string, direction: -1 | 1) {
if (working || profile === null) return;
const current = [...profile.photos];
const index = current.indexOf(filename);
const target = index + direction;
if (index === -1 || target < 0 || target >= current.length) return;
[current[index], current[target]] = [current[target], current[index]];
working = true;
topError = null;
try {
const updated = await nexo.api.reorderPhotos(current);
await syncSession(updated);
} catch (caught) {
topError = friendly(caught);
} finally {
working = false;
}
}
function onFileChange(event: Event) {
const input = event.currentTarget as HTMLInputElement;
if (input.files !== null) void uploadFiles(input.files);
input.value = '';
}
function onDrop(event: DragEvent) {
event.preventDefault();
dragOver = false;
if (event.dataTransfer !== null && event.dataTransfer.files.length > 0) {
void uploadFiles(event.dataTransfer.files);
}
}
</script>
<svelte:head>
<title>Fotos — Nexo</title>
</svelte:head>
<section class="wrap">
<header class="head">
<a class="back" href="/dating/profile" aria-label="Volver al perfil">←</a>
<div>
<h1>Fotos del perfil</h1>
<p class="sub">
Hasta {MAX_PHOTOS} fotos en JPEG / PNG / WebP, 5 MB cada una. La foto principal es la
que se muestra en Discover.
</p>
</div>
</header>
{#if topError !== null}
<p class="banner error" role="alert">{topError}</p>
{/if}
{#if perFileError !== null}
<p class="banner warn" role="alert">{perFileError}</p>
{/if}
{#if info !== null && topError === null}
<p class="banner ok" role="status">{info}</p>
{/if}
<label
class="dropzone"
class:dragover={dragOver}
ondragover={(event) => {
event.preventDefault();
dragOver = true;
}}
ondragleave={() => (dragOver = false)}
ondrop={onDrop}
>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
disabled={working}
onchange={onFileChange}
/>
<span>
<strong>Arrastra fotos</strong> o haz clic para seleccionar.
<small>Quedan {Math.max(0, MAX_PHOTOS - (profile?.photos.length ?? 0))} huecos.</small>
</span>
</label>
{#if profile === null}
<p class="empty pulse">Cargando perfil…</p>
{:else if profile.photos.length === 0}
<p class="empty">Aún no tienes fotos. Sube la primera para empezar.</p>
{:else}
<ul class="grid">
{#each profile.photoUrls as photo, index (photo.filename)}
{@const isPrimary = photo.filename === profile.primaryPhoto}
<li class:primary={isPrimary}>
<img src={photo.thumb} alt={`Foto ${index + 1}`} loading="lazy" />
<div class="badges">
{#if isPrimary}
<span class="badge primary-badge">Principal</span>
{/if}
<span class="badge order">#{index + 1}</span>
</div>
<div class="photo-actions">
{#if !isPrimary}
<button
type="button"
onclick={() => setPrimary(photo.filename)}
disabled={working}
title="Marcar como principal"
>
★
</button>
{/if}
<button
type="button"
onclick={() => move(photo.filename, -1)}
disabled={working || index === 0}
title="Subir"
>
↑
</button>
<button
type="button"
onclick={() => move(photo.filename, 1)}
disabled={working || index === profile.photos.length - 1}
title="Bajar"
>
↓
</button>
<button
type="button"
class="danger"
onclick={() => deletePhoto(photo.filename)}
disabled={working}
title="Eliminar"
>
×
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.wrap {
max-width: 44rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.head {
display: flex;
gap: 0.85rem;
align-items: flex-start;
}
.head h1 {
margin: 0 0 0.2rem;
font-size: 1.5rem;
}
.head .sub {
margin: 0;
opacity: 0.75;
}
.back {
font-size: 1.2rem;
text-decoration: none;
color: inherit;
padding: 0.2rem 0.45rem;
border-radius: 6px;
}
.banner {
padding: 0.55rem 0.85rem;
border-radius: 8px;
font-size: 0.9rem;
margin: 0;
}
.banner.error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
}
.banner.warn {
background: color-mix(in srgb, #ffae42 16%, canvas);
border: 1px solid color-mix(in srgb, #ffae42 45%, transparent);
}
.banner.ok {
background: color-mix(in srgb, #2da44e 14%, canvas);
border: 1px solid color-mix(in srgb, #2da44e 35%, transparent);
}
.dropzone {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
min-height: 7rem;
border-radius: 12px;
border: 2px dashed color-mix(in srgb, currentColor 22%, transparent);
background: color-mix(in srgb, canvas 96%, currentColor 4%);
cursor: pointer;
padding: 1rem;
}
.dropzone.dragover {
background: color-mix(in srgb, #c44a78 14%, canvas);
border-color: color-mix(in srgb, #c44a78 60%, transparent);
}
.dropzone input {
display: none;
}
.dropzone span {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dropzone small {
opacity: 0.7;
font-size: 0.8rem;
}
.empty {
text-align: center;
opacity: 0.75;
padding: 1rem;
margin: 0;
}
.empty.pulse {
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
.grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: 0.75rem;
}
.grid li {
position: relative;
aspect-ratio: 4 / 5;
border-radius: 10px;
overflow: hidden;
background: color-mix(in srgb, currentColor 8%, transparent);
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
}
.grid li.primary {
outline: 2px solid color-mix(in srgb, #c44a78 80%, transparent);
outline-offset: -2px;
}
.grid img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.badges {
position: absolute;
top: 0.4rem;
left: 0.4rem;
display: flex;
gap: 0.3rem;
}
.badge {
font-size: 0.7rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, canvas 80%, transparent);
backdrop-filter: blur(4px);
}
.badge.primary-badge {
background: color-mix(in srgb, #c44a78 80%, transparent);
color: white;
}
.photo-actions {
position: absolute;
bottom: 0.3rem;
right: 0.3rem;
display: flex;
gap: 0.2rem;
}
.photo-actions button {
font: inherit;
width: 26px;
height: 26px;
border-radius: 6px;
border: none;
background: color-mix(in srgb, canvas 80%, transparent);
backdrop-filter: blur(4px);
color: inherit;
cursor: pointer;
font-size: 0.85rem;
}
.photo-actions button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.photo-actions .danger {
background: color-mix(in srgb, #d73a49 70%, transparent);
color: white;
}
</style>

@ -0,0 +1,285 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import AuthLayout from '../_components/AuthLayout.svelte';
import Field from '../_components/Field.svelte';
const nexo = getNexoContext();
let displayName = $state('');
let email = $state('');
let password = $state('');
let passwordConfirm = $state('');
let adultConfirmed = $state(false);
let acceptedRules = $state(false);
let submitting = $state(false);
let topError = $state<string | null>(null);
let fieldErrors = $state<Record<string, string>>({});
const passwordStrength = $derived.by(() => {
const score = [
password.length >= 8,
/[A-Z]/.test(password),
/[0-9]/.test(password),
/[^A-Za-z0-9]/.test(password)
].filter(Boolean).length;
const labels = ['demasiado corta', 'débil', 'aceptable', 'fuerte', 'excelente'];
return { score, label: labels[score] };
});
function validate(): boolean {
const next: Record<string, string> = {};
if (displayName.trim().length < 2) next.displayName = 'Al menos 2 caracteres.';
else if (displayName.trim().length > 80) next.displayName = 'Demasiado largo (80 máx).';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
next.email = 'Introduce un email válido.';
if (password.length < 8) next.password = 'Mínimo 8 caracteres.';
if (password !== passwordConfirm)
next.passwordConfirm = 'Las contraseñas no coinciden.';
if (!adultConfirmed)
next.adultConfirmed = 'Debes confirmar que eres mayor de edad.';
if (!acceptedRules)
next.acceptedRules = 'Debes aceptar las reglas de la demo.';
fieldErrors = next;
return Object.keys(next).length === 0;
}
function messageFor(error: DatingApiError): { top?: string; field?: [string, string] } {
switch (error.code) {
case 'email_already_used':
return { field: ['email', 'Este email ya tiene cuenta.'] };
case 'weak_password':
return { field: ['password', 'Contraseña demasiado débil.'] };
case 'password_mismatch':
return { field: ['passwordConfirm', 'Las contraseñas no coinciden.'] };
case 'adult_confirmation_required':
return {
field: ['adultConfirmed', 'Debes confirmar que eres mayor de edad.']
};
case 'network_error':
return { top: 'No se ha podido conectar con el servidor.' };
default:
return { top: error.message };
}
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (submitting) return;
if (!validate()) return;
submitting = true;
topError = null;
try {
const response = await nexo.api.register({
displayName: displayName.trim(),
email: email.trim(),
password,
passwordConfirm,
adultConfirmed
});
nexo.setSession({
authenticated: true,
user: response.user,
profile: response.profile,
token: response.token
});
await goto('/dating/onboarding', { replaceState: true });
} catch (caught) {
if (caught instanceof DatingApiError) {
const mapped = messageFor(caught);
if (mapped.field) {
fieldErrors = { ...fieldErrors, [mapped.field[0]]: mapped.field[1] };
} else {
topError = mapped.top ?? caught.message;
}
} else {
topError = caught instanceof Error ? caught.message : String(caught);
}
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Crear cuenta — Nexo</title>
</svelte:head>
<AuthLayout
title="Crear cuenta"
subtitle="La demo crea cuentas locales contra el servidor de Nexo. No se envían emails reales."
>
<form onsubmit={submit} novalidate>
{#if topError}
<p class="form-error" role="alert">{topError}</p>
{/if}
<Field id="reg-name" label="Nombre visible" error={fieldErrors.displayName}>
<input
id="reg-name"
type="text"
autocomplete="nickname"
maxlength="80"
required
bind:value={displayName}
disabled={submitting}
/>
</Field>
<Field id="reg-email" label="Email" error={fieldErrors.email}>
<input
id="reg-email"
type="email"
autocomplete="email"
inputmode="email"
required
bind:value={email}
disabled={submitting}
/>
</Field>
<Field
id="reg-password"
label="Contraseña"
hint={password === '' ? 'Mínimo 8 caracteres.' : `Fuerza: ${passwordStrength.label}`}
error={fieldErrors.password}
>
<input
id="reg-password"
type="password"
autocomplete="new-password"
minlength="8"
required
bind:value={password}
disabled={submitting}
/>
</Field>
{#if password !== ''}
<div
class="strength-meter"
aria-label="Fuerza de la contraseña"
data-score={passwordStrength.score}
>
<span></span><span></span><span></span><span></span>
</div>
{/if}
<Field
id="reg-password-confirm"
label="Confirma la contraseña"
error={fieldErrors.passwordConfirm}
>
<input
id="reg-password-confirm"
type="password"
autocomplete="new-password"
required
bind:value={passwordConfirm}
disabled={submitting}
/>
</Field>
<label class="check">
<input type="checkbox" bind:checked={adultConfirmed} disabled={submitting} />
<span>Confirmo que soy mayor de edad.</span>
</label>
{#if fieldErrors.adultConfirmed}
<p class="error" role="alert">{fieldErrors.adultConfirmed}</p>
{/if}
<label class="check">
<input type="checkbox" bind:checked={acceptedRules} disabled={submitting} />
<span>
Acepto que es una demo local con datos ficticios y reglas de privacidad simuladas.
</span>
</label>
{#if fieldErrors.acceptedRules}
<p class="error" role="alert">{fieldErrors.acceptedRules}</p>
{/if}
<button type="submit" class="primary" disabled={submitting}>
{submitting ? 'Creando cuenta…' : 'Crear cuenta'}
</button>
</form>
{#snippet footer()}
<span>¿Ya tienes cuenta?</span>
<a href="/dating/login">Iniciar sesión</a>
{/snippet}
</AuthLayout>
<style>
form {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.form-error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
padding: 0.6rem 0.8rem;
border-radius: 8px;
margin: 0;
font-size: 0.9rem;
}
.primary {
font: inherit;
padding: 0.7rem 1rem;
border-radius: 8px;
border: none;
background: color-mix(in srgb, currentColor 88%, transparent);
color: canvas;
font-weight: 600;
cursor: pointer;
margin-top: 0.25rem;
}
.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.check {
display: flex;
gap: 0.5rem;
font-size: 0.88rem;
align-items: flex-start;
}
.check input {
margin-top: 0.2rem;
}
.error {
font-size: 0.82rem;
color: color-mix(in srgb, #d73a49 70%, currentColor);
margin: -0.5rem 0 0 0;
}
.strength-meter {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin: -0.45rem 0 0;
}
.strength-meter span {
height: 4px;
background: color-mix(in srgb, currentColor 12%, transparent);
border-radius: 999px;
}
.strength-meter[data-score='1'] span:nth-child(-n + 1),
.strength-meter[data-score='2'] span:nth-child(-n + 2),
.strength-meter[data-score='3'] span:nth-child(-n + 3),
.strength-meter[data-score='4'] span {
background: color-mix(in srgb, #2da44e 80%, transparent);
}
.strength-meter[data-score='1'] span:first-child {
background: color-mix(in srgb, #d73a49 80%, transparent);
}
.strength-meter[data-score='2'] span:nth-child(-n + 2) {
background: color-mix(in srgb, #ffae42 90%, transparent);
}
</style>

@ -0,0 +1,142 @@
<script lang="ts">
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import AuthLayout from '../_components/AuthLayout.svelte';
import Field from '../_components/Field.svelte';
const nexo = getNexoContext();
let email = $state('');
let submitting = $state(false);
let sent = $state(false);
let topError = $state<string | null>(null);
let fieldError = $state<string | null>(null);
function validate(): boolean {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
fieldError = 'Introduce un email válido.';
return false;
}
fieldError = null;
return true;
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (submitting) return;
if (!validate()) return;
submitting = true;
topError = null;
try {
await nexo.api.resetRequest({ email: email.trim() });
// Anti-enumeration: the server returns the same shape whether
// the email exists or not. The UI mirrors that — we always
// show the "if there is an account, you'll get an email" copy.
sent = true;
} catch (caught) {
topError =
caught instanceof DatingApiError
? caught.code === 'network_error'
? 'No se ha podido conectar con el servidor.'
: caught.message
: caught instanceof Error
? caught.message
: String(caught);
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Recuperar acceso — Nexo</title>
</svelte:head>
<AuthLayout
title="Recuperar acceso"
subtitle="Si esta cuenta existe en la demo, te enviaremos instrucciones."
>
{#if sent}
<div class="success" role="status">
<p>
Si <strong>{email}</strong> 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.
</p>
<button type="button" class="ghost" onclick={() => (sent = false)}>
Enviar otra solicitud
</button>
</div>
{:else}
<form onsubmit={submit} novalidate>
{#if topError}
<p class="form-error" role="alert">{topError}</p>
{/if}
<Field id="reset-email" label="Email" error={fieldError ?? undefined}>
<input
id="reset-email"
type="email"
autocomplete="email"
inputmode="email"
required
bind:value={email}
disabled={submitting}
/>
</Field>
<button type="submit" class="primary" disabled={submitting}>
{submitting ? 'Enviando…' : 'Enviar instrucciones'}
</button>
</form>
{/if}
{#snippet footer()}
<a href="/dating/login">Volver a iniciar sesión</a>
{/snippet}
</AuthLayout>
<style>
form {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.form-error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
padding: 0.6rem 0.8rem;
border-radius: 8px;
margin: 0;
font-size: 0.9rem;
}
.success {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.success p {
margin: 0;
font-size: 0.95rem;
opacity: 0.9;
}
.primary {
font: inherit;
padding: 0.7rem 1rem;
border-radius: 8px;
border: none;
background: color-mix(in srgb, currentColor 88%, transparent);
color: canvas;
font-weight: 600;
cursor: pointer;
}
.ghost {
font: inherit;
font-size: 0.9rem;
padding: 0.5rem 0.8rem;
border-radius: 8px;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
background: transparent;
color: inherit;
cursor: pointer;
align-self: flex-start;
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.