rubis/apps/web/src/components/demo/DemoClock.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

225 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
import { Play, Pause, X, Gauge } from "lucide-react";
import { cn } from "@/lib/utils";
import {
SPEED_OPTIONS,
type Speed,
useDemoEnd,
useDemoState,
useDemoTick,
} from "@/lib/demo";
import { Gem } from "@/components/brand/Gem";
import { DemoEmailSlide } from "./DemoEmailSlide";
/**
* Horloge virtuelle de démo — visible top-right de _app, uniquement
* quand `org.demoMode = true`.
*
* Anatomie :
* ┌─────────────────────────────────────┐
* │ vendredi 18 mai 2026 │
* │ ◆────●───────────── J+5 / →prochain│
* │ [▶] 1x 2x 5x [↻] [×] │
* └─────────────────────────────────────┘
*
* - Date pleine, font display, mise à jour live à chaque frame
* - Rail rubis-glow avec une pastille qui glisse de virtualNow vers le
* prochain event (proportion calculée backend → SPA)
* - Play/Pause + sélecteur de vitesse 1x/2x/5x
* - Bouton fermer = `/demo/end`
*
* Quand un event est déclenché, la slide-over droite s'ouvre avec
* l'email capturé. L'horloge est en pause tant que tous les events
* en attente n'ont pas été acquittés (clic "Continuer").
*/
const FR_DATE = new Intl.DateTimeFormat("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
export function DemoClock() {
const { data: state } = useDemoState();
const endMutation = useDemoEnd();
const enabled = state?.demoMode === true;
const tick = useDemoTick({
enabled,
initialVirtualNow: state?.virtualNow,
});
// Re-sync local virtualNow quand le backend change (start/reset)
useEffect(() => {
if (state?.virtualNow) tick.resetTo(state.virtualNow);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state?.virtualNow, enabled]);
if (!enabled) return null;
const dateStr = FR_DATE.format(tick.virtualNow);
// Progression vers le prochain event (0..1) — sert au rail visuel.
const progress = computeProgress({
virtualNow: tick.virtualNow,
nextEventAt: state?.nextEventAt ?? null,
});
const hasPending = tick.pendingEvents.length > 0;
return (
<>
<div
className={cn(
"fixed top-4 right-4 z-30 w-[300px]",
"rounded-card border border-rubis-glow bg-white shadow-card",
"px-4 py-3",
)}
>
{/* En-tête : date + tag DÉMO */}
<div className="flex items-start justify-between mb-2">
<div>
<p className="font-display text-[14.5px] font-bold leading-tight text-ink capitalize tabular-nums">
{dateStr}
</p>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold mt-0.5">
Mode démo · horloge virtuelle
</p>
</div>
<button
type="button"
onClick={() => endMutation.mutate()}
className="size-6 flex items-center justify-center rounded-full text-ink-3 hover:text-rubis-deep hover:bg-rubis-glow/40 transition-colors"
aria-label="Quitter le mode démo"
title="Quitter le mode démo"
>
<X size={14} />
</button>
</div>
{/* Rail rubis-glow avec pastille qui glisse */}
<div className="relative h-2 mb-3 mt-1">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-line" />
<div
className="absolute top-1/2 -translate-y-1/2 h-px bg-rubis transition-[width] duration-300"
style={{ width: `${progress * 100}%` }}
/>
<span
aria-hidden="true"
className="absolute -top-1 size-3 rotate-45 bg-rubis shadow-rubis transition-[left] duration-300"
style={{ left: `calc(${progress * 100}% - 6px)` }}
/>
<Gem
size={10}
aria-hidden="true"
className="absolute -top-1 -left-0.5"
/>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => (tick.playing ? tick.pause() : tick.play())}
disabled={hasPending}
className={cn(
"size-9 flex items-center justify-center rounded-default",
"bg-rubis text-white shadow-rubis hover:bg-rubis-deep transition-colors",
"disabled:bg-line disabled:text-ink-3 disabled:shadow-none disabled:cursor-not-allowed",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
aria-label={tick.playing ? "Pause" : "Lecture"}
>
{tick.playing ? <Pause size={14} /> : <Play size={14} className="ml-0.5" />}
</button>
<SpeedSelector value={tick.speed} onChange={tick.setSpeed} />
<p className="ml-auto text-[10.5px] text-ink-3 italic tabular-nums">
{state?.nextEventAt
? `${shortNextLabel(tick.virtualNow, state.nextEventAt)}`
: "aucun event en file"}
</p>
</div>
</div>
{/* Slide-over : empile les events fired à acquitter un par un */}
{hasPending && (
<DemoEmailSlide
event={tick.pendingEvents[0]!}
remaining={tick.pendingEvents.length - 1}
virtualNow={tick.virtualNow}
onContinue={tick.acknowledge}
/>
)}
</>
);
}
function SpeedSelector({
value,
onChange,
}: {
value: Speed;
onChange: (s: Speed) => void;
}) {
return (
<div
role="radiogroup"
aria-label="Vitesse"
className="inline-flex items-center gap-1 rounded-default border border-line bg-cream-2/40 px-1.5 py-0.5"
>
<Gauge size={11} className="text-ink-3" aria-hidden="true" />
{SPEED_OPTIONS.map((s) => {
const active = s === value;
return (
<button
key={s}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(s)}
className={cn(
"h-6 px-1.5 rounded-sharp text-[11px] font-semibold tabular-nums transition-colors",
active
? "bg-rubis text-white"
: "text-ink-2 hover:bg-cream-2",
)}
>
{s}x
</button>
);
})}
</div>
);
}
function computeProgress({
virtualNow,
nextEventAt,
}: {
virtualNow: Date;
nextEventAt: string | null;
}): number {
if (!nextEventAt) return 0;
const next = new Date(nextEventAt).getTime();
// On affiche la progression sur une fenêtre de 30 jours autour du prochain event
// (évite que la pastille soit collée à 0% ou 100% en permanence).
const start = next - 30 * 86400000;
const now = virtualNow.getTime();
if (now <= start) return 0;
if (now >= next) return 1;
return (now - start) / (next - start);
}
function shortNextLabel(now: Date, iso: string): string {
const next = new Date(iso).getTime();
const diffMs = next - now.getTime();
if (diffMs <= 0) return "imminent";
const days = Math.round(diffMs / 86400000);
if (days <= 0) return "aujourd'hui";
return `dans ${days} j`;
}