From 05ad3fa5cffb1d8a6d28923e82bec6aab8cf8854 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 23:10:32 +0200 Subject: [PATCH] refactor(plans/wizard): refonte cadence en liste verticale lisible (mobile + desktop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le précédent layout avec ◆ rotatés en timeline causait des collisions visuelles sur mobile (les coins du diamant débordaient sur les labels et la ligne de connexion). Inutilisable. Nouvelle approche, inspirée des éditeurs de séquences éprouvés (Mailchimp, Klaviyo) : liste verticale de cards de step, identique sur mobile et desktop. Plus prévisible, plus lisible, mêmes tap targets. - Chaque step = card cliquable avec : numéro d'ordre, ◆ accent (petit, coloré par tonalité, signature de marque sans gêner la lecture), J+X, label de tonalité, bouton retirer aligné dans le flux - La card sélectionnée (rubis border + shadow) révèle l'éditeur inline (Décalage + Tonalité) directement sous l'en-tête → pas de panneau séparé, pas de saut de focus, l'utilisateur édite ce qu'il vient de taper - Bouton "Ajouter une étape" en pleine largeur en pied de liste - L'avertissement mise-en-demeure (validation manuelle) s'affiche dans la card sélectionnée - OffsetInput déplacé dans CadenceTimeline avec le reste de l'éditeur ; duplication supprimée du fichier route Co-Authored-By: Claude Opus 4.7 --- .../plans/wizard/CadenceTimeline.tsx | 371 ++++++++++++------ apps/web/src/routes/_app/plans_.nouveau.tsx | 115 +----- 2 files changed, 248 insertions(+), 238 deletions(-) diff --git a/apps/web/src/components/plans/wizard/CadenceTimeline.tsx b/apps/web/src/components/plans/wizard/CadenceTimeline.tsx index 8cfe3e3..8843ee5 100644 --- a/apps/web/src/components/plans/wizard/CadenceTimeline.tsx +++ b/apps/web/src/components/plans/wizard/CadenceTimeline.tsx @@ -1,12 +1,14 @@ -import { Plus, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Plus, X, AlertTriangle } from "lucide-react"; import type { RelanceTone } from "@rubis/shared"; import { cn } from "@/lib/utils"; import { TONE_LABELS } from "@/lib/plans"; +import { Field } from "@/components/ui/Field"; +import { Input } from "@/components/ui/Input"; /** - * Étape minimale pour la timeline (tous les champs ne sont pas connus à - * l'étape 2 du wizard — subject/body arrivent à l'étape 3). + * Étape minimale pour la liste (subject/body arrivent à l'étape 3 du wizard). */ export type DraftStepLite = { offsetDays: number; @@ -14,158 +16,269 @@ export type DraftStepLite = { requiresManualValidation?: boolean; }; -const TONE_NODE_CLASS: Record = { - amical: "bg-rubis-glow border-rubis text-rubis-deep", - courtois: "bg-cream-2 border-line text-ink", - ferme: "bg-ink text-cream border-ink", - mise_en_demeure: "bg-rubis-deep text-cream border-rubis-deep", +const TONE_DOT_CLASS: Record = { + amical: "bg-rubis-glow border-rubis", + courtois: "bg-cream-2 border-line", + ferme: "bg-ink border-ink", + mise_en_demeure: "bg-rubis-deep border-rubis-deep", }; /** - * Timeline horizontale (ou verticale en mobile) de la cadence d'un plan. + * Liste verticale des étapes d'un plan. Chaque étape = une card cliquable + * avec un petit ◆ accent (coloré par tonalité), l'offset (J+X), le label + * de ton, et un bouton retirer aligné dans le flux. * - * - Chaque étape = un nœud diamant ◆ coloré selon le ton - * - Hover = tooltip avec le label - * - Bouton + à la fin (et entre étapes en mode insertion) - * - Bouton × en hover sur chaque nœud + * La card sélectionnée s'étend pour révéler l'éditeur inline (décalage + * en jours + tonalité) directement sous l'en-tête. Pas de panneau séparé, + * pas de saut de focus — l'utilisateur édite ce qu'il vient de tapper. * - * On affiche l'offset (J+3, J+10) sous chaque nœud. Un rail rubis-glow - * relie les nœuds — c'est l'identité visuelle ◆ rubis appliquée à un - * calendrier. + * Décision de design : on n'utilise PAS le ◆ rotaté comme structure + * (trop fragile en vertical, collisions des coins avec le texte). Le ◆ + * reste un accent de marque, présent mais petit. */ export function CadenceTimeline({ steps, + selectedIndex = -1, + onSelectStep, onUpdateStep, onAddStep, onRemoveStep, - selectedIndex = -1, - onSelectStep, }: { steps: DraftStepLite[]; + selectedIndex?: number; + onSelectStep?: (idx: number) => void; onUpdateStep?: (idx: number, patch: Partial) => void; onAddStep: () => void; onRemoveStep: (idx: number) => void; - selectedIndex?: number; - onSelectStep?: (idx: number) => void; }) { return ( -
-
-

+

+
+

Cadence

-

+

{steps.length} étape{steps.length > 1 ? "s" : ""} · max 8

-
- {/* Rail horizontal en desktop uniquement. En mobile, un trait vertical - plus court reliera chaque nœud à son label dans la liste verticale. */} - {steps.length > 0 && ( - + > +
); } + +function StepCard({ + step, + index, + selected, + canRemove, + onSelect, + onUpdate, + onRemove, +}: { + step: DraftStepLite; + index: number; + selected: boolean; + canRemove: boolean; + onSelect: () => void; + onUpdate: (patch: Partial) => void; + onRemove: () => void; +}) { + return ( +
+ {/* Header — toujours visible, cliquable pour sélectionner */} +
+ + + {canRemove && ( + + )} +
+ + {/* Éditeur inline — apparaît dans la card sélectionnée */} + {selected && ( +
+
+ + onUpdate({ offsetDays: n })} + /> + + + + +
+ {step.tone === "mise_en_demeure" && ( +

+ Validation manuelle obligatoire + avant envoi (sécurité juridique). +

+ )} +
+ )} +
+ ); +} + +/** + * Input numérique signé piloté en string local. Évite le 0 fantôme quand on + * efface pour ressaisir, et accepte les états intermédiaires "" et "-" le + * temps que l'utilisateur termine de taper. Clamp [-30, 180] sur commit ; + * fallback à 0 au blur si vide. + */ +function OffsetInput({ + id, + value, + onCommit, +}: { + id: string; + value: number; + onCommit: (n: number) => void; +}) { + const [local, setLocal] = useState(String(value)); + + useEffect(() => { + setLocal(String(value)); + }, [value]); + + return ( + { + const next = e.target.value; + if (next === "" || next === "-" || /^-?\d{0,3}$/.test(next)) { + setLocal(next); + if (next !== "" && next !== "-") { + const parsed = parseInt(next, 10); + if (!Number.isNaN(parsed)) { + onCommit(Math.max(-30, Math.min(180, parsed))); + } + } + } + }} + onBlur={() => { + if (local === "" || local === "-") { + setLocal("0"); + onCommit(0); + } + }} + /> + ); +} diff --git a/apps/web/src/routes/_app/plans_.nouveau.tsx b/apps/web/src/routes/_app/plans_.nouveau.tsx index 37519c2..37d1552 100644 --- a/apps/web/src/routes/_app/plans_.nouveau.tsx +++ b/apps/web/src/routes/_app/plans_.nouveau.tsx @@ -25,7 +25,7 @@ import { Field } from "@/components/ui/Field"; import { Input } from "@/components/ui/Input"; import { Textarea } from "@/components/ui/Textarea"; import { Eyebrow } from "@/components/ui/Eyebrow"; -import { CadenceTimeline, type DraftStepLite } from "@/components/plans/wizard/CadenceTimeline"; +import { CadenceTimeline } from "@/components/plans/wizard/CadenceTimeline"; import { EmailPreview } from "@/components/plans/wizard/EmailPreview"; import { AiGenerateModal } from "@/components/plans/wizard/AiGenerateModal"; @@ -416,7 +416,6 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) = }, [draft.globalTone]); const [selectedIdx, setSelectedIdx] = useState(0); - const selected = draft.steps[selectedIdx]; const updateStep = (idx: number, patch: Partial) => { onChange({ @@ -442,15 +441,16 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) = }; return ( -
+
Étape 2 sur 4

Réglez la cadence

- Chaque ◆ est un email programmé. Le chiffre indique le nombre de jours{" "} - après l'échéance (négatif pour rappel avant échéance). + Chaque étape est un email programmé. Le chiffre indique le nombre de + jours après l'échéance (négatif pour rappel avant + échéance). Touchez une étape pour modifier son timing ou sa tonalité.

@@ -458,60 +458,10 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) = steps={draft.steps} selectedIndex={selectedIdx} onSelectStep={setSelectedIdx} + onUpdateStep={updateStep} onAddStep={addStep} onRemoveStep={removeStep} /> - - {selected && ( -
-

- Étape {selectedIdx + 1} -

-
- - updateStep(selectedIdx, { offsetDays: n })} - /> - - - - -
- {selected.tone === "mise_en_demeure" && ( -

- Cette étape exigera une validation - manuelle avant envoi (sécurité juridique). -

- )} -
- )}
); } @@ -858,59 +808,6 @@ function stepCanProceed(step: WizardStep, draft: Draft): boolean { } } -/** - * Input numérique signé piloté en string local. Évite le 0 fantôme quand on - * efface pour ressaisir, et accepte les états intermédiaires "" et "-" le - * temps que l'utilisateur termine de taper. On clamp [-30, 180] sur commit - * et on retombe à 0 au blur si l'utilisateur sort en laissant vide. - */ -function OffsetInput({ - id, - value, - onCommit, -}: { - id: string; - value: number; - onCommit: (n: number) => void; -}) { - const [local, setLocal] = useState(String(value)); - - // Sync depuis l'extérieur quand on change d'étape sélectionnée. - useEffect(() => { - setLocal(String(value)); - }, [value]); - - return ( - { - const next = e.target.value; - // Autorise vide, juste "-", ou entier signé. - if (next === "" || next === "-" || /^-?\d{0,3}$/.test(next)) { - setLocal(next); - if (next !== "" && next !== "-") { - const parsed = parseInt(next, 10); - if (!Number.isNaN(parsed)) { - const clamped = Math.max(-30, Math.min(180, parsed)); - onCommit(clamped); - } - } - } - }} - onBlur={() => { - if (local === "" || local === "-") { - setLocal("0"); - onCommit(0); - } - }} - /> - ); -} - /** * Détecte les variables sensibles utilisées dans les templates et compte * combien de clients existants n'ont pas le champ correspondant rempli.