You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

410 lines
9.4 KiB

<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>

Powered by TurnKey Linux.