Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt around the panel's own colors instead of an invented palette. Landing (`src/components/landing/`, one section per file, no props): - Seven funnel sections composed by `LandingPage`. The hero's eight swatches are literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands to avatars and services, so "your colors become your numbers" is literal. - `BrandMark` becomes the single source for the logo, replicating `public/favicon.svg`. Blue is the action surface, orange only ever marks. - `NotebookVisual` is the one deliberate exception to the palette: it is what the product replaces. - Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a single easing, and reduced-motion resolves `initial` to the final state so a never-firing `whileInView` cannot leave a section invisible forever. Routing and bundle: - `homePathFor` is the single definition of each role's destination. - Landing, login, dashboard and calendar load lazily. Eager, the login dragged framer-motion (~40 KB gz) into every panel load and the landing downloaded recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is pinned to the `react` chunk because Rollup otherwise assigns it to `charts`, making the entry import 111 KB gz for a 200-byte utility. Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`: - No touch form field below 16px, `dvh` height utilities, safe-area insets, and 40px touch targets keyed off `pointer: coarse` rather than `sm:`. - New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`, so it left the login's side padding at 0 on anything but an iPhone in landscape. No overflow check could see it — there was no overflow, just zero margin. The audit now guards it with a `gutter` check. - `shell-height` no longer fires on pages that legitimately scroll; the static `raw-viewport-unit` scan covers those instead. Production build: - The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time constant, so without it Vite eliminated the magic login and the "Ver como…" switcher: the deployed landing promised "no registration" and led to an empty form. Verified by building both ways and diffing the bundle. - Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged from returning visitors' caches. Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12, landing 13/13 (WebKit), PWA passed against the real production build. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
145 lines
6.8 KiB
TypeScript
145 lines
6.8 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Bell,
|
|
MessageCircle,
|
|
Mail,
|
|
Send,
|
|
XCircle,
|
|
RefreshCw,
|
|
Clock,
|
|
CheckCircle2,
|
|
AlertTriangle,
|
|
} from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import { PageHeader, Spinner, EmptyState, Badge } from "../components/ui";
|
|
import { formatRelative, cn } from "../lib/format";
|
|
|
|
const CHANNEL = {
|
|
whatsapp: { icon: MessageCircle, color: "#10b981", label: "WhatsApp" },
|
|
sms: { icon: MessageCircle, color: "#3b66ff", label: "SMS" },
|
|
email: { icon: Mail, color: "#a855f7", label: "Email" },
|
|
};
|
|
const KIND_LABEL: Record<string, string> = {
|
|
confirmation: "Confirmación",
|
|
reminder: "Recordatorio",
|
|
cancellation: "Cancelación",
|
|
follow_up: "Seguimiento",
|
|
review: "Reseña",
|
|
};
|
|
const STATUS = [
|
|
{ value: "all", label: "Todas" },
|
|
{ value: "pending", label: "Pendientes" },
|
|
{ value: "sent", label: "Enviadas" },
|
|
];
|
|
|
|
export function NotificationsPage() {
|
|
const qc = useQueryClient();
|
|
const [status, setStatus] = useState("pending");
|
|
const { data: stats } = useQuery({ queryKey: ["notifications", "stats"], queryFn: () => api.notifications.stats() });
|
|
const { data } = useQuery({ queryKey: ["notifications", status], queryFn: () => api.notifications.list(status) });
|
|
|
|
const regen = useMutation({
|
|
mutationFn: () => api.notifications.regenerate(),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["notifications"] }),
|
|
});
|
|
const act = (id: number, kind: "send" | "cancel") =>
|
|
api.notifications[kind](id).then(() => qc.invalidateQueries({ queryKey: ["notifications"] }));
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<PageHeader
|
|
title="Recordatorios"
|
|
subtitle="Reduce inasistencias con recordatorios automáticos por WhatsApp, SMS y email."
|
|
actions={
|
|
<button onClick={() => regen.mutate()} className="btn btn-secondary" disabled={regen.isPending}>
|
|
{regen.isPending ? <Spinner /> : <RefreshCw className="h-4 w-4" />} Regenerar
|
|
</button>
|
|
}
|
|
/>
|
|
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
|
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
<Kpi icon={Clock} color="#f17616" label="Pendientes" value={String(stats?.pending ?? 0)} />
|
|
<Kpi icon={CheckCircle2} color="#10b981" label="Enviadas" value={String(stats?.sent ?? 0)} />
|
|
<Kpi icon={Bell} color="#3b66ff" label="Recordatorios próximos" value={String(stats?.upcoming_reminders ?? 0)} />
|
|
<Kpi icon={AlertTriangle} color="#ef4444" label="Tasa no-show (30d)" value={`${stats?.no_show_rate ?? 0}%`} />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
|
|
{STATUS.map((s) => (
|
|
<button
|
|
key={s.value}
|
|
onClick={() => setStatus(s.value)}
|
|
className={cn("rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors", status === s.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100")}
|
|
>
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card overflow-hidden">
|
|
{!data ? (
|
|
<div className="flex h-40 items-center justify-center"><Spinner /></div>
|
|
) : !data.notifications.length ? (
|
|
<EmptyState icon={Bell} title="Sin notificaciones" description="Regenera los recordatorios o agrega citas próximas." />
|
|
) : (
|
|
<div className="divide-y divide-slate-50">
|
|
{data.notifications.map((n: any) => {
|
|
const ch = CHANNEL[n.channel as keyof typeof CHANNEL] ?? CHANNEL.whatsapp;
|
|
const ChIcon = ch.icon;
|
|
return (
|
|
<div key={n.id} className="group flex items-start gap-3 px-5 py-3">
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white" style={{ background: ch.color }}>
|
|
<ChIcon className="h-4 w-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="text-sm font-bold text-slate-900">{n.client_name ?? "Cliente"}</span>
|
|
<Badge bg="#eef4ff" fg="#1c36dc">{KIND_LABEL[n.kind] ?? n.kind}</Badge>
|
|
{n.service_name && <span className="text-[11px] text-slate-500">· {n.service_name}</span>}
|
|
{n.status === "pending" && <span className="chip bg-amber-50 text-amber-700">pendiente</span>}
|
|
{n.status === "sent" && <span className="chip bg-emerald-50 text-emerald-700"><CheckCircle2 className="h-3 w-3" /> enviada</span>}
|
|
{n.status === "canceled" && <span className="chip bg-slate-100 text-slate-500">cancelada</span>}
|
|
</div>
|
|
<p className="mt-0.5 line-clamp-2 text-xs text-slate-500">{n.message}</p>
|
|
<div className="mt-0.5 text-[10px] text-slate-400">
|
|
Programa: {formatRelative(n.send_at)} · {ch.label}
|
|
</div>
|
|
</div>
|
|
{n.status === "pending" && (
|
|
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<button onClick={() => act(n.id, "send")} className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-emerald-50 hover:text-emerald-600" title="Enviar ahora">
|
|
<Send className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button onClick={() => act(n.id, "cancel")} className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600" title="Cancelar">
|
|
<XCircle className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-center text-[11px] text-slate-400">
|
|
Demo: el envío es simulado (no se conecta a WhatsApp/SMS reales). En producción se integraría con la API de WhatsApp Business / Twilio.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Kpi({ icon: Icon, color, label, value }: any) {
|
|
return (
|
|
<div className="card relative overflow-hidden p-4">
|
|
<div className="absolute -right-4 -top-4 h-16 w-16 rounded-full opacity-10" style={{ background: color }} />
|
|
<Icon className="h-4 w-4" style={{ color }} />
|
|
<div className="mt-1 truncate text-lg font-extrabold text-slate-900">{value}</div>
|
|
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">{label}</div>
|
|
</div>
|
|
);
|
|
}
|