feat(club-002): club PWA: join flow, digital card and installable shell

This commit is contained in:
Deploy
2026-08-26 19:21:49 +02:00
parent b2ccc70dc6
commit c263b44355
20 changed files with 1059 additions and 100 deletions

View File

@@ -8026,14 +8026,16 @@
"description": "Phase 2. Add /club/join, /club/card, QR card UI, manifest, standalone installability and mobile-first Club PWA shell.",
"priority": "high",
"risk": "med",
"status": "pending",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": false,
"security": false,
"qa": false
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "frontend"
"phase": "frontend",
"completed_at": "2026-08-26T17:21:49Z"
},
{
"id": "CLUB-003",

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
export async function GET(request: NextRequest) {
return handle(request);
}
export async function POST(request: NextRequest) {
return handle(request);
}
export async function PATCH(request: NextRequest) {
return handle(request);
}
async function handle(request: NextRequest): Promise<NextResponse> {
const path = request.nextUrl.pathname.replace('/api/', '');
const url = `${API}/${path}${request.nextUrl.search}`;
const headers: Record<string, string> = { accept: 'application/json' };
const cookie = request.headers.get('cookie');
if (cookie) headers.cookie = cookie;
const contentType = request.headers.get('content-type');
if (contentType) headers['content-type'] = contentType;
const init: RequestInit = { method: request.method, headers };
if (request.method !== 'GET' && request.method !== 'HEAD') {
init.body = await request.text();
}
try {
const response = await fetch(url, init);
const body = await response.json().catch(() => ({}));
const next = NextResponse.json(body, { status: response.status });
const setCookie = response.headers.get('set-cookie');
if (setCookie) next.headers.set('set-cookie', setCookie);
return next;
} catch (error) {
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: error instanceof Error ? error.message : 'Club proxy failed' } },
{ status: 502 },
);
}
}

View File

@@ -0,0 +1,5 @@
import { ClubExperience } from '@/components/club/ClubExperience';
export default function ClubCardPage() {
return <ClubExperience mode="card" />;
}

View File

@@ -0,0 +1,5 @@
import { ClubExperience } from '@/components/club/ClubExperience';
export default function ClubJoinPage() {
return <ClubExperience mode="join" />;
}

View File

@@ -0,0 +1,5 @@
import { ClubExperience } from '@/components/club/ClubExperience';
export default function ClubPage() {
return <ClubExperience mode="landing" />;
}

View File

