refactor(plans/wizard): refonte cadence en liste verticale lisible (mobile + desktop)

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 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 23:10:32 +02:00
parent 24cbf35902
commit 05ad3fa5cf
2 changed files with 248 additions and 238 deletions

View File

@ -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 type { RelanceTone } from "@rubis/shared";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TONE_LABELS } from "@/lib/plans"; 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 à * Étape minimale pour la liste (subject/body arrivent à l'étape 3 du wizard).
* l'étape 2 du wizard — subject/body arrivent à l'étape 3).
*/ */
export type DraftStepLite = { export type DraftStepLite = {
offsetDays: number; offsetDays: number;
@ -14,158 +16,269 @@ export type DraftStepLite = {
requiresManualValidation?: boolean; requiresManualValidation?: boolean;
}; };
const TONE_NODE_CLASS: Record<RelanceTone, string> = { const TONE_DOT_CLASS: Record<RelanceTone, string> = {
amical: "bg-rubis-glow border-rubis text-rubis-deep", amical: "bg-rubis-glow border-rubis",
courtois: "bg-cream-2 border-line text-ink", courtois: "bg-cream-2 border-line",
ferme: "bg-ink text-cream border-ink", ferme: "bg-ink border-ink",
mise_en_demeure: "bg-rubis-deep text-cream border-rubis-deep", 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 * La card sélectionnée s'étend pour révéler l'éditeur inline (décalage
* - Hover = tooltip avec le label * en jours + tonalité) directement sous l'en-tête. Pas de panneau séparé,
* - Bouton + à la fin (et entre étapes en mode insertion) * pas de saut de focus l'utilisateur édite ce qu'il vient de tapper.
* - Bouton × en hover sur chaque nœud
* *
* On affiche l'offset (J+3, J+10) sous chaque nœud. Un rail rubis-glow * Décision de design : on n'utilise PAS le rotaté comme structure
* relie les nœuds c'est l'identité visuelle rubis appliquée à un * (trop fragile en vertical, collisions des coins avec le texte). Le
* calendrier. * reste un accent de marque, présent mais petit.
*/ */
export function CadenceTimeline({ export function CadenceTimeline({
steps, steps,
selectedIndex = -1,
onSelectStep,
onUpdateStep, onUpdateStep,
onAddStep, onAddStep,
onRemoveStep, onRemoveStep,
selectedIndex = -1,
onSelectStep,
}: { }: {
steps: DraftStepLite[]; steps: DraftStepLite[];
selectedIndex?: number;
onSelectStep?: (idx: number) => void;
onUpdateStep?: (idx: number, patch: Partial<DraftStepLite>) => void; onUpdateStep?: (idx: number, patch: Partial<DraftStepLite>) => void;
onAddStep: () => void; onAddStep: () => void;
onRemoveStep: (idx: number) => void; onRemoveStep: (idx: number) => void;
selectedIndex?: number;
onSelectStep?: (idx: number) => void;
}) { }) {
return ( return (
<div className="rounded-card border border-line bg-cream/40 p-4 sm:p-6"> <div className="rounded-card border border-line bg-cream/40 p-4 sm:p-5">
<div className="flex items-center justify-between mb-4 sm:mb-5"> <div className="flex items-center justify-between mb-3">
<p className="text-[12px] uppercase tracking-[0.1em] text-ink-3 font-semibold"> <p className="text-[11px] uppercase tracking-[0.1em] text-ink-3 font-semibold">
Cadence Cadence
</p> </p>
<p className="text-[11.5px] text-ink-3 italic"> <p className="text-[11.5px] text-ink-3 italic tabular-nums">
{steps.length} étape{steps.length > 1 ? "s" : ""} · max 8 {steps.length} étape{steps.length > 1 ? "s" : ""} · max 8
</p> </p>
</div> </div>
<div className="relative"> <ol className="flex flex-col gap-1.5">
{/* Rail horizontal en desktop uniquement. En mobile, un trait vertical {steps.map((step, idx) => {
plus court reliera chaque nœud à son label dans la liste verticale. */} const isSelected = idx === selectedIndex;
{steps.length > 0 && ( return (
<div <li key={idx}>
aria-hidden="true" <StepCard
className="hidden lg:block absolute left-6 right-6 top-[26px] h-px bg-rubis-glow" step={step}
/> index={idx}
)} selected={isSelected}
canRemove={steps.length > 1}
<ol className="relative flex flex-col lg:flex-row lg:items-start lg:gap-2"> onSelect={() => onSelectStep?.(idx)}
{steps.map((step, idx) => { onUpdate={(patch) => onUpdateStep?.(idx, patch)}
const isSelected = idx === selectedIndex; onRemove={() => onRemoveStep(idx)}
const isLast = idx === steps.length - 1 && steps.length >= 8; />
return (
<li
key={idx}
className={cn(
"group relative flex items-center gap-3 lg:flex-1 lg:flex-col lg:items-center lg:gap-0",
// Ligne verticale entre nœuds en mobile (sauf le dernier)
!isLast &&
"lg:after:hidden after:absolute after:left-[25px] after:top-[52px] after:bottom-[-12px] after:w-px after:bg-rubis-glow",
idx > 0 && "mt-3 lg:mt-0",
)}
>
<button
type="button"
onClick={() => onSelectStep?.(idx)}
className={cn(
"shrink-0 relative flex size-13 items-center justify-center rotate-45 border-2 transition-all",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
TONE_NODE_CLASS[step.tone],
isSelected && "ring-4 ring-rubis-glow scale-105",
)}
aria-label={`Étape J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays}, ${TONE_LABELS[step.tone]}`}
style={{ width: 52, height: 52 }}
>
<span className="-rotate-45 font-display text-[13px] font-bold tabular-nums">
{step.offsetDays >= 0 ? "+" : ""}
{step.offsetDays}
</span>
</button>
<div className="flex-1 lg:flex-none flex flex-col items-start lg:items-center min-w-0 lg:mt-2">
<p className="font-display text-[13px] font-semibold text-ink truncate max-w-[180px] lg:max-w-[120px]">
{TONE_LABELS[step.tone]}
</p>
<p className="text-[11px] text-ink-3 tabular-nums">
J{step.offsetDays >= 0 ? "+" : ""}
{step.offsetDays}
</p>
</div>
{steps.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveStep(idx);
}}
className={cn(
// Mobile : visible à droite de la ligne, plus accessible
"shrink-0 lg:absolute lg:-top-1 lg:right-auto lg:left-[calc(50%+18px)]",
"flex size-7 lg:size-5 items-center justify-center rounded-full",
"bg-white border border-line text-ink-3",
"lg:opacity-0 lg:group-hover:opacity-100 lg:focus:opacity-100",
"hover:text-rubis-deep hover:border-rubis transition-opacity",
)}
aria-label="Retirer cette étape"
>
<X size={13} className="lg:hidden" />
<X size={11} className="hidden lg:block" />
</button>
)}
</li>
);
})}
{/* + add at the end */}
{steps.length < 8 && (
<li
className={cn(
"flex items-center gap-3 lg:flex-col lg:items-center lg:gap-0",
steps.length > 0 && "mt-3 lg:mt-0",
)}
>
<button
type="button"
onClick={onAddStep}
className={cn(
"shrink-0 flex size-13 items-center justify-center rounded-full",
"border-2 border-dashed border-line bg-white text-ink-3",
"hover:border-rubis hover:text-rubis transition-colors",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
style={{ width: 52, height: 52 }}
aria-label="Ajouter une étape"
>
<Plus size={20} />
</button>
<span className="text-[13px] sm:text-[11px] font-medium text-ink-2 lg:text-ink-3 lg:mt-2">
Ajouter une étape
</span>
</li> </li>
);
})}
</ol>
{steps.length < 8 && (
<button
type="button"
onClick={onAddStep}
className={cn(
"mt-2 w-full flex items-center justify-center gap-2",
"rounded-default border-2 border-dashed border-line bg-transparent",
"px-4 py-3 text-[13px] font-medium text-ink-3",
"hover:border-rubis hover:text-rubis-deep hover:bg-rubis-glow/20 transition-colors",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)} )}
</ol> >
</div> <Plus size={15} aria-hidden="true" />
Ajouter une étape
</button>
)}
</div> </div>
); );
} }
function StepCard({
step,
index,
selected,
canRemove,
onSelect,
onUpdate,
onRemove,
}: {
step: DraftStepLite;
index: number;
selected: boolean;
canRemove: boolean;
onSelect: () => void;
onUpdate: (patch: Partial<DraftStepLite>) => void;
onRemove: () => void;
}) {
return (
<div
className={cn(
"rounded-card border bg-white transition-colors",
selected
? "border-rubis shadow-rubis"
: "border-line hover:border-ink-3",
)}
>
{/* Header — toujours visible, cliquable pour sélectionner */}
<div className="flex items-center gap-3 px-4 py-3">
<button
type="button"
onClick={onSelect}
className="flex-1 flex items-center gap-3 min-w-0 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow rounded-default"
aria-pressed={selected}
>
{/* Numéro d'ordre */}
<span className="shrink-0 flex size-7 items-center justify-center rounded-full bg-cream-2 text-ink-2 font-display text-[12px] font-bold tabular-nums">
{index + 1}
</span>
{/* ◆ accent coloré par tonalité — signature de marque */}
<span
aria-hidden="true"
className={cn(
"shrink-0 size-2.5 rotate-45 border",
TONE_DOT_CLASS[step.tone],
)}
/>
{/* Offset + ton */}
<div className="flex-1 min-w-0 flex items-baseline gap-2">
<span className="font-display text-[15px] font-bold text-ink tabular-nums">
J{step.offsetDays >= 0 ? "+" : ""}
{step.offsetDays}
</span>
<span className="text-ink-3">·</span>
<span className="text-[13.5px] text-ink-2 truncate">
{TONE_LABELS[step.tone]}
</span>
</div>
</button>
{canRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className={cn(
"shrink-0 flex size-9 items-center justify-center rounded-full",
"text-ink-3 hover:text-rubis-deep hover:bg-rubis-glow/40 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
)}
aria-label={`Retirer l'étape ${index + 1}`}
>
<X size={16} />
</button>
)}
</div>
{/* Éditeur inline — apparaît dans la card sélectionnée */}
{selected && (
<div className="border-t border-line bg-cream-2/30 px-4 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field
label="Décalage (jours)"
htmlFor={`step-offset-${index}`}
hint="Négatif = avant échéance. 0 = jour J. 30 = J+30."
>
<OffsetInput
id={`step-offset-${index}`}
value={step.offsetDays}
onCommit={(n) => onUpdate({ offsetDays: n })}
/>
</Field>
<Field label="Tonalité" htmlFor={`step-tone-${index}`}>
<select
id={`step-tone-${index}`}
className={cn(
"h-11 w-full rounded-default border border-line bg-white px-3",
"text-[14px] text-ink",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow focus-visible:border-rubis",
)}
value={step.tone}
onChange={(e) => {
const tone = e.target.value as RelanceTone;
onUpdate({
tone,
requiresManualValidation: tone === "mise_en_demeure",
});
}}
>
{(Object.keys(TONE_LABELS) as RelanceTone[]).map((t) => (
<option key={t} value={t}>
{TONE_LABELS[t]}
</option>
))}
</select>
</Field>
</div>
{step.tone === "mise_en_demeure" && (
<p className="mt-3 inline-flex items-center gap-2 text-[12px] text-rubis-deep">
<AlertTriangle size={12} /> Validation manuelle obligatoire
avant envoi (sécurité juridique).
</p>
)}
</div>
)}
</div>
);
}
/**
* 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>(String(value));
useEffect(() => {
setLocal(String(value));
}, [value]);
return (
<Input
id={id}
type="text"
inputMode="numeric"
pattern="-?[0-9]*"
value={local}
onChange={(e) => {
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);
}
}}
/>
);
}

View File

@ -25,7 +25,7 @@ import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Textarea } from "@/components/ui/Textarea"; import { Textarea } from "@/components/ui/Textarea";
import { Eyebrow } from "@/components/ui/Eyebrow"; 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 { EmailPreview } from "@/components/plans/wizard/EmailPreview";
import { AiGenerateModal } from "@/components/plans/wizard/AiGenerateModal"; import { AiGenerateModal } from "@/components/plans/wizard/AiGenerateModal";
@ -416,7 +416,6 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
}, [draft.globalTone]); }, [draft.globalTone]);
const [selectedIdx, setSelectedIdx] = useState(0); const [selectedIdx, setSelectedIdx] = useState(0);
const selected = draft.steps[selectedIdx];
const updateStep = (idx: number, patch: Partial<DraftStep>) => { const updateStep = (idx: number, patch: Partial<DraftStep>) => {
onChange({ onChange({
@ -442,15 +441,16 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
}; };
return ( return (
<div className="flex flex-col gap-7"> <div className="flex flex-col gap-6">
<div> <div>
<Eyebrow>Étape 2 sur 4</Eyebrow> <Eyebrow>Étape 2 sur 4</Eyebrow>
<h2 className="font-display text-[22px] font-bold tracking-[-0.02em] text-ink mt-2"> <h2 className="font-display text-[22px] font-bold tracking-[-0.02em] text-ink mt-2">
Réglez la cadence Réglez la cadence
</h2> </h2>
<p className="mt-1.5 text-[14px] text-ink-3 leading-relaxed max-w-xl"> <p className="mt-1.5 text-[14px] text-ink-3 leading-relaxed max-w-xl">
Chaque est un email programmé. Le chiffre indique le nombre de jours{" "} Chaque étape est un email programmé. Le chiffre indique le nombre de
<strong>après l'échéance</strong> (négatif pour rappel avant échéance). jours <strong>après l'échéance</strong> (négatif pour rappel avant
échéance). Touchez une étape pour modifier son timing ou sa tonalité.
</p> </p>
</div> </div>
@ -458,60 +458,10 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
steps={draft.steps} steps={draft.steps}
selectedIndex={selectedIdx} selectedIndex={selectedIdx}
onSelectStep={setSelectedIdx} onSelectStep={setSelectedIdx}
onUpdateStep={updateStep}
onAddStep={addStep} onAddStep={addStep}
onRemoveStep={removeStep} onRemoveStep={removeStep}
/> />
{selected && (
<div className="rounded-card border border-line bg-white p-5">
<p className="text-[12px] uppercase tracking-[0.1em] text-ink-3 font-semibold mb-3">
Étape {selectedIdx + 1}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field
label="Décalage (jours)"
htmlFor="step-offset"
hint="Négatif = avant échéance. 0 = jour J. 30 = J+30."
>
<OffsetInput
id="step-offset"
value={selected.offsetDays}
onCommit={(n) => updateStep(selectedIdx, { offsetDays: n })}
/>
</Field>
<Field label="Tonalité" htmlFor="step-tone">
<select
id="step-tone"
className={cn(
"h-11 w-full rounded-default border border-line bg-white px-3",
"text-[14px] text-ink",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow focus-visible:border-rubis",
)}
value={selected.tone}
onChange={(e) => {
const tone = e.target.value as RelanceTone;
updateStep(selectedIdx, {
tone,
requiresManualValidation: tone === "mise_en_demeure",
});
}}
>
{(Object.keys(TONE_LABELS) as RelanceTone[]).map((t) => (
<option key={t} value={t}>
{TONE_LABELS[t]}
</option>
))}
</select>
</Field>
</div>
{selected.tone === "mise_en_demeure" && (
<p className="mt-3 inline-flex items-center gap-2 text-[12px] text-rubis-deep">
<AlertTriangle size={12} /> Cette étape exigera une validation
manuelle avant envoi (sécurité juridique).
</p>
)}
</div>
)}
</div> </div>
); );
} }
@ -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>(String(value));
// Sync depuis l'extérieur quand on change d'étape sélectionnée.
useEffect(() => {
setLocal(String(value));
}, [value]);
return (
<Input
id={id}
type="text"
inputMode="numeric"
pattern="-?[0-9]*"
value={local}
onChange={(e) => {
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 * Détecte les variables sensibles utilisées dans les templates et compte
* combien de clients existants n'ont pas le champ correspondant rempli. * combien de clients existants n'ont pas le champ correspondant rempli.