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>
225 lines
7.3 KiB
TypeScript
225 lines
7.3 KiB
TypeScript
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`;
|
||
}
|