rubis/apps/web/src/components/demo/DemoEmailSlide.tsx
ordinarthur 933c6496b1 feat(demo): mode démo live — horloge virtuelle + emails capturés
Permet de faire vivre Rubis en accéléré pour démontrer le produit à des
prospects, SANS impacter la prod. Les vrais users ont demoMode=false par
défaut → toute la logique démo est court-circuitée.

Architecture (priorité : zéro impact prod, codebase propre)

Phase 1 — Abstraction Clock
- Migration : organizations.demo_mode + virtual_now + demo_speed_factor
  (défaut false/null/1, zéro effet sur les orgs existantes)
- services/clock.ts : now(orgId?) → DateTime.utc() en prod, virtualNow
  en démo. Cache mémoire 250ms pour pas spammer la DB. Helpers
  setVirtualNow / setDemoMode pour les transitions.
- Refacto 7 fichiers : relance_scheduler, checkin_scheduler, dashboard,
  send_relance_job, send_checkin_job, mail_dispatcher (buildRelanceVars
  daysLate), activity_recorder, checkin_controller, invoices_controller
  (buildTimeline + markPaid). DateTime.now() → clock.now(orgId).
- Tests existants (51) passent identique → preuve que la prod est intacte.

Phase 2 — Capture emails + dispatch
- Migration : demo_captured_emails (kind, to, from, subject, body, sent_at,
  meta) — index sur (org, sent_at desc) pour l'inbox.
- services/demo/capture.ts : captureEmailIfDemo() — UNIQUE point de fork
  dans la prod (deux lignes dans mail_dispatcher : if captured return).
  Hors démo, fonction retourne false → flux Resend inchangé.
- services/demo/dispatch.ts : tickAndDispatch(orgId, target) → bump
  virtual_now, trouve les tasks dues (relance + checkin), invoke les
  handlers existants synchronement (skip BullMQ, propre). Retourne les
  events fired pour l'UI.
- POST /api/v1/demo/{start,end,tick} + GET /demo/{state,inbox}, toutes
  protégées par requireDemoOrg() (403 si demoMode=false).

Phase 3 — UI horloge "vivante"
- lib/demo.ts : useDemoState, useDemoTick (boucle rAF locale qui avance
  virtualNow à `speed * elapsed` jours/sec, sync backend toutes les
  250ms, auto-pause sur fired events). Pas de boutons +1j/+3j —
  l'horloge tourne vraiment.
- DemoClock (top-right, fixed) : date pleine en font display, rail
  rubis-glow avec pastille ◆ qui glisse vers le prochain event,
  play/pause + sélecteur 1x/2x/5x. Auto-cachée si demoMode=false.
