'use client'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; type ClubMode = 'landing' | 'join' | 'card'; type ClubConfig = { clubEnabled: boolean; cashbackBps: number; cashbackPercentage: number; allowAnonymousMembers: boolean; allowRecoveryCodes: boolean; minimumRedeemAmountCents: number; }; type ClubMember = { id: string; userId: string | null; memberCode: string; status: 'active' | 'blocked' | 'merged'; tierCode: string; currentBalanceCents: number; isAnonymous: boolean; createdAt: string; updatedAt: string; }; type ClubMovement = { id: string; type: 'earn' | 'redeem' | 'refund' | 'bonus' | 'adjustment'; amountCents: number; balanceDeltaCents: number; createdAt: string; }; type ClubMeResponse = { member: ClubMember; config: ClubConfig; }; type ClubMovementsResponse = { member: ClubMember; items: ClubMovement[]; }; type ClubJoinResponse = { created: boolean; deviceToken: string; member: ClubMember; config: ClubConfig; }; type BeforeInstallPromptEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; }; function formatMoney(cents: number): string { return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR', }).format(cents / 100); } function movementLabel(type: ClubMovement['type']): string { switch (type) { case 'earn': return 'Acumulación'; case 'redeem': return 'Canje'; case 'refund': return 'Reembolso'; case 'bonus': return 'Bono'; case 'adjustment': return 'Ajuste'; default: return type; } } async function readJson(input: RequestInfo, init?: RequestInit): Promise { const response = await fetch(input, { ...init, credentials: 'include', cache: 'no-store' }); const data = (await response.json().catch(() => ({}))) as { error?: { message?: string } } & T; if (!response.ok) { throw new Error(data.error?.message || 'No se pudo completar la operación del Club'); } return data; } function isDisplayStandalone(): boolean { if (typeof window === 'undefined') return false; const navigatorWithStandalone = window.navigator as Navigator & { standalone?: boolean }; return window.matchMedia('(display-mode: standalone)').matches || navigatorWithStandalone.standalone === true; } function isIosDevice(): boolean { if (typeof window === 'undefined') return false; return /iphone|ipad|ipod/i.test(window.navigator.userAgent); } function buildVisualCode(seed: string): boolean[][] { const size = 21; const grid = Array.from({ length: size }, () => Array.from({ length: size }, () => false)); const drawFinder = (startRow: number, startCol: number) => { for (let row = 0; row < 7; row += 1) { for (let col = 0; col < 7; col += 1) { const edge = row === 0 || row === 6 || col === 0 || col === 6; const center = row >= 2 && row <= 4 && col >= 2 && col <= 4; grid[startRow + row]![startCol + col] = edge || center; } } }; drawFinder(0, 0); drawFinder(0, size - 7); drawFinder(size - 7, 0); let hash = 0; for (let index = 0; index < seed.length; index += 1) { hash = (hash * 33 + seed.charCodeAt(index)) >>> 0; } for (let row = 0; row < size; row += 1) { for (let col = 0; col < size; col += 1) { const inFinder = (row < 7 && col < 7) || (row < 7 && col >= size - 7) || (row >= size - 7 && col < 7); if (inFinder) continue; const bit = ((hash >> ((row + col) % 24)) ^ ((row + 1) * 17) ^ ((col + 1) * 31)) & 1; grid[row]![col] = bit === 1; hash = ((hash * 1664525 + 1013904223) >>> 0) ^ (row * 97 + col * 53); } } return grid; } function InstallButton() { const [installEvent, setInstallEvent] = useState(null); const [installed, setInstalled] = useState(false); useEffect(() => { setInstalled(isDisplayStandalone()); const handlePrompt = (event: Event) => { event.preventDefault(); setInstallEvent(event as BeforeInstallPromptEvent); }; const handleInstalled = () => { setInstalled(true); setInstallEvent(null); }; window.addEventListener('beforeinstallprompt', handlePrompt); window.addEventListener('appinstalled', handleInstalled); return () => { window.removeEventListener('beforeinstallprompt', handlePrompt); window.removeEventListener('appinstalled', handleInstalled); }; }, []); if (installed) { return (
La app del Club ya está instalada en este dispositivo.
); } if (installEvent) { return ( ); } if (isIosDevice()) { return (
En iPhone o iPad puedes instalarla desde Compartir → Añadir a pantalla de inicio.
); } return null; } function MemberVisualCode({ memberCode }: { memberCode: string }) { const cells = useMemo(() => buildVisualCode(memberCode), [memberCode]); return ( {cells.flatMap((row, rowIndex) => row.map((value, colIndex) => value ? ( ) : null, ), )} ); } function MovementList({ items }: { items: ClubMovement[] }) { if (items.length === 0) { return (
Aún no hay movimientos. Cuando empieces a usar el Club aparecerán aquí.
); } return (
{items.map((item) => { const positive = item.balanceDeltaCents > 0; return (

{movementLabel(item.type)}

{new Date(item.createdAt).toLocaleString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', })}

{positive ? '+' : ''}{formatMoney(item.balanceDeltaCents)}

Base {formatMoney(item.amountCents)}

); })}
); } export function ClubExperience({ mode }: { mode: ClubMode }) { const router = useRouter(); const [config, setConfig] = useState(null); const [member, setMember] = useState(null); const [movements, setMovements] = useState([]); const [loading, setLoading] = useState(true); const [joining, setJoining] = useState(false); const [error, setError] = useState(''); useEffect(() => { let cancelled = false; (async () => { setLoading(true); setError(''); try { const publicConfig = await readJson('/api/club/config'); if (cancelled) return; setConfig(publicConfig); try { const me = await readJson('/api/club/me'); if (cancelled) return; setMember(me.member); setConfig(me.config); const latest = await readJson('/api/club/movements?limit=5'); if (cancelled) return; setMovements(latest.items ?? []); } catch { if (cancelled) return; setMember(null); setMovements([]); } } catch (err) { if (cancelled) return; setError(err instanceof Error ? err.message : 'No se pudo cargar el Club'); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, []); const handleJoin = async () => { setJoining(true); setError(''); try { const response = await readJson('/api/club/join', { method: 'POST' }); setMember(response.member); setConfig(response.config); setMovements([]); router.push('/club/card'); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo darte de alta en el Club'); } finally { setJoining(false); } }; const benefits = [ `Cashback configurable actualmente al ${config?.cashbackPercentage ?? 2}%`, 'Tarjeta digital siempre disponible en tu móvil', 'Historial de movimientos y saldo desde la misma app', ]; if (loading) { return (
Cargando Club…
); } if (error && !config) { return (
{error}
); } const disabled = config && (!config.clubEnabled || !config.allowAnonymousMembers); return (

Club Mercado de Vida

{mode === 'card' ? 'Tu tarjeta digital del Club' : 'Lleva tu Club siempre contigo'}

Únete en segundos, consulta tu saldo y abre tu tarjeta del Club desde el móvil como una app instalada.

{member ? ( Abrir mi tarjeta ) : ( )} Ver acceso rápido
{error && (
{error}
)}
{benefits.map((benefit) => (
{benefit}
))}

Club card

{member ? member.memberCode : 'Activa tu tarjeta'}

{member ? 'Tu tarjeta digital queda guardada en este dispositivo para abrirla al instante.' : 'Date de alta una vez y tendrás tu credencial digital siempre a mano.'}

{member ? 'Activa' : 'Lista para activar'}

Saldo Club

{formatMoney(member?.currentBalanceCents ?? 0)}

Canje mínimo: {formatMoney(config?.minimumRedeemAmountCents ?? 500)}

{member?.memberCode ?? 'Activa tu member code'} {member?.isAnonymous ? 'Modo anónimo' : 'Cuenta vinculada'}

Instalación rápida

Añade el Club a tu pantalla de inicio para abrir la tarjeta como una app.

{mode === 'join' ? 'Alta rápida' : 'Estado del Club'}

{disabled ? 'El Club está temporalmente desactivado desde configuración.' : member ? 'Tu dispositivo ya tiene una identidad Club activa.' : 'Puedes darte de alta en segundos desde este dispositivo.'}

{member && ( Abrir tarjeta → )}
Cashback configurado:{' '} {config?.cashbackPercentage ?? 2}%
Recovery codes:{' '} {config?.allowRecoveryCodes ? 'preparados para fases futuras' : 'desactivados'}
Identidad actual:{' '} {member ? member.memberCode : 'sin activar todavía'}
{!member && (
{disabled && (

Ahora mismo no se permiten nuevas altas anónimas desde la configuración del Club.

)}
)}

Últimos movimientos

Los eventos del ledger del Club aparecerán aquí.

{member && ( {movements.length} visibles )}
); }