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>
155 lines
5.3 KiB
TypeScript
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} <{email.from.email}>
|
|
</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>
|
|
</>
|
|
);
|
|
}
|