Files
AgendaPro/src/pages/NotificationsPage.tsx
T
AgendaPro DevandClaude Opus 5 4c19244df9 feat: public landing page with magic login, brand palette and demo build flag
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]>
2026-07-28 14:25:58 -06:00

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>
);
}