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
parent
0edbb80a4f
commit
35f484ea28
@ -1,137 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { getNexoApp } from './_lib/context';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getNexoContext } from './_lib/context';
|
||||
|
||||
const { App } = getNexoApp();
|
||||
const t = (key: Parameters<typeof App.lang.t>[0]) => App.lang.t(key);
|
||||
const nexo = getNexoContext();
|
||||
|
||||
// Phase 0 landing — links the routes that the plan says will exist
|
||||
// across the full demo so `/dating` is navigable end-to-end before
|
||||
// the sub-pages exist. Plain hrefs (not the typed `resolve(...)`)
|
||||
// because the targets aren't built yet; clicking them 404s, same
|
||||
// UX as opening any unbuilt section of an SPA.
|
||||
const phases = [
|
||||
{
|
||||
title: 'Auth & Session',
|
||||
description: 'Login, register, reset, MFA simulado.',
|
||||
links: [
|
||||
{ href: '/dating/login', label: 'Login' },
|
||||
{ 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' }]
|
||||
// Landing is purely a router. The shell layout has already kicked
|
||||
// off the `/api/session` probe; once it resolves we send the user
|
||||
// to the right surface and never render content here. While the
|
||||
// probe is in flight we show a minimal pulse so the page isn't
|
||||
// blank during a slow network hop.
|
||||
$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 });
|
||||
return;
|
||||
}
|
||||
void goto('/dating/discover', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="hero">
|
||||
<h1>{t('nexo.title')}</h1>
|
||||
<p class="lead">{t('nexo.tagline')}</p>
|
||||
<p class="hint">
|
||||
El servidor de demo vive aislado en <code>servers/dating</code>. Arráncalo con
|
||||
<code>npm run dating:server</code> antes de explorar las rutas.
|
||||
</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>
|
||||
<div class="splash" role="status" aria-live="polite">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="sr">Cargando Nexo…</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
max-width: 56rem;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 2.25rem;
|
||||
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;
|
||||
.splash {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40vh;
|
||||
}
|
||||
.phase-card {
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, canvas 96%, currentColor 4%);
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentColor 60%, transparent);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.phase-card h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 0.25rem;
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.phase-card p {
|
||||
margin: 0 0 0.6rem;
|
||||
opacity: 0.8;
|
||||
font-size: 0.92rem;
|
||||
.dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.phase-card ul {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
.sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.phase-card a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</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
|
||||
* `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<DatingSessionResponse | null>;
|
||||
}
|
||||
|
||||
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<DatingAppHandle | undefined>(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<NexoContext | undefined>(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;
|
||||
|
||||
@ -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,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…
Reference in new issue