@@ -1,4 +1,4 @@
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';
import { Open_Sans } from 'next/font/google';
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
@@ -16,8 +16,16 @@ export const metadata: Metadata = {
title: 'mercadodevida — Productos naturales y orgánicos',
description:
'Tienda online de productos naturales, orgánicos y saludables. Envío a toda España. Calidad certificada.',
applicationName: 'mercadodevida',
manifest: '/manifest.webmanifest',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'Club MdV',
},
icons: {
icon: '/images/favicon.png',
apple: '/images/club-icon-192.png',
},
openGraph: {
title: 'mercadodevida — Productos naturales y orgánicos',
@@ -26,6 +34,10 @@ export const metadata: Metadata = {
},
};
export const viewport: Viewport = {
themeColor: '#70AD47',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es" suppressHydrationWarning className={opensans.variable}>

View File

@@ -0,0 +1,30 @@
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Club Mercado de Vida',
short_name: 'Club MdV',
description: 'Tarjeta digital y acceso rápido al Club de Clientes de Mercado de Vida.',
start_url: '/club',
scope: '/',
display: 'standalone',
orientation: 'portrait',
background_color: '#F5F9EF',
theme_color: '#70AD47',
categories: ['shopping', 'lifestyle'],
icons: [
{
src: '/images/club-icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable',
},
{
src: '/images/club-icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
};
}

View File

@@ -0,0 +1,533 @@
'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<void>;
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<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
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<BeforeInstallPromptEvent | null>(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 (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
La app del Club ya está instalada en este dispositivo.
</div>
);
}
if (installEvent) {
return (
<button
type="button"
onClick={async () => {
await installEvent.prompt();
await installEvent.userChoice.catch(() => undefined);
setInstallEvent(null);
}}
className="inline-flex items-center justify-center rounded-full bg-[#1B4332] px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#163826]"
>
Instalar app del Club
</button>
);
}
if (isIosDevice()) {
return (
<div className="rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-600">
En iPhone o iPad puedes instalarla desde Compartir <strong>Añadir a pantalla de inicio</strong>.
</div>
);
}
return null;
}
function MemberVisualCode({ memberCode }: { memberCode: string }) {
const cells = useMemo(() => buildVisualCode(memberCode), [memberCode]);
return (
<svg viewBox="0 0 210 210" className="h-44 w-44 rounded-3xl bg-white p-4 shadow-inner">
<rect width="210" height="210" rx="24" fill="white" />
{cells.flatMap((row, rowIndex) =>
row.map((value, colIndex) =>
value ? (
<rect
key={`${rowIndex}-${colIndex}`}
x={colIndex * 10}
y={rowIndex * 10}
width="10"
height="10"
rx="2"
fill="#1B4332"
/>
) : null,
),
)}
</svg>
);
}
function MovementList({ items }: { items: ClubMovement[] }) {
if (items.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-stone-300 bg-white px-4 py-8 text-center text-sm text-stone-500">
Aún no hay movimientos. Cuando empieces a usar el Club aparecerán aquí.
</div>
);
}
return (
<div className="space-y-3">
{items.map((item) => {
const positive = item.balanceDeltaCents > 0;
return (
<div
key={item.id}
className="flex items-center justify-between rounded-2xl border border-stone-200 bg-white px-4 py-3"
>
<div>
<p className="text-sm font-semibold text-stone-900">{movementLabel(item.type)}</p>
<p className="text-xs text-stone-500">
{new Date(item.createdAt).toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
<div className="text-right">
<p className={`text-sm font-semibold ${positive ? 'text-emerald-700' : 'text-stone-700'}`}>
{positive ? '+' : ''}{formatMoney(item.balanceDeltaCents)}
</p>
<p className="text-xs text-stone-500">Base {formatMoney(item.amountCents)}</p>
</div>
</div>
);
})}
</div>
);
}
export function ClubExperience({ mode }: { mode: ClubMode }) {
const router = useRouter();
const [config, setConfig] = useState<ClubConfig | null>(null);
const [member, setMember] = useState<ClubMember | null>(null);
const [movements, setMovements] = useState<ClubMovement[]>([]);
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<ClubConfig>('/api/club/config');
if (cancelled) return;
setConfig(publicConfig);
try {
const me = await readJson<ClubMeResponse>('/api/club/me');
if (cancelled) return;
setMember(me.member);
setConfig(me.config);
const latest = await readJson<ClubMovementsResponse>('/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<ClubJoinResponse>('/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 (
<section className="mx-auto max-w-4xl px-4 py-16 sm:px-6 lg:px-8">
<div className="rounded-[2rem] border border-stone-200 bg-white px-6 py-16 text-center text-stone-500 shadow-sm">
Cargando Club
</div>
</section>
);
}
if (error && !config) {
return (
<section className="mx-auto max-w-4xl px-4 py-16 sm:px-6 lg:px-8">
<div className="rounded-[2rem] border border-red-200 bg-red-50 px-6 py-16 text-center text-red-700 shadow-sm">
{error}
</div>
</section>
);
}
const disabled = config && (!config.clubEnabled || !config.allowAnonymousMembers);
return (
<section className="bg-[radial-gradient(circle_at_top,_rgba(112,173,71,0.18),_transparent_45%)]">
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 lg:px-8">
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-[2rem] bg-[#163826] p-8 text-white shadow-xl">
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-[#CFE7B8]">Club Mercado de Vida</p>
<h1 className="mt-3 text-3xl font-bold sm:text-4xl">
{mode === 'card' ? 'Tu tarjeta digital del Club' : 'Lleva tu Club siempre contigo'}
</h1>
<p className="mt-4 max-w-xl text-sm leading-7 text-[#E8F5E0] sm:text-base">
Únete en segundos, consulta tu saldo y abre tu tarjeta del Club desde el móvil como una app instalada.
</p>
<div className="mt-8 flex flex-wrap gap-3">
{member ? (
<Link
href="/club/card"
className="inline-flex items-center justify-center rounded-full bg-white px-5 py-3 text-sm font-semibold text-[#163826] transition-colors hover:bg-[#F5F9EF]"
>
Abrir mi tarjeta
</Link>
) : (
<button
type="button"
onClick={handleJoin}
disabled={joining || Boolean(disabled)}
className="inline-flex items-center justify-center rounded-full bg-white px-5 py-3 text-sm font-semibold text-[#163826] transition-colors hover:bg-[#F5F9EF] disabled:cursor-not-allowed disabled:opacity-60"
>
{joining ? 'Activando…' : 'Unirme al Club'}
</button>
)}
<Link
href="/club/join"
className="inline-flex items-center justify-center rounded-full border border-white/30 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/10"
>
Ver acceso rápido
</Link>
</div>
{error && (
<div className="mt-4 rounded-2xl border border-red-300/30 bg-red-500/10 px-4 py-3 text-sm text-red-100">
{error}
</div>
)}
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{benefits.map((benefit) => (
<div key={benefit} className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4 text-sm text-[#E8F5E0]">
{benefit}
</div>
))}
</div>
</div>
<div className="space-y-6">
<div className="rounded-[2rem] border border-stone-200 bg-[#F5F9EF] p-6 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-[#70AD47]">Club card</p>
<h2 className="mt-2 text-2xl font-bold text-stone-900">
{member ? member.memberCode : 'Activa tu tarjeta'}
</h2>
<p className="mt-2 text-sm text-stone-600">
{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.'}
</p>
</div>
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-stone-600 shadow-sm">
{member ? 'Activa' : 'Lista para activar'}
</span>
</div>
<div className="mt-6 rounded-[2rem] bg-gradient-to-br from-[#70AD47] via-[#5A9040] to-[#2D6A4F] p-5 text-white shadow-lg">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.25em] text-white/80">Saldo Club</p>
<p className="mt-2 text-3xl font-bold">{formatMoney(member?.currentBalanceCents ?? 0)}</p>
<p className="mt-2 text-sm text-white/80">Canje mínimo: {formatMoney(config?.minimumRedeemAmountCents ?? 500)}</p>
</div>
<MemberVisualCode memberCode={member?.memberCode ?? 'MDV-CLUB-DEMO'} />
</div>
<div className="mt-4 flex items-center justify-between rounded-2xl bg-black/10 px-4 py-3 text-sm text-white/90">
<span>{member?.memberCode ?? 'Activa tu member code'}</span>
<span>{member?.isAnonymous ? 'Modo anónimo' : 'Cuenta vinculada'}</span>
</div>
</div>
</div>
<div className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-lg font-bold text-stone-900">Instalación rápida</h3>
<p className="mt-1 text-sm text-stone-600">Añade el Club a tu pantalla de inicio para abrir la tarjeta como una app.</p>
</div>
</div>
<div className="mt-4">
<InstallButton />
</div>
</div>
</div>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<div className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-bold text-stone-900">
{mode === 'join' ? 'Alta rápida' : 'Estado del Club'}
</h2>
<p className="mt-1 text-sm text-stone-600">
{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.'}
</p>
</div>
{member && (
<Link href="/club/card" className="text-sm font-semibold text-[#2D6A4F] hover:text-[#1B4332]">
Abrir tarjeta
</Link>
)}
</div>
<div className="mt-5 space-y-3 text-sm text-stone-700">
<div className="rounded-2xl bg-stone-50 px-4 py-3">
<span className="font-semibold text-stone-900">Cashback configurado:</span>{' '}
{config?.cashbackPercentage ?? 2}%
</div>
<div className="rounded-2xl bg-stone-50 px-4 py-3">
<span className="font-semibold text-stone-900">Recovery codes:</span>{' '}
{config?.allowRecoveryCodes ? 'preparados para fases futuras' : 'desactivados'}
</div>
<div className="rounded-2xl bg-stone-50 px-4 py-3">
<span className="font-semibold text-stone-900">Identidad actual:</span>{' '}
{member ? member.memberCode : 'sin activar todavía'}
</div>
</div>
{!member && (
<div className="mt-6 space-y-3">
<button
type="button"
onClick={handleJoin}
disabled={joining || Boolean(disabled)}
className="inline-flex w-full items-center justify-center rounded-2xl bg-[#70AD47] px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#5A9040] disabled:cursor-not-allowed disabled:opacity-60"
>
{joining ? 'Creando tarjeta…' : 'Crear mi tarjeta del Club'}
</button>
{disabled && (
<p className="text-sm text-stone-500">
Ahora mismo no se permiten nuevas altas anónimas desde la configuración del Club.
</p>
)}
</div>
)}
</div>
<div className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-bold text-stone-900">Últimos movimientos</h2>
<p className="mt-1 text-sm text-stone-600">Los eventos del ledger del Club aparecerán aquí.</p>
</div>
{member && (
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-600">
{movements.length} visibles
</span>
)}
</div>
<div className="mt-5">
<MovementList items={member ? movements : []} />
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -41,6 +41,7 @@ export function Footer() {
['/categories', 'Categorías'],
['/brands', 'Marcas'],
['/search', 'Buscar'],
['/club', 'Club'],
].map(([href, label]) => (
<li key={href}>
<Link href={href}

View File

@@ -232,6 +232,9 @@ export function Header() {
<Link href="/brands" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
Marcas
</Link>
<Link href="/club" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
Club
</Link>
</nav>
{/* Cart + user */}

View File

@@ -0,0 +1,87 @@
# Arquitectura — CLUB-002 · PWA Club
## Objetivo
Construir la primera experiencia frontend del Club sobre el backend ya entregado en CLUB-001:
- alta/join del Club
- tarjeta digital móvil
- shell instalable tipo PWA
## Análisis del frontend actual
- La tienda usa Next App Router en `project/frontend/src/app`.
- No existe manifiesto PWA ni rutas `/club/*` todavía.
- Los flujos autenticados usan proxies Next.js (`/api/auth/*`) para preservar cookies same-origin.
- CLUB-001 ya expone backend suficiente para esta fase:
- `GET /club/config`
- `POST /club/join`
- `GET /club/me`
- `GET /club/movements`
## Diseño propuesto
### 1) Proxy Next dedicado para Club
Añadir `project/frontend/src/app/api/club/[...path]/route.ts`.
Motivo:
- preservar la cookie `mdv_club` como same-origin
- reenviar `set-cookie` del backend en `POST /club/join`
- evitar que componentes cliente hablen directo con `NEXT_PUBLIC_API_URL`
### 2) Rutas frontend Club
Crear páginas:
- `/club` → landing/resumen del Club
- `/club/join` → alta anónima o acceso al card si ya existe identidad Club
- `/club/card` → tarjeta digital con member code, saldo y últimos movimientos
### 3) Shell móvil instalable
Añadir:
- `app/manifest.ts`
- iconos PWA a partir del branding existente
- metadatos PWA en `app/layout.tsx` (`themeColor`, `appleWebApp`, manifest)
- CTA de instalación desde cliente (`beforeinstallprompt`) en la superficie Club
### 4) Estado de Club en cliente
No crear aún un provider global complejo.
En CLUB-002 basta con fetch puntual desde páginas/componentes Club:
- `GET /api/club/config`
- `POST /api/club/join`
- `GET /api/club/me`
- `GET /api/club/movements`
Esto minimiza acoplamiento y deja abierta una futura extracción a `ClubContext` si la PWA crece.
### 5) Tarjeta digital
La card mostrará:
- member code
- saldo actual
- estado del Club
- últimos movimientos
- superficie visual tipo credencial móvil
### 6) Código visual de identificación
CLUB-002 mostrará un identificador visual en la tarjeta usando datos ya emitidos por CLUB-001.
Nota de alcance:
- CLUB-003 es la fase de integración TPV/identificación en caja.
- Por tanto, CLUB-002 prioriza la UX de tarjeta digital e instalación móvil.
- La semántica exacta de escaneo en TPV se cerrará en CLUB-003 para no fijar prematuramente un contrato visual incompatible con caja.
## Alcance
### Sí entra
- proxy Next para Club
- landing/join/card frontend
- manifiesto e iconos PWA
- CTA de instalación
- movimientos recientes y saldo en tarjeta
### No entra
- earn/redeem en TPV
- linking con usuario registrado
- recovery codes
- admin UI del Club
- contrato final de lectura en caja
## Validación prevista
- `cd project/frontend && npm run build`
- `cd project && npm run build`
- `cd project && npm run typecheck`
- `./scripts/verify.sh`

View File

@@ -0,0 +1,22 @@
# CLUB-002 — Documentation notes
## New storefront routes
- `/club`
- `/club/join`
- `/club/card`
- `/manifest.webmanifest`
## New same-origin proxy
- `GET/POST/PATCH /api/club/[...path]`
- Forwards Club requests to backend `/club/*`
- Preserves incoming cookies and forwards backend `set-cookie`
## User-facing behavior
- Customers can activate an anonymous Club identity from the storefront without a separate login flow.
- After join, the browser keeps the `mdv_club` cookie and the Club card can be reopened later from the same device.
- The Club card shows member code, current balance and recent ledger movements.
- The storefront now exposes install metadata for a standalone Club PWA shell.
## Scope reminder
- CLUB-002 is frontend/PWA only.
- TPV scan semantics, recoveries and account linking stay in later Club phases.

View File

@@ -0,0 +1,52 @@
# Implementer evidence — CLUB-002
## Resumen
Implementé la primera superficie frontend/PWA del Club sobre el backend de CLUB-001.
## Qué se añadió
### 1) Proxy Next.js para Club
- `project/frontend/src/app/api/club/[...path]/route.ts`
Función:
- reenviar `GET/POST/PATCH` al backend
- propagar cookies entrantes
- reenviar `set-cookie` del backend para conservar `mdv_club` same-origin
### 2) Rutas frontend del Club
- `project/frontend/src/app/club/page.tsx`
- `project/frontend/src/app/club/join/page.tsx`
- `project/frontend/src/app/club/card/page.tsx`
- `project/frontend/src/components/club/ClubExperience.tsx`
Comportamiento:
- landing Club
- alta anónima desde `POST /api/club/join`
- tarjeta digital con `memberCode`, saldo y movimientos recientes
- CTA para abrir la tarjeta cuando ya existe identidad Club en el dispositivo
- bloque de instalación PWA usando `beforeinstallprompt`
### 3) Manifest e iconos PWA
- `project/frontend/src/app/manifest.ts`
- `project/frontend/public/images/club-icon-192.png`
- `project/frontend/public/images/club-icon-512.png`
Además:
- `project/frontend/src/app/layout.tsx` ahora declara manifest + metadata Apple Web App + viewport themeColor.
### 4) Descubribilidad en storefront
- enlace `Club` añadido en header y footer.
## Decisiones de implementación
- Reutilicé un único componente cliente `ClubExperience` para `/club`, `/club/join` y `/club/card`.
- El frontend consume el backend Club vía proxy Next same-origin para que la cookie `mdv_club` quede gestionada correctamente por el navegador.
- La tarjeta muestra una credencial visual móvil basada en `memberCode` y deja el contrato final de lectura en caja para CLUB-003, como estaba previsto en la fase TPV.
## Validación ejecutada
- `cd project/frontend && npm run build`
- `./scripts/verify.sh`
- `git diff --check`
## Riesgos / siguiente paso
- CLUB-003 debe cerrar el contrato exacto de identificación en TPV/caja para usar la tarjeta digital también en escaneo/lectura operativa.
- Conviene hacer smoke manual real en navegador/móvil para comprobar el prompt de instalación y la persistencia de `mdv_club` después de `POST /club/join`.

View File

@@ -0,0 +1,29 @@
{
"feature_id": "CLUB-002",
"agent": "leader",
"stage": "close",
"verdict": "APPROVED",
"summary": "CLUB-002 cerrada: PWA del Club con join, tarjeta digital, proxy same-origin y manifest instalable entregados con gates aprobados.",
"gates_summary": {
"reviewer": "APPROVED",
"security": "APPROVED",
"qa": "APPROVED"
},
"artifacts": [
"architect.md",
"implementer.md",
"reviewer.json",
"security.json",
"qa.json",
"documenter.md",
"leader-close.json"
],
"evidence": [
"cd project/frontend && npm run build",
"cd project && npm run typecheck",
"cd project && npm run build",
"./scripts/verify.sh",
"git diff --check"
],
"timestamp": "2026-08-26T17:21:45Z"
}

View File

@@ -0,0 +1,50 @@
{
"feature_id": "CLUB-002",
"agent": "qa",
"stage": "qa_gate",
"verdict": "APPROVED",
"qa_check": "qa",
"summary": "QA aprobado: CLUB-002 entrega navegación Club, alta/join, tarjeta digital y shell PWA instalable sin romper el build del storefront ni del backend principal.",
"test_results": {
"automated": [
"cd project/frontend && npm run build ✅",
"cd project && npm run typecheck ✅",
"cd project && npm run build ✅",
"./scripts/verify.sh ✅",
"git diff --check ✅"
],
"coverage": [
"ruta proxy /api/club/[...path]",
"páginas /club, /club/join y /club/card",
"manifest.webmanifest e iconos PWA",
"metadata/viewport del layout del storefront",
"descubribilidad del Club desde header y footer"
],
"manual_smoke_recommended": [
"Abrir /club y /club/join en navegador real y comprobar que POST /api/club/join deja cookie mdv_club y redirige a /club/card.",
"Comprobar en móvil/Chrome que aparece beforeinstallprompt y que la instalación abre start_url=/club en modo standalone.",
"Verificar con un miembro ya existente que /club/card recupera saldo y movimientos desde la cookie sin necesidad de login manual."
]
},
"notes": [
"El build de Next lista correctamente /club, /club/join, /club/card y /manifest.webmanifest.",
"No hay todavía tests E2E navegador para el prompt de instalación; queda como smoke manual recomendado."
],
"evidence": [
"work/artifacts/CLUB-002/implementer.md",
"work/artifacts/CLUB-002/reviewer.json",
"work/artifacts/CLUB-002/security.json",
"project/frontend/src/app/api/club/[...path]/route.ts",
"project/frontend/src/app/club/page.tsx",
"project/frontend/src/app/club/join/page.tsx",
"project/frontend/src/app/club/card/page.tsx",
"project/frontend/src/app/manifest.ts",
"project/frontend/src/components/club/ClubExperience.tsx",
"cd project/frontend && npm run build",
"cd project && npm run typecheck",
"cd project && npm run build",
"./scripts/verify.sh",
"git diff --check"
],
"timestamp": "2026-08-26T17:21:18Z"
}

View File

@@ -0,0 +1,47 @@
{
"feature_id": "CLUB-002",
"agent": "reviewer",
"stage": "review_gate",
"verdict": "APPROVED",
"summary": "Revisión técnica aprobada: CLUB-002 añade una primera superficie PWA coherente con CLUB-001 mediante proxy same-origin, rutas /club, tarjeta digital y manifest instalable sin adelantar todavía el contrato TPV de CLUB-003.",
"checks": [
{
"item": "El storefront añade las rutas /club, /club/join y /club/card reutilizando un componente cliente único y sin introducir acoplamiento global innecesario.",
"ok": true
},
{
"item": "El proxy Next /api/club preserva el prefijo backend /club/*, reenvía cookies y propaga set-cookie para mantener la identidad mdv_club same-origin.",
"ok": true
},
{
"item": "La PWA queda preparada con manifest, iconos, metadata Apple Web App y CTA de instalación, respetando el App Router actual.",
"ok": true
},
{
"item": "Los cambios quedaron validados con build del frontend, typecheck/build del backend, verify.sh y git diff --check.",
"ok": true
}
],
"issues": [],
"notes": [
"La tarjeta visual usa un identificador gráfico derivado del memberCode como placeholder UX; el contrato final de lectura/escaneo en caja sigue correctamente diferido a CLUB-003.",
"No se añadió provider global de Club; para esta fase el fetch puntual por página mantiene el alcance contenido y simplifica evolución futura."
],
"evidence": [
"work/artifacts/CLUB-002/architect.md",
"work/artifacts/CLUB-002/implementer.md",
"project/frontend/src/app/api/club/[...path]/route.ts",
"project/frontend/src/app/club/page.tsx",
"project/frontend/src/app/club/join/page.tsx",
"project/frontend/src/app/club/card/page.tsx",
"project/frontend/src/components/club/ClubExperience.tsx",
"project/frontend/src/app/manifest.ts",
"project/frontend/src/app/layout.tsx",
"cd project/frontend && npm run build",
"cd project && npm run typecheck",
"cd project && npm run build",
"./scripts/verify.sh",
"git diff --check"
],
"timestamp": "2026-08-26T17:20:40Z"
}

View File

@@ -0,0 +1,32 @@
{
"feature_id": "CLUB-002",
"agent": "security",
"stage": "security_gate",
"verdict": "APPROVED",
"security_check": "security",
"summary": "Aprobado: CLUB-002 no introduce nuevas superficies privilegiadas y mantiene la identidad Club dentro de un proxy same-origin que reusa la cookie existente sin exponer secretos adicionales.",
"checks": {
"auth_cookie_flow": "OK: el frontend no gestiona tokens Club en localStorage ni query params; el proxy /api/club reenvía la cookie mdv_club y propaga set-cookie del backend para seguir con el modelo httpOnly de CLUB-001.",
"data_exposure": "OK: las vistas Club muestran solo memberCode, saldo y movimientos del propio dispositivo; no se añaden datos administrativos ni personales adicionales.",
"xss_client": "OK: la UI renderiza datos como texto y SVG generado en React; no usa dangerouslySetInnerHTML, innerHTML ni eval.",
"proxy_integrity": "OK: el proxy apunta a rutas concretas del backend preservando el prefijo /club/* y pasa cookies/Content-Type de forma explícita, sin interpolación insegura de cabeceras arbitrarias del usuario.",
"dependencies": "OK: no se añadieron dependencias nuevas de terceros.",
"pwa_scope": "OK: manifest e iconos no amplían permisos del navegador ni añaden service worker propio en esta fase."
},
"notes": [
"El identificador gráfico de la tarjeta es una representación visual UX, no un secreto ni un mecanismo criptográfico; la semántica operativa de lectura queda para CLUB-003.",
"El uso de NEXT_PUBLIC_API_URL queda encapsulado en el proxy servidor; los componentes cliente hablan con /api/club same-origin."
],
"evidence": [
"project/frontend/src/app/api/club/[...path]/route.ts",
"project/frontend/src/components/club/ClubExperience.tsx",
"project/frontend/src/app/manifest.ts",
"project/frontend/src/app/layout.tsx",
"rg -n \"dangerouslySetInnerHTML|eval\\(|new Function|innerHTML|beforeinstallprompt|set-cookie|cookie|NEXT_PUBLIC_API_URL\" project/frontend/src/app/api/club project/frontend/src/components/club project/frontend/src/app/layout.tsx project/frontend/src/app/manifest.ts",
"cd project/frontend && npm run build",
"cd project && npm run typecheck",
"cd project && npm run build",
"./scripts/verify.sh"
],
"timestamp": "2026-08-26T17:20:55Z"
}

View File

@@ -1,104 +1,13 @@
{
"feature_id": "CLUB-001",
"feature_id": "CLUB-002",
"stage": "close",
"agent": "leader",
"action": "Cerrar CLUB-001 con commit/push automático",
"action": "Cerrar CLUB-002 con commit/push automático",
"state": "running",
"next_agent": "leader",
"waiting_for": "close",
"updated_at": "2026-08-26T16:54:57Z",
"updated_at": "2026-08-26T17:21:47Z",
"timeline": [
{
"ts": "2026-08-26T16:00:10Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Implementar endpoint optimizado y UI paginada de inventario"
},
{
"ts": "2026-08-26T16:06:51Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Inventario optimizado con paginación y sin N+1"
},
{
"ts": "2026-08-26T16:06:54Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Revisión técnica de optimización de inventario"
},
{
"ts": "2026-08-26T16:07:05Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "done",
"message": "Revisión técnica aprobada para inventario"
},
{
"ts": "2026-08-26T16:07:08Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "Revisión de seguridad de inventory admin overview"
},
{
"ts": "2026-08-26T16:07:19Z",
"agent": "security",
"stage": "security_gate",
"state": "done",
"message": "Revisión de seguridad aprobada para inventario"
},
{
"ts": "2026-08-26T16:07:22Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "QA de inventario paginado"
},
{
"ts": "2026-08-26T16:07:34Z",
"agent": "qa",
"stage": "qa_gate",
"state": "done",
"message": "QA aprobada para inventario optimizado"
},
{
"ts": "2026-08-26T16:07:40Z",
"agent": "leader",
"stage": "close",
"state": "running",
"message": "Cerrar INVENTORY-OPT y commit/push automático"
},
{
"ts": "2026-08-26T16:07:53Z",
"agent": "leader",
"stage": "close",
"state": "done",
"message": "INVENTORY-OPT cerrada; siguiente en cola SHIPPING-ZONES"
},
{
"ts": "2026-08-26T16:09:18Z",
"agent": "architect",
"stage": "design",
"state": "running",
"message": "Analizar restricciones Baleares/Canarias y zonas continentales"
},
{
"ts": "2026-08-26T16:52:12Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Retomar CLUB-001 y revisar técnicamente el core backend del Club"
},
{
"ts": "2026-08-26T16:53:28Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "done",
"message": "Revisión técnica aprobada para CLUB-001"
},
{
"ts": "2026-08-26T16:53:35Z",
"agent": "security",
@@ -147,6 +56,97 @@
"stage": "close",
"state": "running",
"message": "Cerrar CLUB-001 con commit/push automático"
},
{
"ts": "2026-08-26T16:55:24Z",
"agent": "architect",
"stage": "design",
"state": "running",
"message": "Diseñar PWA Club: join flow, tarjeta digital e instalación standalone"
},
{
"ts": "2026-08-26T16:57:43Z",
"agent": "architect",
"stage": "design",
"state": "done",
"message": "Diseño de CLUB-002 completado"
},
{
"ts": "2026-08-26T16:57:46Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Implementar frontend PWA del Club con proxy, páginas y manifest"
},
{
"ts": "2026-08-26T17:02:40Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Frontend PWA del Club implementado y validado en build"
},
{
"ts": "2026-08-26T17:20:07Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Revisión técnica de la PWA Club"
},
{
"ts": "2026-08-26T17:20:37Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "done",
"message": "Revisión técnica aprobada para CLUB-002"
},
{
"ts": "2026-08-26T17:20:42Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "Revisión de seguridad de la PWA Club"
},
{
"ts": "2026-08-26T17:21:02Z",
"agent": "security",
"stage": "security_gate",
"state": "done",
"message": "Revisión de seguridad aprobada para CLUB-002"
},
{
"ts": "2026-08-26T17:21:07Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "QA de la PWA Club"
},
{
"ts": "2026-08-26T17:21:25Z",
"agent": "qa",
"stage": "qa_gate",
"state": "done",
"message": "QA aprobada para CLUB-002"
},
{
"ts": "2026-08-26T17:21:28Z",
"agent": "documenter",
"stage": "document",
"state": "running",
"message": "Documentar flujos y contrato visible de CLUB-002"
},
{
"ts": "2026-08-26T17:21:37Z",
"agent": "documenter",
"stage": "document",
"state": "done",
"message": "Documentación de CLUB-002 completada"
},
{
"ts": "2026-08-26T17:21:47Z",
"agent": "leader",
"stage": "close",
"state": "running",
"message": "Cerrar CLUB-002 con commit/push automático"
}
]
}