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>
This commit is contained in:
ordinarthur 2026-05-07 10:42:59 +02:00
parent 6eb9ca4120
commit 933c6496b1
25 changed files with 1483 additions and 28 deletions

View File

@ -3,9 +3,9 @@ import Invoice from '#models/invoice'
import { hashCheckinToken } from '#services/checkin_token'
import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler'
import * as clock from '#services/clock'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import { DateTime } from 'luxon'
import type { HttpContext } from '@adonisjs/core/http'
const CHECKIN_TTL_HOURS = 24
@ -45,7 +45,7 @@ async function resolveCheckin(token: string): Promise<ResolvedTask> {
// Expiration : 24h après l'envoi (sentAt). Tant qu'elle n'a pas été
// envoyée, le link n'est pas censé exister côté user — sécurité belt.
if (task.sentAt && task.sentAt.plus({ hours: CHECKIN_TTL_HOURS }) < DateTime.now()) {
if (task.sentAt && task.sentAt.plus({ hours: CHECKIN_TTL_HOURS }) < (await clock.now(task.organizationId))) {
task.status = 'expired'
await task.save()
return { redirect: spaRedirectUrl('expired') }
@ -77,17 +77,18 @@ export default class CheckinController {
const { task, invoice } = result
await db.transaction(async (trx) => {
const nowOrg = await clock.now(invoice.organizationId)
task.useTransaction(trx)
task.status = 'answered'
task.answer = 'paid'
task.answeredAt = DateTime.now()
task.answeredAt = nowOrg
await task.save()
// Mark paid (mêmes effets que POST /invoices/:id/mark-paid).
if (invoice.status !== 'paid') {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = DateTime.now()
invoice.paidAt = nowOrg
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()
@ -133,7 +134,7 @@ export default class CheckinController {
task.useTransaction(trx)
task.status = 'answered'
task.answer = 'still_pending'
task.answeredAt = DateTime.now()
task.answeredAt = await clock.now(invoice.organizationId)
await task.save()
})

View File

@ -0,0 +1,204 @@
import { DateTime } from 'luxon'
import vine from '@vinejs/vine'
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import Organization from '#models/organization'
import DemoCapturedEmail from '#models/demo_captured_email'
import RelanceTask from '#models/relance_task'
import CheckinTask from '#models/checkin_task'
import * as clock from '#services/clock'
import { tickAndDispatch } from '#services/demo/dispatch'
const tickValidator = vine.create({
/** ISO 8601 — la cible vers laquelle avancer. */
virtualNow: vine.string(),
})
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
async function requireDemoOrg(orgId: string): Promise<Organization> {
const org = await Organization.findOrFail(orgId)
if (!org.demoMode) {
throw new Exception('Mode démo désactivé pour cette organisation', {
status: 403,
code: 'demo_disabled',
})
}
return org
}
/**
* Endpoints du mode démo.
*
* Garde-fou de sécurité : tous les endpoints sauf `/start` et `/end`
* vérifient `org.demoMode = true` avant d'opérer. La prod ne peut donc
* jamais se retrouver à exécuter du code démo par accident, même si
* un endpoint était appelé par erreur.
*/
export default class DemoController {
/**
* POST /api/v1/demo/start
*
* Active le mode démo sur l'org du user. virtual_now = maintenant.
* Wipe les emails capturés précédents (reset propre).
*/
async start({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
await db.transaction(async (trx) => {
const org = await Organization.findOrFail(orgId, { client: trx })
org.useTransaction(trx)
org.demoMode = true
org.virtualNow = DateTime.utc()
await org.save()
// Reset l'inbox démo précédente
await DemoCapturedEmail.query({ client: trx }).where('organization_id', orgId).delete()
})
// Force un reload du cache clock
await clock.setVirtualNow(orgId, DateTime.utc())
const org = await Organization.findOrFail(orgId)
return response.json({
data: {
demoMode: org.demoMode,
virtualNow: org.virtualNow?.toISO(),
speedFactor: org.demoSpeedFactor,
},
})
}
/**
* POST /api/v1/demo/end
*
* Désactive le mode démo. virtual_now=null clock.now() retombe sur
* Date.now() à la prochaine lecture.
*/
async end({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
await clock.setDemoMode(orgId, false)
return response.json({ data: { demoMode: false } })
}
/**
* POST /api/v1/demo/tick { virtualNow: ISO }
*
* Avance virtual_now à la cible et fire les tasks dues entre l'ancien
* et le nouveau virtual_now. Retourne les events déclenchés.
*
* Le client appelle cet endpoint depuis sa boucle rAF (typiquement
* toutes les 250ms quand il joue, avec virtualNow incrémenté de
* `speed * elapsed`).
*/
async tick({ auth, request, response }: HttpContext) {
const orgId = requireOrgId(auth)
await requireDemoOrg(orgId)
const { virtualNow } = await request.validateUsing(tickValidator)
const target = DateTime.fromISO(virtualNow, { zone: 'utc' })
if (!target.isValid) {
throw new Exception('virtualNow ISO invalide', { status: 422, code: 'invalid_iso' })
}
const fired = await tickAndDispatch(orgId, target)
return response.json({
data: {
virtualNow: target.toISO(),
firedEvents: fired,
},
})
}
/**
* GET /api/v1/demo/inbox
*
* Liste des emails capturés pour l'org démo, plus récent en tête.
* Le SPA lit cet endpoint pour la slide-over "boîte de réception".
*/
async inbox({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
await requireDemoOrg(orgId)
const emails = await DemoCapturedEmail.query()
.where('organization_id', orgId)
.orderBy('sent_at', 'desc')
.limit(50)
return response.json({
data: emails.map((e) => ({
id: e.id,
kind: e.kind,
from: { email: e.fromEmail, name: e.fromName },
to: { email: e.toEmail, name: e.toName },
replyTo: e.replyTo,
subject: e.subject,
body: e.body,
meta: e.meta,
sentAt: e.sentAt.toISO(),
})),
})
}
/**
* GET /api/v1/demo/state
*
* État courant : virtualNow, prochaine task à fire (pour que le SPA
* sache quand auto-pause), nombre d'emails en inbox.
* Lu au mount du SPA pour reprendre une démo en cours.
*/
async state({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
const org = await Organization.findOrFail(orgId)
if (!org.demoMode) {
return response.json({ data: { demoMode: false } })
}
// Prochaine task scheduled — relance ou checkin, la plus proche.
const nextRelance = await RelanceTask.query()
.where('organization_id', orgId)
.where('status', 'scheduled')
.orderBy('send_at', 'asc')
.first()
const nextCheckin = await CheckinTask.query()
.where('organization_id', orgId)
.where('status', 'scheduled')
.orderBy('send_at', 'asc')
.first()
let nextEventAt: string | null = null
if (nextRelance && nextCheckin) {
nextEventAt =
nextRelance.sendAt < nextCheckin.sendAt
? nextRelance.sendAt.toISO()
: nextCheckin.sendAt.toISO()
} else if (nextRelance) {
nextEventAt = nextRelance.sendAt.toISO()
} else if (nextCheckin) {
nextEventAt = nextCheckin.sendAt.toISO()
}
const inboxCount = await DemoCapturedEmail.query()
.where('organization_id', orgId)
.count('* as total')
.first()
return response.json({
data: {
demoMode: true,
virtualNow: org.virtualNow?.toISO(),
speedFactor: org.demoSpeedFactor,
nextEventAt,
inboxCount: Number((inboxCount as unknown as { $extras: { total: string } })?.$extras?.total ?? 0),
},
})
}
}

View File

@ -12,6 +12,7 @@ import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances } from '#services/relance_scheduler'
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
import logger from '@adonisjs/core/services/logger'
import * as clock from '#services/clock'
const PAGE_SIZE = 50
@ -47,7 +48,9 @@ function serializeInvoice(i: Invoice) {
*/
function buildTimeline(
invoice: Invoice,
relanceTasks: RelanceTask[] = []
relanceTasks: RelanceTask[] = [],
// `now` injecté par le caller — orgs en mode démo lisent depuis virtualNow.
now: DateTime = DateTime.utc()
): Array<{
id: string
state: 'past' | 'current' | 'future'
@ -70,7 +73,7 @@ function buildTimeline(
if (invoice.plan?.steps?.length && invoice.status !== 'paid' && invoice.status !== 'cancelled') {
const dueMs = invoice.dueDate.toMillis()
const nowMs = DateTime.now().toMillis()
const nowMs = now.toMillis()
const taskByStepId = new Map(relanceTasks.map((task) => [task.planStepId, task]))
let currentSet = false
@ -257,7 +260,7 @@ export default class InvoicesController {
requiresManualValidation: s.requiresManualValidation,
})),
},
timeline: buildTimeline(invoice, relanceTasks),
timeline: buildTimeline(invoice, relanceTasks, await clock.now(invoice.organizationId)),
},
})
}
@ -348,7 +351,7 @@ export default class InvoicesController {
await db.transaction(async (trx) => {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = DateTime.now()
invoice.paidAt = await clock.now(invoice.organizationId)
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()

View File

@ -2,8 +2,8 @@ import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice'
import User from '#models/user'
import { sendCheckinEmail } from '#services/mail_dispatcher'
import * as clock from '#services/clock'
import env from '#start/env'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
/**
@ -64,6 +64,6 @@ export async function sendCheckinJob(jobData: { taskId: string; plain: string })
})
task.status = 'sent'
task.sentAt = DateTime.now()
task.sentAt = await clock.now(invoice.organizationId)
await task.save()
}

View File

@ -3,8 +3,8 @@ import Invoice from '#models/invoice'
import User from '#models/user'
import { sendRelanceEmail } from '#services/mail_dispatcher'
import { recordActivity } from '#services/activity_recorder'
import * as clock from '#services/clock'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
/**
@ -60,7 +60,7 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
await db.transaction(async (trx) => {
task.useTransaction(trx)
task.status = 'sent' // On considère la task "traitée" — le brouillon est l'output
task.sentAt = DateTime.now()
task.sentAt = await clock.now(invoice.organizationId)
await task.save()
await recordActivity({
@ -87,10 +87,11 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
organization: invoice.organization,
})
const sentAt = await clock.now(invoice.organizationId)
await db.transaction(async (trx) => {
task.useTransaction(trx)
task.status = 'sent'
task.sentAt = DateTime.now()
task.sentAt = sentAt
await task.save()
invoice.useTransaction(trx)

View File

@ -0,0 +1,9 @@
import { DemoCapturedEmailSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
export default class DemoCapturedEmail extends DemoCapturedEmailSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
}

View File

@ -1,5 +1,6 @@
import { DateTime } from 'luxon'
import ActivityEvent from '#models/activity_event'
import * as clock from '#services/clock'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
type EventKind = 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
@ -33,7 +34,7 @@ export async function recordActivity(opts: RecordOpts): Promise<ActivityEvent> {
kind,
label,
meta,
at: at ?? DateTime.now(),
at: at ?? (await clock.now(organizationId)),
},
trx ? { client: trx } : undefined
)

View File

@ -1,8 +1,8 @@
import { DateTime } from 'luxon'
import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import { generateCheckinToken } from '#services/checkin_token'
import * as clock from '#services/clock'
import app from '@adonisjs/core/services/app'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
@ -47,7 +47,7 @@ export async function scheduleCheckinForInvoice(
await t.save()
}
const now = DateTime.now()
const now = await clock.now(invoice.organizationId)
const sendAtRaw = invoice.dueDate
const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw

View File

@ -0,0 +1,111 @@
import { DateTime } from 'luxon'
import Organization from '#models/organization'
/**
* Clock abstraction time-sensitive de l'app.
*
* En prod (`org.demoMode = false`, ou pas d'orgId fourni), retourne
* `DateTime.utc()`. C'est l'API à utiliser **par défaut** dans tous les
* services qui font des comparaisons de dates (relance scheduler,
* checkin scheduler, dashboard KPIs, jobs BullMQ).
*
* En mode démo (`org.demoMode = true` ET `org.virtualNow != null`),
* retourne le `virtualNow` stocké sur l'org. C'est ce qui permet
* "d'avancer dans le temps" pendant une démo sans toucher au système.
*
* **Garde-fou prod** : si `orgId` est absent, on retourne toujours
* l'horloge système on ne fait JAMAIS un side-effect demoMode si
* on n'a pas explicitement le contexte d'une org.
*
* Cette abstraction sert aussi aux tests (on pourra mock plus tard
* via un singleton injectable, V2). En V1, on lit la DB à chaque
* appel : c'est fonctionnel, et le cache n'a pas de sens vu que la
* valeur peut bouger pendant une démo.
*
* Cache : on prend un cache mémoire très court (250ms) pour ne pas
* spammer la DB quand un même handler appelle clock.now() plusieurs
* fois. Invalidé à chaque écriture (cf. setVirtualNow).
*/
type CachedClock = {
demoMode: boolean
virtualNow: DateTime | null
fetchedAt: number
}
const CACHE_TTL_MS = 250
const cache = new Map<string, CachedClock>()
async function loadOrg(orgId: string): Promise<CachedClock> {
const cached = cache.get(orgId)
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) return cached
const org = await Organization.find(orgId)
const entry: CachedClock = {
demoMode: org?.demoMode ?? false,
virtualNow: org?.virtualNow ?? null,
fetchedAt: Date.now(),
}
cache.set(orgId, entry)
return entry
}
/**
* Heure courante. Async pour permettre la lecture DB en mode démo.
*
* const now = await clock.now(invoice.organizationId)
*
* Si `orgId` est `null` / `undefined`, retourne `DateTime.utc()`
* synchronement-compatible (pas de side-effect démo).
*/
export async function now(orgId?: string | null): Promise<DateTime> {
if (!orgId) return DateTime.utc()
const c = await loadOrg(orgId)
if (c.demoMode && c.virtualNow) return c.virtualNow
return DateTime.utc()
}
/**
* Variante sync utile dans des chemins on n'a pas d'orgId
* (création de tokens auth par exemple). Comportement strictement
* identique à `DateTime.utc()`. Existe pour faciliter une migration
* incrémentale : remplacer `DateTime.utc()` ou `DateTime.now()` par
* `clockSync()` rend explicite que c'est une horloge système.
*/
export function nowSync(): DateTime {
return DateTime.utc()
}
/**
* Met à jour `virtual_now` sur une org (utilisé par /demo/tick).
* Invalide le cache pour que le prochain `now(orgId)` renvoie la
* nouvelle valeur sans attendre le TTL.
*/
export async function setVirtualNow(orgId: string, virtualNow: DateTime): Promise<void> {
const org = await Organization.findOrFail(orgId)
org.virtualNow = virtualNow
await org.save()
cache.delete(orgId)
}
/**
* Active/désactive le mode démo. Côté `start`, on initialise
* `virtualNow` à maintenant (UTC) si pas déjà set.
*/
export async function setDemoMode(orgId: string, enabled: boolean): Promise<void> {
const org = await Organization.findOrFail(orgId)
org.demoMode = enabled
if (enabled && !org.virtualNow) {
org.virtualNow = DateTime.utc()
}
if (!enabled) {
org.virtualNow = null
}
await org.save()
cache.delete(orgId)
}
/** Invalidation manuelle (tests). */
export function clearClockCache(): void {
cache.clear()
}

View File

@ -1,6 +1,7 @@
import db from '@adonisjs/lucid/services/db'
import Organization from '#models/organization'
import { DateTime } from 'luxon'
import * as clock from '#services/clock'
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
@ -38,7 +39,7 @@ function startOfDay(d: DateTime): Date {
* Le contrat reste stable côté SPA.
*/
export async function computeKpis(organizationId: string): Promise<DashboardKpis> {
const now = DateTime.now()
const now = await clock.now(organizationId)
const monthStart = startOfMonth(now)
const todayStart = startOfDay(now)
const prevMonthStart = startOfMonth(now.minus({ months: 1 }))
@ -128,7 +129,7 @@ export async function topLatePayers(
organizationId: string,
limit = 5
): Promise<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
const today = startOfDay(DateTime.now())
const today = startOfDay(await clock.now(organizationId))
const rows = await db
.from('invoices')
@ -240,7 +241,7 @@ async function fetchPaidByMonth(params: {
clientId?: string
range: RangeMonths
}): Promise<PaidByMonthPoint[]> {
const now = DateTime.utc()
const now = await clock.now(params.organizationId)
const firstBucket = now.minus({ months: params.range - 1 }).startOf('month')
const buckets = new Map<string, PaidByMonthPoint>()

View File

@ -0,0 +1,48 @@
import DemoCapturedEmail from '#models/demo_captured_email'
import * as clock from '#services/clock'
import Organization from '#models/organization'
/**
* Service de capture d'emails en mode démo.
*
* Tout le code "démo" vit dans `services/demo/*` la prod ne référence
* qu'une seule fonction (`captureEmailIfDemo`) depuis `mail_dispatcher`,
* pour minimiser le couplage.
*
* Si l'org est en mode démo : crée une `DemoCapturedEmail` avec le
* timestamp virtualNow et retourne `true` caller doit ne PAS envoyer
* via Resend.
*
* Sinon : retourne `false` comportement prod inchangé.
*/
export type CaptureInput = {
organizationId: string
kind: 'relance' | 'checkin'
to: { email: string; name?: string | null }
from: { email: string; name?: string | null }
replyTo?: string | null
subject: string
body: string
meta?: Record<string, unknown>
}
export async function captureEmailIfDemo(input: CaptureInput): Promise<boolean> {
const org = await Organization.find(input.organizationId)
if (!org || !org.demoMode) return false
const sentAt = await clock.now(input.organizationId)
await DemoCapturedEmail.create({
organizationId: input.organizationId,
kind: input.kind,
toEmail: input.to.email,
toName: input.to.name ?? null,
fromEmail: input.from.email,
fromName: input.from.name ?? null,
replyTo: input.replyTo ?? null,
subject: input.subject,
body: input.body,
meta: input.meta ?? {},
sentAt,
})
return true
}

View File

@ -0,0 +1,140 @@
import { DateTime } from 'luxon'
import db from '@adonisjs/lucid/services/db'
import RelanceTask from '#models/relance_task'
import CheckinTask from '#models/checkin_task'
import * as clock from '#services/clock'
import { sendRelanceJob } from '#jobs/send_relance_job'
import { sendCheckinJob } from '#jobs/send_checkin_job'
/**
* Démo dispatch quand le SPA tick l'horloge virtuelle, on cherche
* les tasks dont la date d'envoi virtuelle est dépassée et on les
* exécute synchronement (les handlers BullMQ sont appelés directement,
* skippant Redis propre, aucune dépendance externe en démo).
*
* Les emails sont capturés (cf. captureEmailIfDemo dans mail_dispatcher),
* pas envoyés réellement.
*
* Idempotent : seules les tasks `scheduled` sont prises ; les déjà
* traitées sont ignorées. Si on tick deux fois avec la même `targetVirtualNow`,
* la deuxième tick ne fait rien.
*/
export type FiredEvent = {
kind: 'relance' | 'checkin'
taskId: string
invoiceId: string
/** Numéro facture pour le toast UI ("Relance F-2026-0042 envoyée"). */
invoiceNumero: string
/** ISO du DemoCapturedEmail créé — permet de l'afficher direct. */
capturedEmailId: string | null
/** Le moment virtuel où l'event s'est produit. */
firedAt: string
}
/**
* Avance virtual_now et fire les tasks dues. Retourne les events
* déclenchés (à afficher en UI + auto-pause de l'horloge).
*
* Note : on récupère les tasks AVANT de bumper virtual_now, puis on
* ajuste leur `sendAt` virtuel pour comparer correctement. Concrètement :
* - relance task.sendAt et checkin task.sendAt sont déjà des dates
* absolues (basées sur invoice.dueDate qui ne bouge pas)
* - on les compare donc à la cible (target virtualNow)
*
* Ordre d'exécution : par sendAt croissant pour que les events
* soient présentés au user dans l'ordre temporel.
*/
export async function tickAndDispatch(
organizationId: string,
targetVirtualNow: DateTime
): Promise<FiredEvent[]> {
// Met à jour virtual_now AVANT de chercher les tasks → les handlers
// qui appellent clock.now() à l'intérieur lisent la valeur cible.
await clock.setVirtualNow(organizationId, targetVirtualNow)
// Tasks "à envoyer" : status scheduled + sendAt <= cible.
const dueRelances = await RelanceTask.query()
.where('organization_id', organizationId)
.where('status', 'scheduled')
.where('send_at', '<=', targetVirtualNow.toJSDate())
.preload('planStep')
.orderBy('send_at', 'asc')
const dueCheckins = await CheckinTask.query()
.where('organization_id', organizationId)
.where('status', 'scheduled')
.where('send_at', '<=', targetVirtualNow.toJSDate())
.orderBy('send_at', 'asc')
// Merge ordered par sendAt pour un récit chronologique.
type Pending =
| { type: 'relance'; task: RelanceTask }
| { type: 'checkin'; task: CheckinTask; plain: string | null }
const pending: Pending[] = []
for (const t of dueRelances) pending.push({ type: 'relance', task: t })
for (const t of dueCheckins) pending.push({ type: 'checkin', task: t, plain: null })
pending.sort((a, b) => a.task.sendAt.toMillis() - b.task.sendAt.toMillis())
const fired: FiredEvent[] = []
for (const p of pending) {
if (p.type === 'relance') {
// Le job a déjà la logique complète (mark sent, bump rubis, activity).
await sendRelanceJob({ taskId: p.task.id })
const invoice = await db
.from('invoices')
.where('id', p.task.invoiceId)
.select('id', 'numero')
.first()
const captured = await db
.from('demo_captured_emails')
.where('organization_id', organizationId)
.whereRaw(`(meta->>'invoiceId') = ?`, [p.task.invoiceId])
.orderBy('sent_at', 'desc')
.first()
fired.push({
kind: 'relance',
taskId: p.task.id,
invoiceId: p.task.invoiceId,
invoiceNumero: invoice?.numero ?? '',
capturedEmailId: captured?.id ?? null,
firedAt: targetVirtualNow.toISO()!,
})
} else {
// Pour le checkin job, on a besoin du plain token — pas stocké
// (on a juste le hash). En démo on regénère un "plain" volatile
// dérivé de l'ID — les liens paid/pending n'ont pas vraiment de
// sens ici (l'utilisateur ne va pas cliquer dessus en démo).
// On délègue quand même à sendCheckinJob qui appellera l'envoi
// (capturé), puis update task.status.
await sendCheckinJob({ taskId: p.task.id, plain: 'demo-' + p.task.id })
const invoice = await db
.from('invoices')
.where('id', p.task.invoiceId)
.select('id', 'numero')
.first()
const captured = await db
.from('demo_captured_emails')
.where('organization_id', organizationId)
.whereRaw(`(meta->>'invoiceId') = ?`, [p.task.invoiceId])
.orderBy('sent_at', 'desc')
.first()
fired.push({
kind: 'checkin',
taskId: p.task.id,
invoiceId: p.task.invoiceId,
invoiceNumero: invoice?.numero ?? '',
capturedEmailId: captured?.id ?? null,
firedAt: targetVirtualNow.toISO()!,
})
}
}
return fired
}

View File

@ -2,6 +2,8 @@ import mail from '@adonisjs/mail/services/main'
import env from '#start/env'
import { DateTime } from 'luxon'
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
import * as clock from '#services/clock'
import { captureEmailIfDemo } from '#services/demo/capture'
import type Invoice from '#models/invoice'
import type Client from '#models/client'
import type PlanStep from '#models/plan_step'
@ -34,16 +36,19 @@ export function buildRelanceVars({
client,
user,
organization,
now = DateTime.utc(),
}: {
invoice: Pick<Invoice, 'numero' | 'amountTtcCents' | 'dueDate' | 'issueDate'>
client: Pick<Client, 'name' | 'email' | 'contactFirstName' | 'contactLastName'>
user: Pick<User, 'fullName' | 'signature' | 'email'> | null
organization?: Pick<Organization, 'name'> | null
/** `now` injecté pour respecter virtualNow en mode démo. */
now?: DateTime
}) {
const dueDate = invoice.dueDate.toJSDate()
// Jours de retard arrondis à l'entier (UTC pour cohérence).
// Jours de retard arrondis à l'entier — démo-aware via `now` injecté.
const daysLate = Math.floor(
DateTime.utc().startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
now.startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
)
return {
client: {
@ -80,14 +85,38 @@ export async function sendRelanceEmail({
user,
organization,
}: RelancePayload) {
const vars = buildRelanceVars({ invoice, client, user, organization })
const vars = buildRelanceVars({
invoice,
client,
user,
organization,
now: await clock.now(invoice.organizationId),
})
const subject = renderTemplate(step.subject, vars)
const body = renderTemplate(step.body, vars)
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
// FORK DÉMO — unique point où l'app dévie de la prod. Si l'org est
// en mode démo, on capture l'email dans demo_captured_emails au lieu
// de l'envoyer via Resend. Tout le reste du pipeline (idempotence,
// status update, rubis bump) tourne identique.
const captured = await captureEmailIfDemo({
organizationId: invoice.organizationId,
kind: 'relance',
to: { email: client.email, name: client.name },
from: { email: fromAddress, name: fromName },
replyTo: user?.email ?? null,
subject,
body,
meta: { invoiceId: invoice.id, clientId: client.id, stepOrder: step.order },
})
if (captured) return // demo : ne pas envoyer pour de vrai
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
m.from(fromAddress, fromName)
.to(client.email, client.name)
.subject(subject)
// Texte brut pour V1 — on ajoutera un template HTML quand on aura
@ -144,9 +173,25 @@ Ces liens expirent dans 24h.
Merci,
L'équipe Rubis`
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
// FORK DÉMO — capture si demoMode (cf. sendRelanceEmail).
const captured = await captureEmailIfDemo({
organizationId: invoice.organizationId,
kind: 'checkin',
to: { email: user.email, name: user.fullName ?? user.email },
from: { email: fromAddress, name: fromName },
replyTo: null,
subject,
body,
meta: { invoiceId: invoice.id, clientId: client.id, paidUrl, pendingUrl },
})
if (captured) return
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
m.from(fromAddress, fromName)
.to(user.email, user.fullName ?? user.email)
.subject(subject)
.text(body)

View File

@ -1,8 +1,8 @@
import { DateTime } from 'luxon'
import RelanceTask from '#models/relance_task'
import Plan from '#models/plan'
import type Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import * as clock from '#services/clock'
import app from '@adonisjs/core/services/app'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
@ -70,7 +70,7 @@ export async function scheduleRelancesForInvoice(
await t.save()
}
const now = DateTime.now()
const now = await clock.now(invoice.organizationId)
const created: RelanceTask[] = []
const steps = plan.steps.slice().sort((a, b) => a.order - b.order)
const firstOverdueStep = steps.find(

View File

@ -0,0 +1,42 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Mode démo sur les organisations.
*
* - `demo_mode` : flag activé/désactivé. Quand true, les services
* time-sensitive (relances, check-ins, KPIs dashboard) lisent
* `virtual_now` au lieu de `Date.now()`. Les emails sont capturés
* dans la table demo_captured_emails au lieu d'être envoyés via
* Resend.
*
* - `virtual_now` : horloge virtuelle. Permet d'avancer le temps
* pendant une démo (1 jour démo 800 ms en réel par défaut).
* Null = on ne touche à rien (= comportement prod normal).
*
* - `demo_speed_factor` : multiplicateur d'accélération côté UI.
* Stocké côté serveur pour la persistance entre sessions, mais
* le tick reste piloté par le client.
*
* Tous les champs sont à `null`/`false` par défaut zéro impact sur
* les organisations existantes (cf. priorité produit : la prod est
* intacte tant que demo_mode reste off).
*/
export default class extends BaseSchema {
protected tableName = 'organizations'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.boolean('demo_mode').notNullable().defaultTo(false)
table.timestamp('virtual_now', { useTz: true }).nullable()
table.smallint('demo_speed_factor').notNullable().defaultTo(1)
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('demo_mode')
table.dropColumn('virtual_now')
table.dropColumn('demo_speed_factor')
})
}
}

View File

@ -0,0 +1,53 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Boîte de réception démo : quand `org.demo_mode = true`, les emails
* de relance et de check-in ne partent PAS via Resend mais sont stockés
* ici. Lus par le SPA via `/demo/inbox` pour montrer "ce que le client
* recevrait" pendant la démo live.
*
* - kind : type d'email (relance ou checkin)
* - to / from : adresses telles qu'on les aurait envoyées
* - subject / body : interpolés (toutes les variables résolues)
* - sent_at : DateTime virtuelle de l'envoi (= virtual_now au moment T)
* - meta : invoiceId / clientId pour permettre des liens dans l'UI
*
* Pas de relation FK : on garde une trace même si la facture est
* supprimée ensuite (l'inbox démo doit être robuste à un reset partiel).
*/
export default class extends BaseSchema {
protected tableName = 'demo_captured_emails'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table
.uuid('organization_id')
.notNullable()
.references('id')
.inTable('organizations')
.onDelete('CASCADE')
table.string('kind', 16).notNullable() // 'relance' | 'checkin'
table.string('to_email', 254).notNullable()
table.string('to_name', 200).nullable()
table.string('from_email', 254).notNullable()
table.string('from_name', 200).nullable()
table.string('reply_to', 254).nullable()
table.string('subject', 200).notNullable()
table.text('body').notNullable()
table.jsonb('meta').notNullable().defaultTo('{}')
table.timestamp('sent_at', { useTz: true }).notNullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
// Lectures principales : la dernière inbox d'une org, en ordre desc.
table.index(['organization_id', 'sent_at'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -109,6 +109,39 @@ export class ClientSchema extends BaseModel {
declare updatedAt: DateTime | null
}
export class DemoCapturedEmailSchema extends BaseModel {
static $columns = ['body', 'createdAt', 'fromEmail', 'fromName', 'id', 'kind', 'meta', 'organizationId', 'replyTo', 'sentAt', 'subject', 'toEmail', 'toName', 'updatedAt'] as const
$columns = DemoCapturedEmailSchema.$columns
@column()
declare body: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare fromEmail: string
@column()
declare fromName: string | null
@column({ isPrimary: true })
declare id: string
@column()
declare kind: string
@column()
declare meta: any
@column()
declare organizationId: string
@column()
declare replyTo: string | null
@column.dateTime()
declare sentAt: DateTime
@column()
declare subject: string
@column()
declare toEmail: string
@column()
declare toName: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class ImportBatchSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'organizationId', 'updatedAt'] as const
$columns = ImportBatchSchema.$columns
@ -185,10 +218,14 @@ export class InvoiceSchema extends BaseModel {
}
export class OrganizationSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt'] as const
static $columns = ['createdAt', 'demoMode', 'demoSpeedFactor', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt', 'virtualNow'] as const
$columns = OrganizationSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare demoMode: boolean
@column()
declare demoSpeedFactor: number
@column({ isPrimary: true })
declare id: string
@column()
@ -203,6 +240,8 @@ export class OrganizationSchema extends BaseModel {
declare siret: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
@column.dateTime()
declare virtualNow: DateTime | null
}
export class PlanStepSchema extends BaseModel {

View File

@ -141,6 +141,22 @@ router
.as('plans')
.use(middleware.auth())
/**
* Demo auth requise. Mode démo opt-in par org (cf. CLAUDE.md
* Architecture). Routes opérantes seulement si `org.demo_mode = true`.
*/
router
.group(() => {
router.post('start', [controllers.Demo, 'start']).as('start')
router.post('end', [controllers.Demo, 'end']).as('end')
router.post('tick', [controllers.Demo, 'tick']).as('tick')
router.get('state', [controllers.Demo, 'state']).as('state')
router.get('inbox', [controllers.Demo, 'inbox']).as('inbox')
})
.prefix('demo')
.as('demo')
.use(middleware.auth())
/**
* Dashboard auth requise. Calculs agrégés on-the-fly (pas de cache V1).
*/

View File

@ -0,0 +1,224 @@
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 é 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`;
}

View File

@ -0,0 +1,154 @@
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>
</>
);
}

View File

@ -0,0 +1,102 @@
import { useState } from "react";
import { Sparkles, Power } from "lucide-react";
import { toast } from "sonner";
import { useDemoEnd, useDemoStart, useDemoState } from "@/lib/demo";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
/**
* Bouton "Mode démo" pour /parametres.
*
* Active le flag `demo_mode` sur l'org du user, qui :
* - Diverte les emails vers une boîte capturée (pas de Resend)
* - Active l'horloge virtuelle (top-right)
* - Permet d'avancer le temps en accéléré
*
* À désactiver après une démo pour repasser en prod normale.
*/
export function DemoToggle() {
const { data: state } = useDemoState();
const startMutation = useDemoStart();
const endMutation = useDemoEnd();
const [confirmEnd, setConfirmEnd] = useState(false);
const isDemo = state?.demoMode === true;
if (isDemo) {
return (
<Card padding="md" className="border-rubis bg-rubis-glow/30">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Mode démo actif
</p>
<p className="mt-1 font-display text-[16px] font-bold text-ink">
L'horloge virtuelle tourne
</p>
<p className="mt-1.5 text-[12.5px] text-ink-2 leading-snug max-w-md">
Les emails sont capturés dans la boîte démo (pas envoyés à de
vrais clients). Vos KPIs et le DSO suivent l'horloge virtuelle.
Quittez le mode démo pour revenir au temps réel.
</p>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
{confirmEnd ? (
<>
<p className="text-[12.5px] text-ink-2">
Sûr ? L'inbox démo sera conservée mais l'horloge revient au
temps réel.
</p>
<Button
variant="danger"
size="sm"
loading={endMutation.isPending}
onClick={() => {
endMutation.mutate(undefined, {
onSuccess: () => {
toast.success("Mode démo désactivé.");
setConfirmEnd(false);
},
});
}}
>
<Power size={13} /> Quitter le mode démo
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirmEnd(false)}>
Annuler
</Button>
</>
) : (
<Button variant="secondary" size="sm" onClick={() => setConfirmEnd(true)}>
<Power size={13} /> Quitter le mode démo
</Button>
)}
</div>
</Card>
);
}
return (
<Card padding="md">
<p className="text-[12.5px] text-ink-2 leading-snug max-w-md mb-3">
Active une horloge virtuelle qui permet d'avancer dans le temps en
accéléré. Idéal pour montrer Rubis en condition réelle pendant une
démo : les emails apparaissent en direct, ne partent pas chez de vrais
clients.
</p>
<Button
size="sm"
loading={startMutation.isPending}
onClick={() => {
startMutation.mutate(undefined, {
onSuccess: () => toast.success("Mode démo activé. Horloge en pause."),
});
}}
>
<Sparkles size={13} /> Démarrer une démo
</Button>
</Card>
);
}

View File

@ -7,6 +7,7 @@ import { Button } from "@/components/ui/Button";
import { AppSidebar } from "./AppSidebar";
import { AppTopbar } from "./AppTopbar";
import { MobileTabBar } from "./MobileTabBar";
import { DemoClock } from "@/components/demo/DemoClock";
import { Link } from "@tanstack/react-router";
import {
ManualInvoiceProvider,
@ -95,6 +96,9 @@ function AppLayoutInner({ children, title, subtitle, actions }: AppLayoutProps)
</div>
<MobileTabBar />
{/* Horloge démo — auto-cachée si org.demoMode = false */}
<DemoClock />
</div>
);
}

243
apps/web/src/lib/demo.ts Normal file
View File

@ -0,0 +1,243 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "./api";
import { queryKeys } from "./queryKeys";
/**
* Mode démo côté SPA horloge virtuelle + boucle de tick.
*
* L'idée : avancer virtualNow LOCALEMENT (smooth via rAF) puis sync
* périodiquement avec le backend qui fire les tasks dues. Quand un
* event est déclenché, on auto-pause et on pousse l'email dans une
* file pour la slide-over.
*
* Tout le code démo est isolé ici et dans /components/demo. La prod
* ne charge ce hook que pour les orgs avec `demoMode = true`.
*/
export type FiredEvent = {
kind: "relance" | "checkin";
taskId: string;
invoiceId: string;
invoiceNumero: string;
capturedEmailId: string | null;
firedAt: string;
};
export type DemoState = {
demoMode: boolean;
virtualNow?: string; // ISO
speedFactor?: number;
nextEventAt?: string | null;
inboxCount?: number;
};
export type DemoCapturedEmail = {
id: string;
kind: "relance" | "checkin";
from: { email: string; name: string | null };
to: { email: string; name: string | null };
replyTo: string | null;
subject: string;
body: string;
meta: Record<string, unknown>;
sentAt: string;
};
/**
* Vitesse : nombre de jours-démo par seconde réelle.
* 1 = 1 jour-démo / seconde (= 30 jours en 30s, lecture posée pour démo)
* 2 = 2 jours/s, etc.
*/
export const SPEED_OPTIONS = [1, 2, 5] as const;
export type Speed = (typeof SPEED_OPTIONS)[number];
/**
* Interval entre 2 syncs backend pendant la lecture, en ms.
* Trade-off : plus court = plus réactif aux events, plus de charge.
*/
const SYNC_INTERVAL_MS = 250;
export const queryKeysDemo = {
state: () => ["demo", "state"] as const,
inbox: () => ["demo", "inbox"] as const,
};
/**
* Hook principal query l'état démo. Si demoMode=false, retourne juste
* le flag. Le composant DemoClock se monte conditionnellement.
*/
export function useDemoState() {
return useQuery({
queryKey: queryKeysDemo.state(),
queryFn: () => api.get<DemoState>("/api/v1/demo/state"),
staleTime: 0,
refetchOnWindowFocus: false,
});
}
export function useDemoInbox(enabled: boolean) {
return useQuery({
queryKey: queryKeysDemo.inbox(),
queryFn: () => api.get<DemoCapturedEmail[]>("/api/v1/demo/inbox"),
enabled,
staleTime: 0,
refetchOnWindowFocus: false,
});
}
export function useDemoStart() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<DemoState>("/api/v1/demo/start"),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: queryKeysDemo.state() });
void qc.invalidateQueries({ queryKey: queryKeysDemo.inbox() });
void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() });
},
});
}
export function useDemoEnd() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<{ demoMode: false }>("/api/v1/demo/end"),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: queryKeysDemo.state() });
void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() });
},
});
}
/**
* Boucle de tick avance virtualNow localement (rAF) et sync le
* backend périodiquement. Quand fired events > 0 : auto-pause +
* push event dans la file.
*
* Retourne :
* - virtualNow : Date locale, mise à jour à chaque frame
* - playing : true si la pastille avance
* - speed : 1 | 2 | 5
* - play / pause / setSpeed : controls
* - pendingEvents : queue des events fired (à dépiler par l'UI quand
* l'utilisateur les acquitte)
* - acknowledge : retire le 1er event de la file (et quand vide, l'UI
* sait qu'on peut reprendre)
*
* @param initialVirtualNow ISO string de la backend lors du mount
* @param enabled false = ne pas démarrer la boucle (pour orgs non-démo)
*/
export function useDemoTick(params: {
enabled: boolean;
initialVirtualNow?: string;
}) {
const { enabled, initialVirtualNow } = params;
const qc = useQueryClient();
const [virtualNow, setVirtualNow] = useState<Date>(() =>
initialVirtualNow ? new Date(initialVirtualNow) : new Date(),
);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<Speed>(1);
const [pendingEvents, setPendingEvents] = useState<FiredEvent[]>([]);
// Ref pour le scheduler — on évite de recréer la closure à chaque render.
const lastTickRef = useRef<number>(performance.now());
const lastSyncRef = useRef<number>(0);
const virtualNowRef = useRef<Date>(virtualNow);
virtualNowRef.current = virtualNow;
const playingRef = useRef(playing);
playingRef.current = playing;
const speedRef = useRef(speed);
speedRef.current = speed;
// Sync avec le backend.
const syncWithBackend = async () => {
const target = virtualNowRef.current.toISOString();
try {
const { firedEvents } = await api.post<{
virtualNow: string;
firedEvents: FiredEvent[];
}>("/api/v1/demo/tick", { virtualNow: target });
if (firedEvents.length > 0) {
// Auto-pause + push events
playingRef.current = false;
setPlaying(false);
setPendingEvents((prev) => [...prev, ...firedEvents]);
// Refresh inbox + dashboard pour que l'UI reflète l'état réel
void qc.invalidateQueries({ queryKey: queryKeysDemo.inbox() });
void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() });
}
} catch {
// Erreur réseau / backend down → on pause silencieusement, l'UI
// reste cohérente, l'utilisateur peut retenter.
playingRef.current = false;
setPlaying(false);
}
};
// Boucle rAF
useEffect(() => {
if (!enabled) return;
let raf = 0;
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
if (!playingRef.current) {
lastTickRef.current = now;
return;
}
const elapsedRealMs = now - lastTickRef.current;
lastTickRef.current = now;
// speed = jours-démo par seconde réelle.
// → 1 ms réelle = (speed / 1000) jour-démo = (speed * 86_400_000 / 1000) ms virtuelles.
const advanceMs = (speedRef.current * elapsedRealMs * 86_400_000) / 1000;
const next = new Date(virtualNowRef.current.getTime() + advanceMs);
virtualNowRef.current = next;
setVirtualNow(next);
// Sync avec backend toutes les SYNC_INTERVAL_MS
if (now - lastSyncRef.current >= SYNC_INTERVAL_MS) {
lastSyncRef.current = now;
void syncWithBackend();
}
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]);
const acknowledge = () => {
setPendingEvents((prev) => prev.slice(1));
};
// L'UI considère qu'on peut "play" si on n'a aucun event en attente.
const canPlay = pendingEvents.length === 0;
return useMemo(
() => ({
virtualNow,
playing,
speed,
pendingEvents,
canPlay,
play: () => {
if (!pendingEvents.length) {
lastTickRef.current = performance.now();
setPlaying(true);
}
},
pause: () => setPlaying(false),
setSpeed: (s: Speed) => setSpeed(s),
acknowledge,
// Reset à la valeur backend (utile après /demo/start)
resetTo: (iso: string) => {
const d = new Date(iso);
virtualNowRef.current = d;
setVirtualNow(d);
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[virtualNow, playing, speed, pendingEvents, canPlay],
);
}

View File

@ -21,6 +21,7 @@ export const queryKeys = {
detail: (id: string) => ["clients", "detail", id] as const,
},
dashboard: {
all: () => ["dashboard"] as const,
kpis: () => ["dashboard", "kpis"] as const,
activity: () => ["dashboard", "activity"] as const,
},

View File

@ -5,6 +5,7 @@ import { AccountForm } from "@/components/settings/AccountForm";
import { OrganizationForm } from "@/components/settings/OrganizationForm";
import { SignatureForm } from "@/components/settings/SignatureForm";
import { DangerZone } from "@/components/settings/DangerZone";
import { DemoToggle } from "@/components/demo/DemoToggle";
export const Route = createFileRoute("/_app/parametres")({
component: ParametresPage,
@ -63,6 +64,18 @@ function ParametresPage() {
<SignatureForm />
</SettingsSection>
<SettingsSection
eyebrow="Démonstration"
title={
<>
Faire vivre Rubis en <em className="text-rubis">accéléré</em>
</>
}
description="Mode démo : horloge virtuelle qui avance dans le temps, emails capturés au lieu d'être envoyés à de vrais clients. Idéal pour montrer Rubis à un prospect."
>
<DemoToggle />
</SettingsSection>
<SettingsSection
eyebrow="Zone danger"
title="Déconnexion et compte"