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">
|
<script lang="ts">
|
||||||
import { getNexoApp } from './_lib/context';
|
import { goto } from '$app/navigation';
|
||||||
|
import { getNexoContext } from './_lib/context';
|
||||||
|
|
||||||
const { App } = getNexoApp();
|
const nexo = getNexoContext();
|
||||||
const t = (key: Parameters<typeof App.lang.t>[0]) => App.lang.t(key);
|
|
||||||
|
|
||||||
// Phase 0 landing — links the routes that the plan says will exist
|
// Landing is purely a router. The shell layout has already kicked
|
||||||
// across the full demo so `/dating` is navigable end-to-end before
|
// off the `/api/session` probe; once it resolves we send the user
|
||||||
// the sub-pages exist. Plain hrefs (not the typed `resolve(...)`)
|
// to the right surface and never render content here. While the
|
||||||
// because the targets aren't built yet; clicking them 404s, same
|
// probe is in flight we show a minimal pulse so the page isn't
|
||||||
// UX as opening any unbuilt section of an SPA.
|
// blank during a slow network hop.
|
||||||
const phases = [
|
$effect(() => {
|
||||||
{
|
const session = nexo.session();
|
||||||
title: 'Auth & Session',
|
if (session === null) return;
|
||||||
description: 'Login, register, reset, MFA simulado.',
|
if (!session.authenticated) {
|
||||||
links: [
|
void goto('/dating/login', { replaceState: true });
|
||||||
{ href: '/dating/login', label: 'Login' },
|
return;
|
||||||
{ href: '/dating/register', label: 'Register' },
|
|
||||||
{ href: '/dating/reset', label: 'Reset' },
|
|
||||||
{ href: '/dating/mfa', label: 'MFA' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Profile',
|
|
||||||
description: 'Onboarding, perfil, fotos, preferencias.',
|
|
||||||
links: [
|
|
||||||
{ href: '/dating/onboarding', label: 'Onboarding' },
|
|
||||||
{ href: '/dating/profile', label: 'Profile' },
|
|
||||||
{ href: '/dating/profile/photos', label: 'Photos' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Discover & Match',
|
|
||||||
description: 'Feed, like/pass, matches, chat realtime.',
|
|
||||||
links: [
|
|
||||||
{ href: '/dating/discover', label: 'Discover' },
|
|
||||||
{ href: '/dating/matches', label: 'Matches' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Safety & Moderación',
|
|
||||||
description: 'Bloqueo, reporte, bandeja de moderación.',
|
|
||||||
links: [
|
|
||||||
{ href: '/dating/safety', label: 'Safety center' },
|
|
||||||
{ href: '/dating/admin', label: 'Admin' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Diagnóstico',
|
|
||||||
description: 'Inspector de servicios y trazas del ecosistema.',
|
|
||||||
links: [{ href: '/dating/devtools', label: 'Devtools' }]
|
|
||||||
}
|
}
|
||||||
];
|
if (session.profile === null || !session.profile.completed) {
|
||||||
|
void goto('/dating/onboarding', { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void goto('/dating/discover', { replaceState: true });
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="hero">
|
<div class="splash" role="status" aria-live="polite">
|
||||||
<h1>{t('nexo.title')}</h1>
|
<span class="dot"></span>
|
||||||
<p class="lead">{t('nexo.tagline')}</p>
|
<span class="dot"></span>
|
||||||
<p class="hint">
|
<span class="dot"></span>
|
||||||
El servidor de demo vive aislado en <code>servers/dating</code>. Arráncalo con
|
<span class="sr">Cargando Nexo…</span>
|
||||||
<code>npm run dating:server</code> antes de explorar las rutas.
|
</div>
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="phases">
|
|
||||||
{#each phases as phase (phase.title)}
|
|
||||||
<article class="phase-card">
|
|
||||||
<h2>{phase.title}</h2>
|
|
||||||
<p>{phase.description}</p>
|
|
||||||
<ul>
|
|
||||||
{#each phase.links as link (link.href)}
|
|
||||||
<li><a href={link.href}>{link.label}</a></li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hero {
|
.splash {
|
||||||
max-width: 56rem;
|
display: flex;
|
||||||
margin: 0 auto 2rem;
|
gap: 0.4rem;
|
||||||
}
|
align-items: center;
|
||||||
.hero h1 {
|
justify-content: center;
|
||||||
font-size: 2.25rem;
|
min-height: 40vh;
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
.lead {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.hint code {
|
|
||||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
|
||||||
padding: 0.1rem 0.35rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.phases {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
max-width: 64rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
.phase-card {
|
.dot {
|
||||||
padding: 1rem 1.1rem;
|
width: 8px;
|
||||||
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
height: 8px;
|
||||||
border-radius: 10px;
|
border-radius: 999px;
|
||||||
background: color-mix(in srgb, canvas 96%, currentColor 4%);
|
background: color-mix(in srgb, currentColor 60%, transparent);
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.phase-card h2 {
|
.dot:nth-child(2) {
|
||||||
font-size: 1.05rem;
|
animation-delay: 0.2s;
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
}
|
}
|
||||||
.phase-card p {
|
.dot:nth-child(3) {
|
||||||
margin: 0 0 0.6rem;
|
animation-delay: 0.4s;
|
||||||
opacity: 0.8;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
}
|
||||||
.phase-card ul {
|
.sr {
|
||||||
margin: 0;
|
position: absolute;
|
||||||
padding-left: 1rem;
|
width: 1px;
|
||||||
display: flex;
|
height: 1px;
|
||||||
flex-direction: column;
|
padding: 0;
|
||||||
gap: 0.15rem;
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
.phase-card a {
|
@keyframes pulse {
|
||||||
color: inherit;
|
0%,
|
||||||
text-decoration: underline;
|
80%,
|
||||||
text-underline-offset: 2px;
|
100% {
|
||||||
|
transform: scale(0.6);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
<!--
|
||||||
|
Shared auth shell. Centers a card on the page, hosts a brand line on
|
||||||
|
top, and renders the consumer's form via children. Used by login,
|
||||||
|
register, reset and mfa so visual rhythm stays consistent and the
|
||||||
|
pages stay focused on their own form/state.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: Snippet;
|
||||||
|
footer?: Snippet;
|
||||||
|
}
|
||||||
|
let { title, subtitle, children, footer }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="auth-frame">
|
||||||
|
<div class="auth-card">
|
||||||
|
<header>
|
||||||
|
<p class="brand">Nexo</p>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{#if subtitle}<p class="subtitle">{subtitle}</p>{/if}
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{#if footer}
|
||||||
|
<footer>
|
||||||
|
{@render footer()}
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth-frame {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 2rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 26rem;
|
||||||
|
background: color-mix(in srgb, canvas 96%, currentColor 4%);
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.5rem 1.5rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 1px 0 color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
<!--
|
||||||
|
Form field primitive. Wraps a `<label>` + control + per-field error
|
||||||
|
in a single block so every form across the demo has the same focus
|
||||||
|
ring, spacing and error wiring without each page re-implementing it.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
error?: string;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
let { id, label, hint, error, children }: Props = $props();
|
||||||
|
const errorId = $derived(error ? `${id}-error` : undefined);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="field" class:has-error={Boolean(error)}>
|
||||||
|
<label for={id}>{label}</label>
|
||||||
|
{@render children()}
|
||||||
|
{#if error}
|
||||||
|
<p id={errorId} class="error" role="alert">{error}</p>
|
||||||
|
{:else if hint}
|
||||||
|
<p class="hint">{hint}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
:global(.field input),
|
||||||
|
:global(.field textarea),
|
||||||
|
:global(.field select) {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
background: canvas;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
:global(.field input:focus-visible),
|
||||||
|
:global(.field textarea:focus-visible),
|
||||||
|
:global(.field select:focus-visible) {
|
||||||
|
outline: 2px solid color-mix(in srgb, #0366d6 70%, transparent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.has-error :global(input),
|
||||||
|
.has-error :global(textarea),
|
||||||
|
.has-error :global(select) {
|
||||||
|
border-color: color-mix(in srgb, #d73a49 50%, transparent);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: color-mix(in srgb, #d73a49 70%, currentColor);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,157 @@
|
|||||||
|
<!--
|
||||||
|
Tarjeta canónica de perfil para Discover. Muestra foto principal,
|
||||||
|
nombre+edad, intent, ubicación aproximada, intereses y bio. Es
|
||||||
|
intencionalmente "dating-specific": vivirá bajo `_components/`
|
||||||
|
hasta que el plan-implementacion la promocione a `uix`.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { DatingProfile } from '../_lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profile: DatingProfile;
|
||||||
|
}
|
||||||
|
let { profile }: Props = $props();
|
||||||
|
|
||||||
|
const main = $derived(
|
||||||
|
profile.photoUrls.find((entry) => entry.filename === profile.primaryPhoto) ??
|
||||||
|
profile.photoUrls[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
const intentLabel = $derived(
|
||||||
|
({
|
||||||
|
dating: 'Conocer gente',
|
||||||
|
long_term: 'Algo serio',
|
||||||
|
casual: 'Algo casual',
|
||||||
|
friends: 'Amistad',
|
||||||
|
unsure: 'Sin etiqueta'
|
||||||
|
} as const)[profile.intent]
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="card" data-intent={profile.intent}>
|
||||||
|
<div class="photo">
|
||||||
|
{#if main}
|
||||||
|
<img src={main.card} alt={`Foto de ${profile.displayName}`} loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="photo-placeholder" aria-hidden="true">
|
||||||
|
<span>{profile.displayName.slice(0, 1).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="intent-pill">{intentLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<header>
|
||||||
|
<h2>
|
||||||
|
<span class="name">{profile.displayName}</span>
|
||||||
|
<span class="age">{profile.age}</span>
|
||||||
|
</h2>
|
||||||
|
{#if profile.approxLocation !== ''}
|
||||||
|
<p class="location">{profile.approxLocation}</p>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if profile.bio !== ''}
|
||||||
|
<p class="bio">{profile.bio}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if profile.interests.length > 0}
|
||||||
|
<ul class="chips" aria-label="Intereses">
|
||||||
|
{#each profile.interests.slice(0, 8) as interest (interest)}
|
||||||
|
<li>{interest}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
background: color-mix(in srgb, canvas 96%, currentColor 4%);
|
||||||
|
box-shadow: 0 1px 0 color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
max-width: 24rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.photo {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 4 / 5;
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
}
|
||||||
|
.photo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.photo-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.intent-pill {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, canvas 80%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
padding: 1rem 1.1rem 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
header h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
header .age {
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.location {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.bio {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.chips li {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.18rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,26 +1,46 @@
|
|||||||
/**
|
/**
|
||||||
* Svelte context bridge for `DatingAppHandle`. The shell layout calls
|
* Svelte context bridge for the Nexo runtime: the `DatingAppHandle`
|
||||||
* `setNexoApp` once on mount; nested pages and components retrieve the
|
* (App + API) plus a session cell owned by the shell layout. Nested
|
||||||
* app via `getNexoApp` instead of rebuilding it themselves.
|
* pages and components consume both via `getNexoContext()` instead of
|
||||||
|
* each rebuilding their own.
|
||||||
*
|
*
|
||||||
* Using a symbol-keyed context (over `setContext('app', ...)`) avoids
|
* Symbol-keyed (instead of `setContext('app', ...)`) so it can't
|
||||||
* collisions with whatever the rest of the SvelteKit app might register
|
* collide with whatever else the surrounding SvelteKit app registers.
|
||||||
* under that string.
|
*
|
||||||
|
* `session` is exposed as a getter — pages call `nexo.session()` so
|
||||||
|
* `$derived(...)` and `$effect(...)` track session-aware UI without
|
||||||
|
* each page re-issuing its own `/api/session` probe.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getContext, setContext } from 'svelte';
|
import { getContext, setContext } from 'svelte';
|
||||||
import type { DatingAppHandle } from './app.ts';
|
import type { DatingAppHandle } from './app.ts';
|
||||||
|
import type { DatingSessionResponse } from './types.ts';
|
||||||
|
|
||||||
|
export interface NexoContext extends DatingAppHandle {
|
||||||
|
/** Current session snapshot. `null` while the initial probe is in flight. */
|
||||||
|
session(): DatingSessionResponse | null;
|
||||||
|
/** Replace the session cell after a login / logout / refresh. */
|
||||||
|
setSession(next: DatingSessionResponse | null): void;
|
||||||
|
/** Re-issue `/api/session`; layout uses it on mount, pages on demand. */
|
||||||
|
refreshSession(): Promise<DatingSessionResponse | null>;
|
||||||
|
}
|
||||||
|
|
||||||
const NEXO_APP_KEY = Symbol('nexo.app');
|
const NEXO_CONTEXT_KEY = Symbol('nexo.context');
|
||||||
|
|
||||||
export function setNexoApp(handle: DatingAppHandle): DatingAppHandle {
|
export function setNexoContext(value: NexoContext): NexoContext {
|
||||||
return setContext(NEXO_APP_KEY, handle);
|
return setContext(NEXO_CONTEXT_KEY, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNexoApp(): DatingAppHandle {
|
export function getNexoContext(): NexoContext {
|
||||||
const handle = getContext<DatingAppHandle | undefined>(NEXO_APP_KEY);
|
const value = getContext<NexoContext | undefined>(NEXO_CONTEXT_KEY);
|
||||||
if (handle === undefined) {
|
if (value === undefined) {
|
||||||
throw new Error('Nexo App is not in context. Did you mount /dating without its layout?');
|
throw new Error('Nexo context is not set. Did you mount /dating without its layout?');
|
||||||
}
|
}
|
||||||
return handle;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Back-compat aliases — pages that only need the App handle keep the
|
||||||
|
// terser names. New code uses `getNexoContext()` for the session
|
||||||
|
// surface too.
|
||||||
|
export const setNexoApp = setNexoContext;
|
||||||
|
export const getNexoApp = getNexoContext;
|
||||||
|
|||||||
@ -0,0 +1,345 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { getNexoContext } from '../../_lib/context';
|
||||||
|
import { DatingApiError } from '../../_lib/api';
|
||||||
|
import type { DatingMatch, DatingMessage, DatingProfile } from '../../_lib/types';
|
||||||
|
|
||||||
|
const nexo = getNexoContext();
|
||||||
|
const matchId = $derived(page.params.matchId ?? '');
|
||||||
|
|
||||||
|
let match = $state<DatingMatch | null>(null);
|
||||||
|
let messages = $state<DatingMessage[]>([]);
|
||||||
|
let pending = $state<DatingMessage[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let composer = $state('');
|
||||||
|
let sending = $state(false);
|
||||||
|
let topError = $state<string | null>(null);
|
||||||
|
let timeline: HTMLElement | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const session = nexo.session();
|
||||||
|
if (session === null) return;
|
||||||
|
if (!session.authenticated) {
|
||||||
|
void goto('/dating/login', { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchId !== '') void load();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
topError = null;
|
||||||
|
try {
|
||||||
|
const [allMatches, response] = await Promise.all([
|
||||||
|
nexo.api.matches(),
|
||||||
|
nexo.api.messages(matchId)
|
||||||
|
]);
|
||||||
|
match = allMatches.find((entry) => entry.id === matchId) ?? null;
|
||||||
|
messages = [...response.messages];
|
||||||
|
pending = [];
|
||||||
|
await tick();
|
||||||
|
scrollToEnd();
|
||||||
|
} catch (caught) {
|
||||||
|
if (caught instanceof DatingApiError && caught.code === 'match_not_found') {
|
||||||
|
await goto('/dating/matches', { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
topError = caught instanceof Error ? caught.message : String(caught);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function counterpart(): DatingProfile | null {
|
||||||
|
const session = nexo.session();
|
||||||
|
if (session?.authenticated !== true || match === null) return null;
|
||||||
|
return match.profiles.find((profile) => profile.userId !== session.user.id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const myUserId = $derived.by(() => {
|
||||||
|
const session = nexo.session();
|
||||||
|
return session?.authenticated === true ? session.user.id : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const counterpartName = $derived(counterpart()?.displayName || 'Match');
|
||||||
|
|
||||||
|
function scrollToEnd() {
|
||||||
|
if (timeline === null) return;
|
||||||
|
timeline.scrollTop = timeline.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newNonce(): string {
|
||||||
|
return `nx-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const body = composer.trim();
|
||||||
|
if (body === '' || sending) return;
|
||||||
|
|
||||||
|
const nonce = newNonce();
|
||||||
|
const provisional: DatingMessage = {
|
||||||
|
id: nonce,
|
||||||
|
matchId,
|
||||||
|
senderId: myUserId,
|
||||||
|
body,
|
||||||
|
state: 'queued',
|
||||||
|
clientNonce: nonce,
|
||||||
|
deliveredAt: '',
|
||||||
|
metadata: {},
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString()
|
||||||
|
};
|
||||||
|
pending = [...pending, provisional];
|
||||||
|
composer = '';
|
||||||
|
sending = true;
|
||||||
|
await tick();
|
||||||
|
scrollToEnd();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sent = await nexo.api.sendMessage(matchId, { body, clientNonce: nonce });
|
||||||
|
messages = [...messages, sent];
|
||||||
|
pending = pending.filter((entry) => entry.clientNonce !== nonce);
|
||||||
|
await tick();
|
||||||
|
scrollToEnd();
|
||||||
|
} catch (caught) {
|
||||||
|
pending = pending.map((entry) =>
|
||||||
|
entry.clientNonce === nonce ? { ...entry, state: 'failed' } : entry
|
||||||
|
);
|
||||||
|
topError = caught instanceof DatingApiError ? caught.message : String(caught);
|
||||||
|
} finally {
|
||||||
|
sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry(message: DatingMessage) {
|
||||||
|
pending = pending.filter((entry) => entry.clientNonce !== message.clientNonce);
|
||||||
|
composer = message.body;
|
||||||
|
void send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropFailed(message: DatingMessage) {
|
||||||
|
pending = pending.filter((entry) => entry.clientNonce !== message.clientNonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeOf(iso: string): string {
|
||||||
|
if (iso === '') return '';
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allMessages = $derived([...messages, ...pending]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{counterpartName} — Nexo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="chat">
|
||||||
|
<header class="chat-header">
|
||||||
|
<a href="/dating/matches" class="back" aria-label="Volver a matches">←</a>
|
||||||
|
<div class="who">
|
||||||
|
<strong>{counterpartName}</strong>
|
||||||
|
{#if match !== null}
|
||||||
|
<small>match {match.state}</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if topError !== null}
|
||||||
|
<p class="banner error" role="alert">{topError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="timeline" bind:this={timeline}>
|
||||||
|
{#if loading}
|
||||||
|
<p class="empty pulse">Cargando conversación…</p>
|
||||||
|
{:else if allMessages.length === 0}
|
||||||
|
<p class="empty">
|
||||||
|
Aún no hay mensajes. Rompe el hielo: pregunta por algo que viste en su perfil.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each allMessages as message (message.id)}
|
||||||
|
{@const mine = message.senderId === myUserId}
|
||||||
|
<article class="bubble" class:mine class:pending={message.state === 'queued'}
|
||||||
|
class:failed={message.state === 'failed'}>
|
||||||
|
<p>{message.body}</p>
|
||||||
|
<footer>
|
||||||
|
<time datetime={message.created}>{timeOf(message.created)}</time>
|
||||||
|
{#if message.state === 'queued'}
|
||||||
|
<span class="state">enviando…</span>
|
||||||
|
{:else if message.state === 'failed'}
|
||||||
|
<span class="state state-error">no enviado</span>
|
||||||
|
<button type="button" class="link" onclick={() => retry(message)}>reintentar</button>
|
||||||
|
<button type="button" class="link" onclick={() => dropFailed(message)}>descartar</button>
|
||||||
|
{/if}
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="composer" onsubmit={(event) => { event.preventDefault(); void send(); }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Escribe un mensaje…"
|
||||||
|
autocomplete="off"
|
||||||
|
bind:value={composer}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button type="submit" class="send" disabled={sending || composer.trim() === ''}>
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat {
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-height: calc(100vh - 5rem);
|
||||||
|
}
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
}
|
||||||
|
.who {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.who small {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
min-height: 12rem;
|
||||||
|
max-height: calc(100vh - 14rem);
|
||||||
|
}
|
||||||
|
.bubble {
|
||||||
|
max-width: 78%;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.bubble.mine {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: color-mix(in srgb, #c44a78 18%, canvas);
|
||||||
|
}
|
||||||
|
.bubble.pending {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
.bubble.failed {
|
||||||
|
border: 1px solid color-mix(in srgb, #d73a49 50%, transparent);
|
||||||
|
background: color-mix(in srgb, #d73a49 8%, canvas);
|
||||||
|
}
|
||||||
|
.bubble p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.bubble footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.state-error {
|
||||||
|
color: color-mix(in srgb, #d73a49 70%, currentColor);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.link {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.composer {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
background: canvas;
|
||||||
|
}
|
||||||
|
.composer input {
|
||||||
|
font: inherit;
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
background: canvas;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.composer input:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, #0366d6 70%, transparent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.composer .send {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: color-mix(in srgb, currentColor 88%, transparent);
|
||||||
|
color: canvas;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.composer .send:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.banner.error {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, #d73a49 12%, canvas);
|
||||||
|
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.empty.pulse {
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,409 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getNexoContext } from '../_lib/context';
|
||||||
|
import { DatingApiError } from '../_lib/api';
|
||||||
|
import {
|
||||||
|
DATING_INTENTS,
|
||||||
|
type DatingDiscoverFilters,
|
||||||
|
type DatingIntent,
|
||||||
|
type DatingMatch,
|
||||||
|
type DatingProfile
|
||||||
|
} from '../_lib/types';
|
||||||
|
import ProfileCard from '../_components/ProfileCard.svelte';
|
||||||
|
|
||||||
|
const nexo = getNexoContext();
|
||||||
|
|
||||||
|
// Auth + onboarding gate.
|
||||||
|
$effect(() => {
|
||||||
|
const session = nexo.session();
|
||||||
|
if (session === null) return;
|
||||||
|
if (!session.authenticated) {
|
||||||
|
void goto('/dating/login', { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.profile === null || !session.profile.completed) {
|
||||||
|
void goto('/dating/onboarding', { replaceState: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filters — kept simple for Phase 1: intent + age range. The API
|
||||||
|
// also accepts `q` and `cursor` but they're not surfaced yet.
|
||||||
|
let intent = $state<'all' | DatingIntent>('all');
|
||||||
|
let minAge = $state<number | null>(null);
|
||||||
|
let maxAge = $state<number | null>(null);
|
||||||
|
|
||||||
|
let profiles = $state<DatingProfile[]>([]);
|
||||||
|
let cursor = $state<string | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let loadingMore = $state(false);
|
||||||
|
let topError = $state<string | null>(null);
|
||||||
|
let actionPending = $state(false);
|
||||||
|
let lastMatch = $state<DatingMatch | null>(null);
|
||||||
|
|
||||||
|
function buildFilters(extra: Partial<DatingDiscoverFilters> = {}): DatingDiscoverFilters {
|
||||||
|
const filters: { -readonly [K in keyof DatingDiscoverFilters]?: DatingDiscoverFilters[K] } =
|
||||||
|
{};
|
||||||
|
if (intent !== 'all') filters.intent = intent;
|
||||||
|
if (minAge !== null) filters.minAge = minAge;
|
||||||
|
if (maxAge !== null) filters.maxAge = maxAge;
|
||||||
|
Object.assign(filters, extra);
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(reset = false) {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
topError = null;
|
||||||
|
try {
|
||||||
|
const response = await nexo.api.discover(buildFilters());
|
||||||
|
profiles = reset ? [...response.profiles] : [...profiles, ...response.profiles];
|
||||||
|
cursor = response.nextCursor;
|
||||||
|
} catch (caught) {
|
||||||
|
topError = friendlyError(caught);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (cursor === null || loadingMore) return;
|
||||||
|
loadingMore = true;
|
||||||
|
try {
|
||||||
|
const response = await nexo.api.discover(buildFilters({ cursor }));
|
||||||
|
profiles = [...profiles, ...response.profiles];
|
||||||
|
cursor = response.nextCursor;
|
||||||
|
} catch (caught) {
|
||||||
|
topError = friendlyError(caught);
|
||||||
|
} finally {
|
||||||
|
loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyError(caught: unknown): string {
|
||||||
|
if (caught instanceof DatingApiError) {
|
||||||
|
if (caught.code === 'network_error') return 'Sin conexión con Nexo.';
|
||||||
|
if (caught.code === 'session_required') return 'Tu sesión ha caducado.';
|
||||||
|
return caught.message;
|
||||||
|
}
|
||||||
|
return caught instanceof Error ? caught.message : String(caught);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const session = nexo.session();
|
||||||
|
if (session?.authenticated && session.profile?.completed) {
|
||||||
|
void load(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const top = $derived(profiles[0] ?? null);
|
||||||
|
|
||||||
|
async function decide(state: 'like' | 'pass') {
|
||||||
|
if (top === null || actionPending) return;
|
||||||
|
actionPending = true;
|
||||||
|
topError = null;
|
||||||
|
try {
|
||||||
|
if (state === 'like') {
|
||||||
|
const result = await nexo.api.like({ targetUserId: top.userId, state: 'like' });
|
||||||
|
if (result.match !== null) lastMatch = result.match;
|
||||||
|
} else {
|
||||||
|
await nexo.api.like({ targetUserId: top.userId, state: 'pass' });
|
||||||
|
}
|
||||||
|
profiles = profiles.slice(1);
|
||||||
|
if (profiles.length <= 2 && cursor !== null) {
|
||||||
|
void loadMore();
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
topError = friendlyError(caught);
|
||||||
|
} finally {
|
||||||
|
actionPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissMatch() {
|
||||||
|
lastMatch = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewMatch() {
|
||||||
|
if (lastMatch === null) return;
|
||||||
|
await goto(`/dating/chat/${lastMatch.id}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Descubre — Nexo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="layout">
|
||||||
|
<aside class="filters" aria-label="Filtros">
|
||||||
|
<h2>Filtros</h2>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Buscando</span>
|
||||||
|
<select bind:value={intent} onchange={() => load(true)}>
|
||||||
|
<option value="all">Cualquiera</option>
|
||||||
|
{#each DATING_INTENTS as option (option)}
|
||||||
|
<option value={option}>{option}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Edad mínima</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="18"
|
||||||
|
max="120"
|
||||||
|
placeholder="—"
|
||||||
|
bind:value={minAge}
|
||||||
|
onchange={() => load(true)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Edad máxima</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="18"
|
||||||
|
max="120"
|
||||||
|
placeholder="—"
|
||||||
|
bind:value={maxAge}
|
||||||
|
onchange={() => load(true)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="button" class="ghost" onclick={() => load(true)}>Refrescar</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
{#if topError !== null}
|
||||||
|
<p class="banner error" role="alert">{topError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading && profiles.length === 0}
|
||||||
|
<div class="empty pulse">Buscando perfiles…</div>
|
||||||
|
{:else if top === null}
|
||||||
|
<div class="empty">
|
||||||
|
<h3>No hay más perfiles por ahora.</h3>
|
||||||
|
<p>
|
||||||
|
Prueba a ampliar los filtros o vuelve más tarde. Los perfiles se generan a partir
|
||||||
|
de las semillas locales.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="ghost" onclick={() => load(true)}>
|
||||||
|
Volver a empezar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card-stack">
|
||||||
|
<ProfileCard profile={top} />
|
||||||
|
{#if profiles.length > 1}
|
||||||
|
<p class="queue-meta">{profiles.length - 1} perfiles más en cola</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action pass"
|
||||||
|
onclick={() => decide('pass')}
|
||||||
|
disabled={actionPending}
|
||||||
|
aria-label="Pasar"
|
||||||
|
title="Pasar"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action like"
|
||||||
|
onclick={() => decide('like')}
|
||||||
|
disabled={actionPending}
|
||||||
|
aria-label="Me gusta"
|
||||||
|
title="Me gusta"
|
||||||
|
>
|
||||||
|
♥
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if lastMatch !== null}
|
||||||
|
<div class="match-toast" role="dialog" aria-modal="true" aria-labelledby="match-title">
|
||||||
|
<div class="match-card">
|
||||||
|
<h3 id="match-title">¡Es match!</h3>
|
||||||
|
<p>Acabáis de hacer match. Puedes empezar la conversación cuando quieras.</p>
|
||||||
|
<div class="match-actions">
|
||||||
|
<button type="button" class="ghost" onclick={dismissMatch}>Seguir mirando</button>
|
||||||
|
<button type="button" class="primary" onclick={viewMatch}>Abrir chat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 14rem 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 60rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
|
||||||
|
background: color-mix(in srgb, canvas 96%, currentColor 4%);
|
||||||
|
height: fit-content;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.filters h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.filters label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.filters select,
|
||||||
|
.filters input {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
background: canvas;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.ghost {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.card-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.queue-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.action {
|
||||||
|
font: inherit;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
background: canvas;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.action:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.action:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.action.pass {
|
||||||
|
color: #d73a49;
|
||||||
|
}
|
||||||
|
.action.like {
|
||||||
|
color: #c44a78;
|
||||||
|
}
|
||||||
|
.banner.error {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 24rem;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, #d73a49 12%, canvas);
|
||||||
|
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
max-width: 24rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.empty.pulse {
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.match-toast {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: color-mix(in srgb, canvas 70%, transparent);
|
||||||
|
z-index: 50;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.match-card {
|
||||||
|
max-width: 24rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: canvas;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
.match-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
.match-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.match-actions .primary {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
background: color-mix(in srgb, currentColor 88%, transparent);
|
||||||
|
color: canvas;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getNexoContext } from '../_lib/context';
|
||||||
|
import { DatingApiError } from '../_lib/api';
|
||||||
|
import AuthLayout from '../_components/AuthLayout.svelte';
|
||||||
|
import Field from '../_components/Field.svelte';
|
||||||
|
|
||||||
|
const nexo = getNexoContext();
|
||||||
|
|
||||||
|
let email = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let fieldErrors = $state<{ email?: string; password?: string }>({});
|
||||||
|
|
||||||
|
// Local validation: keep it minimal — the server is authoritative
|
||||||
|
// and returns structured codes (`weak_password`, etc.) that we map
|
||||||
|
// to localised messages below. Doing the heavy lifting here would
|
||||||
|
// just duplicate the server's contract.
|
||||||
|
function validate(): boolean {
|
||||||
|
const next: typeof fieldErrors = {};
|
||||||
|
if (email.trim() === '') next.email = 'Introduce tu email.';
|
||||||
|
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
||||||
|
next.email = 'Email no válido.';
|
||||||
|
if (password === '') next.password = 'Introduce tu contraseña.';
|
||||||
|
fieldErrors = next;
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFor(error: DatingApiError): string {
|
||||||
|
switch (error.code) {
|
||||||
|
case 'invalid_credentials':
|
||||||
|
return 'Email o contraseña incorrectos.';
|
||||||
|
case 'account_restricted':
|
||||||
|
return 'Tu cuenta está restringida. Contacta con soporte.';
|
||||||
|
case 'mfa_required':
|
||||||
|
return 'Esta cuenta requiere verificación adicional.';
|
||||||
|
case 'network_error':
|
||||||
|
return 'No se ha podido conectar con el servidor. Vuelve a intentarlo.';
|
||||||
|
default:
|
||||||
|
return error.message || 'Algo ha ido mal. Vuelve a intentarlo.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
submitting = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const response = await nexo.api.login({ email: email.trim(), password });
|
||||||
|
nexo.setSession({
|
||||||
|
authenticated: true,
|
||||||
|
user: response.user,
|
||||||
|
profile: response.profile,
|
||||||
|
token: response.token
|
||||||
|
});
|
||||||
|
const next =
|
||||||
|
response.profile === null || !response.profile.completed
|
||||||
|
? '/dating/onboarding'
|
||||||
|
: '/dating/discover';
|
||||||
|
await goto(next, { replaceState: true });
|
||||||
|
} catch (caught) {
|
||||||
|
if (caught instanceof DatingApiError && caught.code === 'mfa_required') {
|
||||||
|
await goto('/dating/mfa');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error =
|
||||||
|
caught instanceof DatingApiError
|
||||||
|
? messageFor(caught)
|
||||||
|
: caught instanceof Error
|
||||||
|
? caught.message
|
||||||
|
: String(caught);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Iniciar sesión — Nexo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<AuthLayout title="Iniciar sesión" subtitle="Bienvenido de vuelta a Nexo.">
|
||||||
|
<form onsubmit={submit} novalidate>
|
||||||
|
{#if error}
|
||||||
|
<p class="form-error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Field id="login-email" label="Email" error={fieldErrors.email}>
|
||||||
|
<input
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
inputmode="email"
|
||||||
|
required
|
||||||
|
bind:value={email}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field id="login-password" label="Contraseña" error={fieldErrors.password}>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
bind:value={password}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<button type="submit" class="primary" disabled={submitting}>
|
||||||
|
{submitting ? 'Comprobando…' : 'Entrar'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<a href="/dating/reset">¿Has olvidado la contraseña?</a>
|
||||||
|
<a href="/dating/register">Crear cuenta</a>
|
||||||
|
{/snippet}
|
||||||
|
</AuthLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
background: color-mix(in srgb, #d73a49 12%, canvas);
|
||||||
|
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: color-mix(in srgb, currentColor 88%, transparent);
|
||||||
|
color: canvas;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,241 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getNexoContext } from '../_lib/context';
|
||||||
|
import { DatingApiError } from '../_lib/api';
|
||||||
|
import type { DatingMatch, DatingProfile } from '../_lib/types';
|
||||||
|
|
||||||
|
const nexo = getNexoContext();
|
||||||
|
|
||||||
|
let matches = $state<DatingMatch[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const session = nexo.session();
|
||||||
|
if (session === null) return;
|
||||||
|
if (!session.authenticated) {
|
||||||
|
void goto('/dating/login', { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
matches = [...(await nexo.api.matches())];
|
||||||
|
} catch (caught) {
|
||||||
|
if (caught instanceof DatingApiError && caught.code === 'network_error') {
|
||||||
|
error = 'Sin conexión con Nexo.';
|
||||||
|
} else {
|
||||||
|
error = caught instanceof Error ? caught.message : String(caught);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function counterpart(match: DatingMatch): DatingProfile | null {
|
||||||
|
const session = nexo.session();
|
||||||
|
if (session?.authenticated !== true) return null;
|
||||||
|
const me = session.user.id;
|
||||||
|
return match.profiles.find((profile) => profile.userId !== me) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatar(match: DatingMatch): string | null {
|
||||||
|
const profile = counterpart(match);
|
||||||
|
if (!profile) return null;
|
||||||
|
const main =
|
||||||
|
profile.photoUrls.find((entry) => entry.filename === profile.primaryPhoto) ??
|
||||||
|
profile.photoUrls[0];
|
||||||
|
return main?.thumb ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function name(match: DatingMatch): string {
|
||||||
|
return counterpart(match)?.displayName || 'Perfil';
|
||||||
|
}
|
||||||
|
|
||||||
|
function summary(match: DatingMatch): string {
|
||||||
|
const profile = counterpart(match);
|
||||||
|
if (!profile) return '';
|
||||||
|
if (profile.approxLocation === '') return profile.bio.slice(0, 80);
|
||||||
|
return `${profile.approxLocation} · ${profile.bio.slice(0, 60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string): string {
|
||||||
|
if (iso === '') return '';
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(then)) return '';
|
||||||
|
const diffMs = Date.now() - then;
|
||||||
|
const minutes = Math.floor(diffMs / 60_000);
|
||||||
|
if (minutes < 1) return 'ahora';
|
||||||
|
if (minutes < 60) return `hace ${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `hace ${hours} h`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `hace ${days} d`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Matches — Nexo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="wrap">
|
||||||
|
<header>
|
||||||
|
<h1>Tus matches</h1>
|
||||||
|
<p class="sub">Cuando dos personas se gustan en Discover, aparecen aquí.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error !== null}
|
||||||
|
<p class="banner error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="empty pulse">Cargando matches…</p>
|
||||||
|
{:else if matches.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<h2>Aún no tienes matches.</h2>
|
||||||
|
<p>Da likes en Discover para empezar a conectar con otras personas.</p>
|
||||||
|
<a class="primary" href="/dating/discover">Ir a Discover</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each matches as match (match.id)}
|
||||||
|
<li>
|
||||||
|
<a class="row" href={`/dating/chat/${match.id}`}>
|
||||||
|
<span class="avatar" aria-hidden="true">
|
||||||
|
{#if avatar(match)}
|
||||||
|
<img src={avatar(match)} alt="" />
|
||||||
|
{:else}
|
||||||
|
<span>{name(match).slice(0, 1).toUpperCase()}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="meta">
|
||||||
|
<span class="who">
|
||||||
|
<strong>{name(match)}</strong>
|
||||||
|
<small>{relative(match.updated || match.created)}</small>
|
||||||
|
</span>
|
||||||
|
<span class="summary">{summary(match)}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
max-width: 36rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
header .sub {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.banner.error {
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, #d73a49 12%, canvas);
|
||||||
|
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.empty.pulse {
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.empty .primary {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, currentColor 88%, transparent);
|
||||||
|
color: canvas;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, canvas 96%, currentColor 4%);
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.row:hover {
|
||||||
|
background: color-mix(in srgb, canvas 90%, currentColor 10%);
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.who {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.who small {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,448 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getNexoContext } from '../_lib/context';
|
||||||
|
import { DatingApiError } from '../_lib/api';
|
||||||
|
import { DATING_INTENTS, type DatingIntent } from '../_lib/types';
|
||||||
|
import Field from '../_components/Field.svelte';
|
||||||
|
|
||||||
|
const nexo = getNexoContext();
|
||||||
|
|
||||||
|
// Guard the route — anonymous users go to login. The layout has
|
||||||
|
// already kicked off the session probe, so we just react to the
|
||||||
|
// cell.
|
||||||
|
$effect(() => {
|
||||||
|
const session = nexo.session();
|
||||||
|
if (session === null) return;
|
||||||
|
if (!session.authenticated) {
|
||||||
|
void goto('/dating/login', { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.profile?.completed === true) {
|
||||||
|
void goto('/dating/discover', { replaceState: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type StepId = 'identity' | 'about' | 'preferences' | 'review';
|
||||||
|
const STEPS: ReadonlyArray<{ id: StepId; label: string; hint: string }> = [
|
||||||
|
{
|
||||||
|
id: 'identity',
|
||||||
|
label: 'Identidad',
|
||||||
|
hint: 'Cómo te ven los demás. Puedes cambiarlo más tarde.'
|
||||||
|
},
|
||||||
|
{ id: 'about', label: 'Sobre ti', hint: 'Una bio breve y tus intereses.' },
|
||||||
|
{
|
||||||
|
id: 'preferences',
|
||||||
|
label: 'Preferencias',
|
||||||
|
hint: '¿Qué buscas ahora mismo en Nexo?'
|
||||||
|
},
|
||||||
|
{ id: 'review', label: 'Revisar', hint: 'Comprueba y publica tu perfil.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let stepIndex = $state(0);
|
||||||
|
const currentStep = $derived(STEPS[stepIndex].id);
|
||||||
|
const isLast = $derived(stepIndex === STEPS.length - 1);
|
||||||
|
|
||||||
|
const initialSession = nexo.session();
|
||||||
|
const initialProfile =
|
||||||
|
initialSession?.authenticated === true ? initialSession.profile : null;
|
||||||
|
const initialUser =
|
||||||
|
initialSession?.authenticated === true ? initialSession.user : null;
|
||||||
|
|
||||||
|
let displayName = $state(
|
||||||
|
initialProfile?.displayName || initialUser?.displayName || ''
|
||||||
|
);
|
||||||
|
let age = $state(initialProfile?.age && initialProfile.age >= 18 ? initialProfile.age : 25);
|
||||||
|
let bio = $state(initialProfile?.bio ?? '');
|
||||||
|
let interestsRaw = $state(initialProfile?.interests.join(', ') ?? '');
|
||||||
|
let intent = $state<DatingIntent>(initialProfile?.intent ?? 'unsure');
|
||||||
|
let approxLocation = $state(initialProfile?.approxLocation ?? '');
|
||||||
|
let visibility = $state<'visible' | 'hidden' | 'paused'>(
|
||||||
|
initialProfile?.visibility === 'visible' ? 'visible' : 'visible'
|
||||||
|
);
|
||||||
|
let submitting = $state(false);
|
||||||
|
let topError = $state<string | null>(null);
|
||||||
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
const interests = $derived(
|
||||||
|
interestsRaw
|
||||||
|
.split(',')
|
||||||
|
.map((entry: string) => entry.trim())
|
||||||
|
.filter((entry: string) => entry !== '')
|
||||||
|
.slice(0, 30)
|
||||||
|
);
|
||||||
|
|
||||||
|
function validateStep(step: StepId): boolean {
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
if (step === 'identity') {
|
||||||
|
if (displayName.trim().length < 2) next.displayName = 'Al menos 2 caracteres.';
|
||||||
|
else if (displayName.length > 80) next.displayName = 'Máximo 80 caracteres.';
|
||||||
|
if (!Number.isInteger(age) || age < 18 || age > 120) next.age = 'Edad entre 18 y 120.';
|
||||||
|
}
|
||||||
|
if (step === 'about') {
|
||||||
|
if (bio.length > 500) next.bio = 'Máximo 500 caracteres.';
|
||||||
|
if (interests.length === 0) next.interestsRaw = 'Añade al menos un interés.';
|
||||||
|
if (interests.some((entry: string) => entry.length > 60))
|
||||||
|
next.interestsRaw = 'Cada interés debe ocupar 60 caracteres como máximo.';
|
||||||
|
}
|
||||||
|
if (step === 'preferences') {
|
||||||
|
if (!DATING_INTENTS.includes(intent)) next.intent = 'Selecciona una opción válida.';
|
||||||
|
if (approxLocation.length > 120) next.approxLocation = 'Máximo 120 caracteres.';
|
||||||
|
}
|
||||||
|
fieldErrors = next;
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (!validateStep(currentStep)) return;
|
||||||
|
stepIndex = Math.min(stepIndex + 1, STEPS.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
stepIndex = Math.max(0, stepIndex - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFor(error: DatingApiError): string {
|
||||||
|
switch (error.code) {
|
||||||
|
case 'missing_display_name':
|
||||||
|
return 'Falta el nombre visible.';
|
||||||
|
case 'invalid_age':
|
||||||
|
return 'La edad debe estar entre 18 y 120.';
|
||||||
|
case 'profile_required':
|
||||||
|
return 'No se ha encontrado el perfil. Vuelve a iniciar sesión.';
|
||||||
|
case 'network_error':
|
||||||
|
return 'No se ha podido contactar con el servidor.';
|
||||||
|
default:
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish() {
|
||||||
|
if (submitting) return;
|
||||||
|
// Validate every step in order so errors land where they belong.
|
||||||
|
for (const step of STEPS) {
|
||||||
|
if (!validateStep(step.id)) {
|
||||||
|
stepIndex = STEPS.findIndex((s) => s.id === step.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
submitting = true;
|
||||||
|
topError = null;
|
||||||
|
try {
|
||||||
|
const updated = await nexo.api.updateMyProfile({
|
||||||
|
displayName: displayName.trim(),
|
||||||
|
age,
|
||||||
|
bio: bio.trim(),
|
||||||
|
interests,
|
||||||
|
intent,
|
||||||
|
approxLocation: approxLocation.trim(),
|
||||||
|
visibility,
|
||||||
|
completed: true
|
||||||
|
});
|
||||||
|
const session = nexo.session();
|
||||||
|
if (session?.authenticated === true) {
|
||||||
|
nexo.setSession({ ...session, profile: updated });
|
||||||
|
}
|
||||||
|
await goto('/dating/discover', { replaceState: true });
|
||||||
|
} catch (caught) {
|
||||||
|
topError =
|
||||||
|
caught instanceof DatingApiError
|
||||||
|
? messageFor(caught)
|
||||||
|
: caught instanceof Error
|
||||||
|
? caught.message
|
||||||
|
: String(caught);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Onboarding — Nexo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="wrap">
|
||||||
|
<header class="head">
|
||||||
|
<p class="brand">Nexo</p>
|
||||||
|
<h1>Crea tu perfil</h1>
|
||||||
|
<p class="sub">{STEPS[stepIndex].hint}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ol class="stepper" aria-label="Progreso de onboarding">
|
||||||
|
{#each STEPS as step, i (step.id)}
|
||||||
|
<li
|
||||||
|
class:active={i === stepIndex}
|
||||||
|
class:done={i < stepIndex}
|
||||||
|
aria-current={i === stepIndex ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
<span class="dot">{i + 1}</span>
|
||||||
|
<span class="label">{step.label}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{#if topError}
|
||||||
|
<p class="form-error" role="alert">{topError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
{#if currentStep === 'identity'}
|
||||||
|
<Field id="ob-name" label="Nombre visible" error={fieldErrors.displayName}>
|
||||||
|
<input
|
||||||
|
id="ob-name"
|
||||||
|
type="text"
|
||||||
|
autocomplete="nickname"
|
||||||
|
maxlength="80"
|
||||||
|
required
|
||||||
|
bind:value={displayName}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
id="ob-age"
|
||||||
|
label="Edad"
|
||||||
|
hint="Solo personas adultas. La demo no permite menores."
|
||||||
|
error={fieldErrors.age}
|
||||||
|
>
|
||||||
|
<input id="ob-age" type="number" min="18" max="120" required bind:value={age} />
|
||||||
|
</Field>
|
||||||
|
{:else if currentStep === 'about'}
|
||||||
|
<Field
|
||||||
|
id="ob-bio"
|
||||||
|
label="Bio"
|
||||||
|
hint="Cuéntanos algo en 2-3 frases. Máximo 500 caracteres."
|
||||||
|
error={fieldErrors.bio}
|
||||||
|
>
|
||||||
|
<textarea id="ob-bio" rows={4} maxlength="500" bind:value={bio}></textarea>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
id="ob-interests"
|
||||||
|
label="Intereses"
|
||||||
|
hint="Sepáralos por comas. Hasta 30 entradas."
|
||||||
|
error={fieldErrors.interestsRaw}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="ob-interests"
|
||||||
|
type="text"
|
||||||
|
placeholder="cine, senderismo, jazz, café"
|
||||||
|
bind:value={interestsRaw}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{#if interests.length > 0}
|
||||||
|
<ul class="chips" aria-label="Vista previa de intereses">
|
||||||
|
{#each interests as tag (tag)}
|
||||||
|
<li>{tag}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{:else if currentStep === 'preferences'}
|
||||||
|
<Field id="ob-intent" label="¿Qué buscas?" error={fieldErrors.intent}>
|
||||||
|
<select id="ob-intent" bind:value={intent}>
|
||||||
|
<option value="dating">Algo serio</option>
|
||||||
|
<option value="long_term">Relación a largo plazo</option>
|
||||||
|
<option value="casual">Algo casual</option>
|
||||||
|
<option value="friends">Amistad</option>
|
||||||
|
<option value="unsure">Aún no lo sé</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
id="ob-location"
|
||||||
|
label="Ubicación aproximada"
|
||||||
|
hint="Texto libre — no se guarda tu ubicación exacta."
|
||||||
|
error={fieldErrors.approxLocation}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="ob-location"
|
||||||
|
type="text"
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="Madrid, ES"
|
||||||
|
bind:value={approxLocation}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
id="ob-visibility"
|
||||||
|
label="Visibilidad inicial"
|
||||||
|
hint="Puedes ocultar el perfil en cualquier momento desde Profile."
|
||||||
|
>
|
||||||
|
<select id="ob-visibility" bind:value={visibility}>
|
||||||
|
<option value="visible">Visible para otros</option>
|
||||||
|
<option value="hidden">Oculto (solo tú lo ves)</option>
|
||||||
|
<option value="paused">Pausado temporalmente</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{:else if currentStep === 'review'}
|
||||||
|
<dl class="review">
|
||||||
|
<div><dt>Nombre</dt><dd>{displayName}</dd></div>
|
||||||
|
<div><dt>Edad</dt><dd>{age}</dd></div>
|
||||||
|
<div><dt>Bio</dt><dd>{bio || '—'}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Intereses</dt>
|
||||||
|
<dd>{interests.length === 0 ? '—' : interests.join(', ')}</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Buscando</dt><dd>{intent}</dd></div>
|
||||||
|
<div><dt>Ubicación</dt><dd>{approxLocation || '—'}</dd></div>
|
||||||
|
<div><dt>Visibilidad</dt><dd>{visibility}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="actions">
|
||||||
|
<button type="button" class="ghost" onclick={back} disabled={submitting || stepIndex === 0}>
|
||||||
|
Atrás
|
||||||
|
</button>
|
||||||
|
{#if isLast}
|
||||||
|
<button type="button" class="primary" onclick={publish} disabled={submitting}>
|
||||||
|
{submitting ? 'Publicando…' : 'Publicar perfil'}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="primary" onclick={next} disabled={submitting}>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
max-width: 38rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.stepper {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.stepper li {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
.stepper li.active {
|
||||||
|
opacity: 1;
|
||||||
|
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.stepper li.done {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.stepper .dot {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.stepper li.active .dot {
|
||||||
|
background: color-mix(in srgb, currentColor 80%, transparent);
|
||||||
|
color: canvas;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
background: color-mix(in srgb, canvas 96%, currentColor 4%);
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.chips li {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
}
|
||||||
|
.review {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
}
|
||||||
|
.review div {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.review dt {
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.review dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.actions .primary {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: color-mix(in srgb, currentColor 88%, transparent);
|
||||||
|
color: canvas;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.actions .ghost {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.actions button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
background: color-mix(in srgb, #d73a49 12%, canvas);
|
||||||
|
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,326 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getNexoContext } from '../_lib/context';
|
||||||
|
import { DatingApiError } from '../_lib/api';
|
||||||
|
import {
|
||||||
|
DATING_INTENTS,
|
||||||
|
DATING_VISIBILITIES,
|
||||||
|
type DatingIntent,
|
||||||
|
type DatingProfile,
|
||||||
|
type DatingVisibility
|
||||||
|
} from '../_lib/types';
|
||||||
|
import Field from '../_components/Field.svelte';
|
||||||
|
|
||||||
|
const nexo = getNexoContext();
|
||||||
|
|
||||||
|
let profile = $state<DatingProfile | null>(null);
|
||||||
|
let displayName = $state('');
|
||||||
|
let age = $state(25);
|
||||||
|
let bio = $state('');
|
||||||
|
let interestsRaw = $state('');
|
||||||
|
let intent = $state<DatingIntent>('unsure');
|
||||||
|
let approxLocation = $state('');
|
||||||
|
let visibility = $state<DatingVisibility>('hidden');
|
||||||
|
|
||||||
|
let dirty = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let savedAt = $state<number | null>(null);
|
||||||
|
let topError = $state<string | null>(null);
|
||||||
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const session = nexo.session();
|
||||||
|
if (session === null) return;
|
||||||
|
if (!session.authenticated) {
|
||||||
|
void goto('/dating/login', { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!session.profile?.completed) {
|
||||||
|
void goto('/dating/onboarding', { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hydrate(session.profile);
|
||||||
|
});
|
||||||
|
|
||||||
|
function hydrate(next: DatingProfile) {
|
||||||
|
profile = next;
|
||||||
|
displayName = next.displayName;
|
||||||
|
age = next.age;
|
||||||
|
bio = next.bio;
|
||||||
|
interestsRaw = next.interests.join(', ');
|
||||||
|
intent = next.intent;
|
||||||
|
approxLocation = next.approxLocation;
|
||||||
|
visibility = next.visibility;
|
||||||
|
dirty = false;
|
||||||
|
fieldErrors = {};
|
||||||
|
topError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interests = $derived(
|
||||||
|
interestsRaw
|
||||||
|
.split(',')
|
||||||
|
.map((entry: string) => entry.trim())
|
||||||
|
.filter((entry: string) => entry !== '')
|
||||||
|
.slice(0, 30)
|
||||||
|
);
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
dirty = true;
|
||||||
|
savedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
if (displayName.trim().length < 2) next.displayName = 'Al menos 2 caracteres.';
|
||||||
|
if (!Number.isInteger(age) || age < 18 || age > 120) next.age = 'Entre 18 y 120.';
|
||||||
|
if (bio.length > 500) next.bio = 'Máximo 500 caracteres.';
|
||||||
|
if (interests.some((entry: string) => entry.length > 60))
|
||||||
|
next.interestsRaw = 'Cada interés debe tener 60 caracteres como máximo.';
|
||||||
|
if (approxLocation.length > 120) next.approxLocation = 'Máximo 120 caracteres.';
|
||||||
|
fieldErrors = next;
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (saving || !dirty) return;
|
||||||
|
if (!validate()) return;
|
||||||
|
saving = true;
|
||||||
|
topError = null;
|
||||||
|
try {
|
||||||
|
const updated = await nexo.api.updateMyProfile({
|
||||||
|
displayName: displayName.trim(),
|
||||||
|
age,
|
||||||
|
bio: bio.trim(),
|
||||||
|
interests,
|
||||||
|
intent,
|
||||||
|
approxLocation: approxLocation.trim(),
|
||||||
|
visibility,
|
||||||
|
completed: true
|
||||||
|
});
|
||||||
|
const session = nexo.session();
|
||||||
|
if (session?.authenticated === true) {
|
||||||
|
nexo.setSession({ ...session, profile: updated });
|
||||||
|
}
|
||||||
|
hydrate(updated);
|
||||||
|
savedAt = Date.now();
|
||||||
|
} catch (caught) {
|
||||||
|
topError =
|
||||||
|
caught instanceof DatingApiError
|
||||||
|
? caught.message
|
||||||
|
: caught instanceof Error
|
||||||
|
? caught.message
|
||||||
|
: String(caught);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetChanges() {
|
||||||
|
if (profile !== null) hydrate(profile);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Perfil — Nexo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="wrap">
|
||||||
|
<header class="head">
|
||||||
|
<div>
|
||||||
|
<h1>Mi perfil</h1>
|
||||||
|
<p class="sub">Edita tu información pública y la visibilidad del perfil.</p>
|
||||||
|
</div>
|
||||||
|
<a class="ghost" href="/dating/profile/photos">Gestionar fotos</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if topError !== null}
|
||||||
|
<p class="banner error" role="alert">{topError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if savedAt !== null && !dirty}
|
||||||
|
<p class="banner ok" role="status">Cambios guardados.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onsubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void save();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field id="prof-name" label="Nombre visible" error={fieldErrors.displayName}>
|
||||||
|
<input
|
||||||
|
id="prof-name"
|
||||||
|
type="text"
|
||||||
|
maxlength="80"
|
||||||
|
bind:value={displayName}
|
||||||
|
oninput={markDirty}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field id="prof-age" label="Edad" error={fieldErrors.age}>
|
||||||
|
<input
|
||||||
|
id="prof-age"
|
||||||
|
type="number"
|
||||||
|
min="18"
|
||||||
|
max="120"
|
||||||
|
bind:value={age}
|
||||||
|
oninput={markDirty}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field id="prof-bio" label="Bio" hint="Hasta 500 caracteres." error={fieldErrors.bio}>
|
||||||
|
<textarea
|
||||||
|
id="prof-bio"
|
||||||
|
rows={4}
|
||||||
|
maxlength="500"
|
||||||
|
bind:value={bio}
|
||||||
|
oninput={markDirty}
|
||||||
|
disabled={saving}
|
||||||
|
></textarea>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
id="prof-interests"
|
||||||
|
label="Intereses"
|
||||||
|
hint="Sepáralos por comas. Hasta 30."
|
||||||
|
error={fieldErrors.interestsRaw}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="prof-interests"
|
||||||
|
type="text"
|
||||||
|
bind:value={interestsRaw}
|
||||||
|
oninput={markDirty}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field id="prof-intent" label="¿Qué buscas?">
|
||||||
|
<select id="prof-intent" bind:value={intent} onchange={markDirty} disabled={saving}>
|
||||||
|
{#each DATING_INTENTS as option (option)}
|
||||||
|
<option value={option}>{option}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
id="prof-location"
|
||||||
|
label="Ubicación aproximada"
|
||||||
|
error={fieldErrors.approxLocation}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="prof-location"
|
||||||
|
type="text"
|
||||||
|
maxlength="120"
|
||||||
|
bind:value={approxLocation}
|
||||||
|
oninput={markDirty}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
id="prof-visibility"
|
||||||
|
label="Visibilidad"
|
||||||
|
hint="`hidden` te oculta de Discover; `paused` mantiene matches pero pausa el feed."
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="prof-visibility"
|
||||||
|
bind:value={visibility}
|
||||||
|
onchange={markDirty}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{#each DATING_VISIBILITIES as option (option)}
|
||||||
|
<option value={option}>{option}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ghost"
|
||||||
|
onclick={resetChanges}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
>
|
||||||
|
Descartar cambios
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="primary" disabled={!dirty || saving}>
|
||||||
|
{saving ? 'Guardando…' : 'Guardar cambios'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
max-width: 36rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.head h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.head .sub {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: color-mix(in srgb, currentColor 88%, transparent);
|
||||||
|
color: canvas;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ghost {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.banner {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.banner.error {
|
||||||
|
background: color-mix(in srgb, #d73a49 12%, canvas);
|
||||||
|
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
|
||||||
|
}
|
||||||
|
.banner.ok {
|
||||||
|
background: color-mix(in srgb, #2da44e 14%, canvas);
|
||||||
|
border: 1px solid color-mix(in srgb, #2da44e 35%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,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