feat(club-002): club PWA: join flow, digital card and installable shell
This commit is contained in:
44
project/frontend/src/app/api/club/[...path]/route.ts
Normal file
44
project/frontend/src/app/api/club/[...path]/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
5
project/frontend/src/app/club/card/page.tsx
Normal file
5
project/frontend/src/app/club/card/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ClubExperience } from '@/components/club/ClubExperience';
|
||||
|
||||
export default function ClubCardPage() {
|
||||
return <ClubExperience mode="card" />;
|
||||
}
|
||||
5
project/frontend/src/app/club/join/page.tsx
Normal file
5
project/frontend/src/app/club/join/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ClubExperience } from '@/components/club/ClubExperience';
|
||||
|
||||
export default function ClubJoinPage() {
|
||||
return <ClubExperience mode="join" />;
|
||||
}
|
||||
5
project/frontend/src/app/club/page.tsx
Normal file
5
project/frontend/src/app/club/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ClubExperience } from '@/components/club/ClubExperience';
|
||||
|
||||
export default function ClubPage() {
|
||||
return <ClubExperience mode="landing" />;
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
30
project/frontend/src/app/manifest.ts
Normal file
30
project/frontend/src/app/manifest.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
533
project/frontend/src/components/club/ClubExperience.tsx
Normal file
533
project/frontend/src/components/club/ClubExperience.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user