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