- DemoEmailSlide : slide-over droite quand event fires — affiche
  l'email capturé (de/à/sujet/body) façon vrai client mail. Pause
  forcée tant que tous les events ne sont pas acquittés ("comme si
  le temps était vraiment passé").
- DemoToggle dans /parametres : démarrer/quitter le mode démo, avec
  copy explicite ("emails capturés, pas envoyés à de vrais clients").

Le code démo vit isolé dans services/demo/, controllers/demo_controller.ts,
components/demo/, lib/demo.ts. La prod ne référence ces fichiers QUE
via captureEmailIfDemo dans mail_dispatcher.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 10:42:59 +02:00

155 lines
5.3 KiB
TypeScript

import { useEffect, useState } from "react";
import { Mail, ArrowRight, X } from "lucide-react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { DemoCapturedEmail, FiredEvent } from "@/lib/demo";
import { Button } from "@/components/ui/Button";
/**
* Slide-over droite affichant l'email qui vient d'être déclenché.
* Tant que l'utilisateur n'a pas cliqué "Continuer la démo", l'horloge
* reste en pause — c'est l'effet "le temps s'est arrêté pour montrer".
*/
const FR_DATETIME = new Intl.DateTimeFormat("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
hour: "2-digit",
minute: "2-digit",
});
export function DemoEmailSlide({
event,
remaining,
virtualNow,
onContinue,
}: {
event: FiredEvent;
remaining: number;
virtualNow: Date;
onContinue: () => void;
}) {
const [email, setEmail] = useState<DemoCapturedEmail | null>(null);
useEffect(() => {
if (!event.capturedEmailId) {
setEmail(null);
return;
}
let cancelled = false;
void api
.get<DemoCapturedEmail[]>("/api/v1/demo/inbox")
.then((list) => {
if (cancelled) return;
const found = list.find((e) => e.id === event.capturedEmailId);
setEmail(found ?? null);
})
.catch(() => setEmail(null));
return () => {
cancelled = true;
};
}, [event.capturedEmailId]);
// Petit slide-in subtil — pas une animation tape-à-l'œil, on tient la DA.
return (
<>
{/* Backdrop discret — on ne ferme pas au clic dehors (volontaire :
l'utilisateur DOIT acquitter pour reprendre la démo). */}
<div
aria-hidden="true"
className="fixed inset-0 z-40 bg-ink/10 backdrop-blur-[2px]"
/>
<aside
role="dialog"
aria-label="Email reçu pendant la démo"
className={cn(
"fixed top-0 right-0 z-50 h-screen w-full max-w-[520px]",
"bg-cream border-l border-line shadow-card",
"flex flex-col",
"animate-in slide-in-from-right duration-200",
)}
>
{/* Header */}
<div className="flex items-center gap-3 border-b border-line bg-white px-5 py-4">
<span
className={cn(
"shrink-0 size-9 flex items-center justify-center rounded-full",
event.kind === "relance" ? "bg-rubis text-white" : "bg-rubis-glow text-rubis-deep",
)}
>
<Mail size={15} />
</span>
<div className="flex-1 min-w-0">
<p className="font-display text-[14.5px] font-bold text-ink leading-tight">
{event.kind === "relance" ? "Relance envoyée" : "Check-in envoyé"}
</p>
<p className="text-[11.5px] text-ink-3 capitalize">
{FR_DATETIME.format(virtualNow)} · facture {event.invoiceNumero}
</p>
</div>
</div>
{/* Email rendu façon vrai client mail */}
<div className="flex-1 overflow-y-auto px-5 py-5">
{email ? (
<article className="rounded-card border border-line bg-white shadow-soft overflow-hidden">
<div className="border-b border-line bg-cream-2/50 px-5 py-3 space-y-1">
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">De :</span>{" "}
{email.from.name} &lt;{email.from.email}&gt;
</p>
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">À :</span>{" "}
{email.to.name ? `${email.to.name}` : ""}
{email.to.email}
</p>
{email.replyTo && (
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">Reply-To :</span>{" "}
{email.replyTo}
</p>
)}
<p className="font-display text-[15px] font-bold text-ink mt-1.5 leading-tight">
{email.subject}
</p>
</div>
<div className="px-5 py-4">
<pre className="whitespace-pre-wrap font-sans text-[13.5px] leading-relaxed text-ink-2">
{email.body}
</pre>
</div>
</article>
) : (
<p className="text-[13px] italic text-ink-3 text-center py-8">
Chargement de l'email
</p>
)}
</div>
{/* Footer */}
<div className="border-t border-line bg-white px-5 py-4 flex items-center justify-between">
<p className="text-[11.5px] text-ink-3 italic">
{remaining > 0
? `${remaining} autre${remaining > 1 ? "s" : ""} email${remaining > 1 ? "s" : ""} à voir`
: "Cliquez pour reprendre la démo"}
</p>
<Button size="sm" onClick={onContinue}>
Continuer <ArrowRight size={14} />
</Button>
</div>
<button
type="button"
onClick={onContinue}
className="absolute top-3 right-3 size-7 flex items-center justify-center rounded-full text-ink-3 hover:bg-cream-2"
aria-label="Fermer"
>
<X size={14} />
</button>
</aside>
</>
);
}