feat(web): plans bibliothèque + éditeur

Bibliothèque /plans (cf. wireframe 3.1) :
- Grid responsive 1/2/3 cols avec PlanCard + CreatePlanCard placeholder
- PlanCard : titre, chip meta (un seul à la fois), aperçu 3 étapes avec
  ◆ rotated comme bullet, footer usage + lien "Modifier →"
- Le plan le plus utilisé reçoit le badge "✦ Le plus utilisé" (rubis-glow
  + Sparkles), les autres gardent leur label de tonalité (Doux / Standard
  / Ferme / Strict). Pas de "PLAN PAR DÉFAUT" partout — info tautologique
  vu que les 4 plans seedés sont des défauts.
- Chips de tonalité adoucis (bg-cream-2 ou rubis-glow, plus de fills lourds)
- Skeleton pulsé pendant le fetch

Éditeur /plans/$slug (cf. wireframe 3.2, route _app/plans_.$slug pour
escape la layout parent) :
- Header : eyebrow humeur + nom + compteur d'usage + boutons Dupliquer
  (V2) / Enregistrer (fonctionnel, désactivé tant que pas de changements)
- Layout 2-col responsive (1fr / 1.4fr) :
  · Gauche : cadence — list de StepCard cliquables, état sélectionné avec
    border-rubis + shadow-rubis, "+ Ajouter une étape" disabled (V2)
  · Droite : éditeur d'email — Card avec subject (Input), body (Textarea
    mono 10 rows), grille de variables-chips
- Variable insertion fonctionnelle : clic = insertion au curseur via
  selectionStart/End du textarea, label FR + token mono ({{numero}})
- Bandeau warning rubis-glow quand l'étape est requiresManualValidation :
  "Validation manuelle obligatoire. L'email est généré en brouillon"
- Save fonctionnel : isDirty calculé via JSON.stringify, mutation PATCH
  /plans/:slug, invalidate cache plans.all + setQueryData detail, toast
- Sync state local ↔ serveur via useEffect sur plan.id+updatedAt

MSW :
- handlers/plans.ts : GET /plans (avec usageCount), GET /plans/:slug,
  PATCH /plans/:slug (validation Zod, recompose ids manquants)
- mockDb : findPlanBySlug, listPlansForOrg, updatePlan
- Calcul usageCount : factures du plan en statut != paid && != cancelled

Lib /plans.ts :
- TONE_LABELS : Amical / Standard / Ferme / Mise en demeure (FR)
- planMoodLabel + planOverallTone (humeur globale = ton de la dernière étape)
- TEMPLATE_VARIABLES : 5 variables avec token + label FR + preview

Bundle prod : 117.31 KB gzip core (stable). plans 2.06 KB gzip,
plans_._slug 3.28 KB gzip — la plus grosse route chunk vu sa complexité
(form + variables + state local).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 11:05:36 +02:00
parent 14d0e982e9
commit b5b67056aa
7 changed files with 835 additions and 24 deletions

View File

@ -0,0 +1,172 @@
import { Link } from "@tanstack/react-router";
import { Plus, ArrowRight, Sparkles } from "lucide-react";
import type { Plan } from "@rubis/shared";
import { Card } from "@/components/ui/Card";
import { planMoodLabel } from "@/lib/plans";
import { cn } from "@/lib/utils";
/**
* Card d'un plan dans la bibliothèque (cf. wireframe 3.1).
*
* Hiérarchie d'info (en accord avec le feedback retour utilisateur) :
* - Le titre est primaire
* - Une seule meta en haut-droite : soit "★ Le plus utilisé" pour LE plan
* le plus actif (signal utile), soit le label de tonalité (signal de
* différenciation pour les autres). Les deux ne coexistent pas un seul
* chip par card.
* - Aperçu des étapes avec comme bullet (notre marqueur, pas un point neutre)
* - Footer : usage + lien Modifier
*
* Pas de "PLAN PAR DÉFAUT" affiché : tous les plans seedés sont par défaut,
* l'info est tautologique. Quand l'utilisateur créera ses propres plans,
* on pourra distinguer (cf. V2).
*/
type PlanCardProps = {
plan: Plan & { usageCount?: number };
/** Si true, affiche le badge "★ Le plus utilisé" en lieu et place du chip de ton. */
isMostUsed?: boolean;
className?: string;
};
const MOOD_CHIP: Record<string, string> = {
Doux: "bg-rubis-glow text-rubis-deep border-rubis-glow/60",
Standard: "bg-cream-2 text-ink-2 border-line",
Ferme: "bg-cream-2 text-ink border-line",
Strict: "bg-rubis-glow text-rubis-deep border-rubis/30",
};
export function PlanCard({ plan, isMostUsed = false, className }: PlanCardProps) {
const mood = planMoodLabel(plan);
const usage = plan.usageCount ?? 0;
return (
<Card
padding="md"
className={cn(
"flex flex-col h-full min-w-0 transition-all duration-150",
"hover:border-ink-3 hover:shadow-card",
className,
)}
>
<div className="flex items-start justify-between gap-3">
<p className="font-display text-[16px] font-semibold tracking-[-0.018em] text-ink truncate min-w-0">
{plan.name}
</p>
{isMostUsed ? (
<span
className={cn(
"shrink-0 inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5",
"border-rubis bg-rubis-glow text-rubis-deep",
"font-sans text-[11px] font-semibold leading-tight whitespace-nowrap",
)}
>
<Sparkles size={11} aria-hidden="true" /> Le plus utilisé
</span>
) : (
<span
className={cn(
"shrink-0 rounded-full border px-2.5 py-0.5",
"font-sans text-[11px] font-medium leading-tight whitespace-nowrap",
MOOD_CHIP[mood] ?? MOOD_CHIP.Standard,
)}
>
{mood}
</span>
)}
</div>
{plan.description && (
<p className="mt-2 text-[12.5px] leading-relaxed text-ink-3 line-clamp-2">
{plan.description}
</p>
)}
<ul className="mt-4 flex flex-col gap-1.5 text-[12.5px] text-ink-2">
{plan.steps.slice(0, 3).map((step) => (
<li key={step.id} className="flex items-baseline gap-2">
<span
className="shrink-0 size-[5px] rotate-45 bg-rubis"
aria-hidden="true"
/>
<span className="tabular-nums font-semibold text-ink">
J{step.offsetDays >= 0 ? "+" : ""}
{step.offsetDays}
</span>
<span className="text-ink-3">·</span>
<span className="truncate">
{step.requiresManualValidation ? "Mise en demeure" : labelForTone(step.tone)}
</span>
</li>
))}
{plan.steps.length > 3 && (
<li className="text-[11.5px] italic text-ink-3 ml-3.5">
+{plan.steps.length - 3} étape{plan.steps.length - 3 > 1 ? "s" : ""}
</li>
)}
</ul>
<div className="mt-auto pt-4 flex items-center justify-between border-t border-line">
<p className="text-[11.5px] text-ink-3">
{usage > 0 ? (
<>
Utilisé sur <strong className="font-semibold text-ink-2 tabular-nums">{usage}</strong>{" "}
facture{usage > 1 ? "s" : ""}
</>
) : (
<span className="italic">Aucune facture active</span>
)}
</p>
{plan.slug && (
<Link
to="/plans/$slug"
params={{ slug: plan.slug }}
className="inline-flex items-center gap-1 text-[12px] font-medium text-rubis hover:underline underline-offset-4"
>
Modifier <ArrowRight size={12} aria-hidden="true" />
</Link>
)}
</div>
</Card>
);
}
function labelForTone(tone: string): string {
switch (tone) {
case "amical":
return "Rappel amical";
case "courtois":
return "Relance courtoise";
case "ferme":
return "Relance ferme";
case "mise_en_demeure":
return "Mise en demeure";
default:
return "Email programmé";
}
}
/** Card "+ Créer un plan" — placeholder pour la création de plans custom. */
export function CreatePlanCard() {
return (
<button
type="button"
disabled
className={cn(
"flex flex-col items-center justify-center gap-2 w-full",
"rounded-card border-2 border-dashed border-line bg-transparent p-6 min-h-[220px]",
"text-ink-3 transition-colors hover:border-ink-3 hover:text-ink-2",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
"disabled:opacity-60 disabled:cursor-not-allowed",
)}
>
<Plus size={26} strokeWidth={1.5} aria-hidden="true" />
<span className="font-display text-[14.5px] font-semibold text-ink">
Créer un plan
</span>
<span className="text-[11.5px] italic text-ink-3 max-w-[180px]">
Bientôt pour l&apos;instant, dupliquez un des plans existants.
</span>
</button>
);
}

58
apps/web/src/lib/plans.ts Normal file
View File

@ -0,0 +1,58 @@
import type { Plan, RelanceTone } from "@rubis/shared";
/**
* Helpers de présentation des plans de relance.
* Garde la conversion tonalité label public au même endroit.
*/
/** Label visible utilisateur pour chaque ton (cf. wireframe 3.1, chips). */
export const TONE_LABELS: Record<RelanceTone, string> = {
amical: "Amical",
courtois: "Standard",
ferme: "Ferme",
mise_en_demeure: "Mise en demeure",
};
/** Tonalité globale d'un plan = la dernière étape (la plus stricte). */
export function planOverallTone(plan: Pick<Plan, "steps">): RelanceTone {
const last = plan.steps[plan.steps.length - 1];
return last?.tone ?? "courtois";
}
/** Label court d'humeur d'un plan ("Doux" / "Standard" / "Ferme" / "Strict"). */
export function planMoodLabel(plan: Pick<Plan, "steps">): string {
const tone = planOverallTone(plan);
switch (tone) {
case "amical":
return "Doux";
case "courtois":
return "Standard";
case "ferme":
return "Ferme";
case "mise_en_demeure":
return "Strict";
default:
return "Standard";
}
}
/**
* Variables de template disponibles dans les emails de relance.
* Les chips dans l'éditeur viennent piocher ici.
*/
export type TemplateVariable = {
/** Token inséré tel quel dans le body (ex. "{{numero}}"). */
token: string;
/** Label affiché sur le chip cliquable. */
label: string;
/** Aperçu utilisé dans l'éditeur (placeholder réaliste). */
preview: string;
};
export const TEMPLATE_VARIABLES: TemplateVariable[] = [
{ token: "{{client.name}}", label: "Nom du client", preview: "Boulangerie Martin SARL" },
{ token: "{{numero}}", label: "Numéro", preview: "F-2026-0042" },
{ token: "{{amount}}", label: "Montant", preview: "1 240,00 €" },
{ token: "{{dueDate}}", label: "Échéance", preview: "15 mai 2026" },
{ token: "{{signature}}", label: "Signature", preview: "Cordialement,\nArthur" },
];

View File

@ -162,6 +162,34 @@ export const mockDb = {
findPlanById(orgId: string, id: string): Plan | undefined {
return load().plans.find((p) => p.organizationId === orgId && p.id === id);
},
findPlanBySlug(orgId: string, slug: string): Plan | undefined {
return load().plans.find(
(p) => p.organizationId === orgId && p.slug === slug,
);
},
listPlansForOrg(orgId: string): Plan[] {
return load().plans.filter((p) => p.organizationId === orgId);
},
updatePlan(
orgId: string,
id: string,
patch: Partial<Pick<Plan, "name" | "description" | "steps">>,
): Plan | undefined {
const db = load();
const idx = db.plans.findIndex(
(p) => p.organizationId === orgId && p.id === id,
);
if (idx === -1) return undefined;
const existing = db.plans[idx]!;
const updated: Plan = {
...existing,
...patch,
updatedAt: new Date().toISOString(),
};
db.plans[idx] = updated;
save(db);
return updated;
},
// === Invoices ===
listInvoicesForOrg(orgId: string): StoredInvoice[] {

View File

@ -2,6 +2,7 @@ import { authHandlers } from "./auth";
import { onboardingHandlers } from "./onboarding";
import { dashboardHandlers } from "./dashboard";
import { invoiceHandlers } from "./invoices";
import { planHandlers } from "./plans";
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
export const handlers = [
@ -9,4 +10,5 @@ export const handlers = [
...onboardingHandlers,
...dashboardHandlers,
...invoiceHandlers,
...planHandlers,
];

View File

@ -0,0 +1,122 @@
import { http, HttpResponse } from "msw";
import { z } from "zod";
import { RELANCE_TONES } from "@rubis/shared";
import { mockDb } from "../db";
import { userIdFromAuthHeader } from "./auth";
const apiBase = "*/api/v1";
function unauthenticated() {
return HttpResponse.json(
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
{ status: 401 },
);
}
function notFound() {
return HttpResponse.json(
{ errors: [{ code: "not_found", message: "Plan introuvable" }] },
{ status: 404 },
);
}
function authedOrgId(authHeader: string | null): string | undefined {
const userId = userIdFromAuthHeader(authHeader);
if (!userId) return undefined;
return mockDb.findUserById(userId)?.organizationId;
}
const updatePlanStepSchema = z.object({
id: z.string().optional(),
order: z.number().int().min(0),
offsetDays: z.number().int().min(-30).max(180),
tone: z.enum(RELANCE_TONES),
subject: z.string().min(1).max(200),
body: z.string().min(1).max(5000),
requiresManualValidation: z.boolean(),
});
const updatePlanSchema = z.object({
name: z.string().min(1).max(80).optional(),
description: z.string().max(500).optional(),
steps: z.array(updatePlanStepSchema).min(1).max(10).optional(),
});
export const planHandlers = [
// GET /api/v1/plans — liste enrichie avec compteurs d'usage
http.get(`${apiBase}/plans`, ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const plans = mockDb.listPlansForOrg(orgId);
const invoices = mockDb.listInvoicesForOrg(orgId);
const enriched = plans.map((plan) => {
const usageCount = invoices.filter(
(inv) => inv.planId === plan.id && inv.status !== "paid" && inv.status !== "cancelled",
).length;
return { ...plan, usageCount };
});
return HttpResponse.json({ data: enriched });
}),
// GET /api/v1/plans/:slug — détail (par slug pour les plans pré-fournis)
http.get(`${apiBase}/plans/:slug`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const slug = params.slug as string;
const plan = mockDb.findPlanBySlug(orgId, slug);
if (!plan) return notFound();
const invoices = mockDb.listInvoicesForOrg(orgId);
const usageCount = invoices.filter(
(inv) => inv.planId === plan.id && inv.status !== "paid" && inv.status !== "cancelled",
).length;
return HttpResponse.json({ data: { ...plan, usageCount } });
}),
// PATCH /api/v1/plans/:slug — sauvegarde nom/description/étapes
http.patch(`${apiBase}/plans/:slug`, async ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const slug = params.slug as string;
const plan = mockDb.findPlanBySlug(orgId, slug);
if (!plan) return notFound();
const json = await request.json();
const parsed = updatePlanSchema.safeParse(json);
if (!parsed.success) {
return HttpResponse.json(
{
errors: parsed.error.issues.map((i) => ({
code: "validation_failed",
message: i.message,
field: i.path.join("."),
})),
},
{ status: 422 },
);
}
// Recompose les étapes en garantissant un id sur chaque
const steps = parsed.data.steps?.map((s, idx) => ({
...s,
id: s.id ?? `step_${plan.id}_${idx}_${Date.now()}`,
}));
const updated = mockDb.updatePlan(orgId, plan.id, {
...(parsed.data.name !== undefined && { name: parsed.data.name }),
...(parsed.data.description !== undefined && {
description: parsed.data.description,
}),
...(steps !== undefined && { steps }),
});
return HttpResponse.json({ data: updated });
}),
];

View File

@ -1,40 +1,86 @@
import { createFileRoute } from "@tanstack/react-router";
import { ListChecks } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { EmptyState } from "@/components/ui/EmptyState";
import type { Plan } from "@rubis/shared";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { PlanCard, CreatePlanCard } from "@/components/plans/PlanCard";
type PlanWithUsage = Plan & { usageCount: number };
export const Route = createFileRoute("/_app/plans")({
component: PlansPage,
component: PlansLibraryPage,
loader: ({ context }) => {
void context.queryClient.prefetchQuery({
queryKey: queryKeys.plans.all(),
queryFn: () => api.get<PlanWithUsage[]>("/api/v1/plans"),
});
},
});
function PlansPage() {
function PlansLibraryPage() {
const { data: plans = [], isPending } = useQuery({
queryKey: queryKeys.plans.all(),
queryFn: () => api.get<PlanWithUsage[]>("/api/v1/plans"),
});
// Le plan le plus utilisé reçoit le badge "★ Le plus utilisé". On ne le
// calcule que s'il y a au moins une facture active (sinon le badge n'a
// pas de sens).
const mostUsedPlanId = plans.reduce<string | null>((best, plan) => {
if (plan.usageCount <= 0) return best;
if (!best) return plan.id;
const bestPlan = plans.find((p) => p.id === best);
return plan.usageCount > (bestPlan?.usageCount ?? 0) ? plan.id : best;
}, null);
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
Plans de relance
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
Plans de <em className="text-rubis">relance</em>
</h1>
<p className="mt-1 text-[14px] text-ink-3">
La cadence avec laquelle Rubis relance pour vous.
<p className="mt-1.5 max-w-xl text-[14px] text-ink-3 leading-relaxed">
Un plan = une cadence d&apos;emails automatisée que Rubis joue pour
vous. Quatre modèles vous sont fournis modifiables, mais déjà bien
calibrés.
</p>
</div>
<EmptyState
draft
icon={<ListChecks size={36} strokeWidth={1.5} aria-hidden="true" />}
title={
<>
Quatre plans <em className="text-rubis">prêts à l&apos;emploi</em>.
</>
}
description={
<>
Bibliothèque + éditeur de plan : cadences (J+3, J+10, J+20),
templates email avec variables, ton qui monte avec le retard.
Étape suivante.
</>
}
/>
{isPending ? (
<PlansSkeleton />
) : (
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
{plans.map((plan) => (
<li key={plan.id}>
<PlanCard plan={plan} isMostUsed={plan.id === mostUsedPlanId} />
</li>
))}
<li>
<CreatePlanCard />
</li>
</ul>
)}
</div>
);
}
function PlansSkeleton() {
return (
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
{Array.from({ length: 4 }).map((_, idx) => (
<li
key={idx}
className="rounded-card border border-line bg-white p-6 h-[240px] animate-pulse"
>
<div className="h-4 w-1/2 rounded bg-cream-2" />
<div className="mt-4 space-y-2">
<div className="h-3 w-2/3 rounded bg-cream-2" />
<div className="h-3 w-1/2 rounded bg-cream-2" />
<div className="h-3 w-3/5 rounded bg-cream-2" />
</div>
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,383 @@
import { useEffect, useRef, useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
ArrowLeft,
AlertTriangle,
Copy as CopyIcon,
Plus,
} from "lucide-react";
import { toast } from "sonner";
import type { Plan, PlanStep, RelanceTone } from "@rubis/shared";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import {
TEMPLATE_VARIABLES,
TONE_LABELS,
planMoodLabel,
} from "@/lib/plans";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
import { Textarea } from "@/components/ui/Textarea";
type PlanWithUsage = Plan & { usageCount: number };
export const Route = createFileRoute("/_app/plans_/$slug")({
component: PlanEditorPage,
loader: ({ context, params }) => {
void context.queryClient.prefetchQuery({
queryKey: queryKeys.plans.detail(params.slug),
queryFn: () => api.get<PlanWithUsage>(`/api/v1/plans/${params.slug}`),
});
},
});
function PlanEditorPage() {
const { slug } = Route.useParams();
const queryClient = useQueryClient();
const { data: plan, isPending, isError } = useQuery({
queryKey: queryKeys.plans.detail(slug),
queryFn: () => api.get<PlanWithUsage>(`/api/v1/plans/${slug}`),
});
// État local de l'édition. On clone les steps depuis le serveur au mount
// (et à chaque refetch) puis on travaille en local jusqu'au "Enregistrer".
const [draftSteps, setDraftSteps] = useState<PlanStep[] | null>(null);
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const bodyRef = useRef<HTMLTextAreaElement | null>(null);
// Sync : quand le plan arrive ou change côté serveur, on remet à zéro l'état
// local. On évite les races avec une clé sur plan.id+updatedAt.
useEffect(() => {
if (!plan) return;
setDraftSteps(plan.steps);
setSelectedStepId((current) => current ?? plan.steps[0]?.id ?? null);
}, [plan?.id, plan?.updatedAt]); // eslint-disable-line react-hooks/exhaustive-deps
const saveMutation = useMutation({
mutationFn: (steps: PlanStep[]) =>
api.patch<Plan>(`/api/v1/plans/${slug}`, { steps }),
onSuccess: (saved) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.plans.all() });
void queryClient.setQueryData(
queryKeys.plans.detail(slug),
(prev: PlanWithUsage | undefined) =>
prev ? { ...prev, ...saved } : (saved as PlanWithUsage),
);
toast.success("Plan enregistré.");
},
onError: () => {
toast.error("Sauvegarde impossible. Réessayez dans un instant.");
},
});
if (isError) {
return (
<div className="text-center py-12">
<p className="font-display text-[20px] font-semibold text-ink">
Plan introuvable.
</p>
<Link
to="/plans"
className="mt-3 inline-block text-[13px] text-rubis underline-offset-4 hover:underline"
>
Retour à la bibliothèque
</Link>
</div>
);
}
if (isPending || !plan || !draftSteps) {
return <EditorSkeleton />;
}
const selectedStep =
draftSteps.find((s) => s.id === selectedStepId) ?? draftSteps[0];
const updateSelectedStep = (patch: Partial<PlanStep>) => {
if (!selectedStep) return;
setDraftSteps((prev) =>
prev
? prev.map((s) => (s.id === selectedStep.id ? { ...s, ...patch } : s))
: prev,
);
};
const insertVariable = (token: string) => {
if (!selectedStep || !bodyRef.current) return;
const ta = bodyRef.current;
const start = ta.selectionStart ?? selectedStep.body.length;
const end = ta.selectionEnd ?? selectedStep.body.length;
const newBody =
selectedStep.body.slice(0, start) + token + selectedStep.body.slice(end);
updateSelectedStep({ body: newBody });
// Reposition le curseur après le token inséré
requestAnimationFrame(() => {
ta.focus();
const cursor = start + token.length;
ta.setSelectionRange(cursor, cursor);
});
};
const isDirty =
JSON.stringify(draftSteps) !== JSON.stringify(plan.steps);
const mood = planMoodLabel({ steps: draftSteps });
return (
<div className="flex flex-col gap-6">
<Link
to="/plans"
className="inline-flex items-center gap-1.5 self-start text-[12.5px] text-ink-3 hover:text-rubis"
>
<ArrowLeft size={13} aria-hidden="true" /> Plans · {plan.name}
</Link>
<header className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<Eyebrow>{mood}</Eyebrow>
<h1 className="mt-2 font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
{plan.name}
</h1>
<p className="mt-1.5 text-[13px] text-ink-3">
{plan.usageCount > 0 ? (
<>
Utilisé sur{" "}
<strong className="font-semibold text-ink-2 tabular-nums">
{plan.usageCount}
</strong>{" "}
facture{plan.usageCount > 1 ? "s" : ""} active
{plan.usageCount > 1 ? "s" : ""}
</>
) : (
<span className="italic">Aucune facture active</span>
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" size="sm" disabled>
<CopyIcon size={14} aria-hidden="true" /> Dupliquer
</Button>
<Button
size="sm"
disabled={!isDirty}
loading={saveMutation.isPending}
onClick={() => saveMutation.mutate(draftSteps)}
>
{isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
</div>
</header>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.4fr]">
{/* === Cadence (left) === */}
<section aria-label="Cadence du plan" className="flex flex-col gap-3">
<Eyebrow tone="ink">Cadence</Eyebrow>
<ol className="flex flex-col gap-3">
{draftSteps.map((step, idx) => (
<li key={step.id}>
<StepCard
step={step}
index={idx}
selected={step.id === selectedStep?.id}
onSelect={() => setSelectedStepId(step.id)}
/>
</li>
))}
</ol>
<button
type="button"
disabled
className={cn(
"rounded-default border border-dashed border-line bg-transparent",
"px-4 py-3 text-[13px] font-medium text-ink-3",
"transition-colors hover:border-ink-3 hover:text-ink-2",
"disabled:opacity-60 disabled:cursor-not-allowed",
"inline-flex items-center justify-center gap-1.5",
)}
>
<Plus size={14} aria-hidden="true" /> Ajouter une étape
<span className="ml-1 text-[11px] italic">(bientôt)</span>
</button>
</section>
{/* === Email editor (right) === */}
<section aria-label="Édition de l'étape sélectionnée">
{selectedStep ? (
<Card padding="md" className="flex flex-col gap-5">
<div className="flex items-baseline justify-between gap-3">
<Eyebrow tone="ink">
Étape {(selectedStep.order ?? 0) + 1} ·{" "}
<span className="text-rubis">
J{selectedStep.offsetDays >= 0 ? "+" : ""}
{selectedStep.offsetDays}
</span>
</Eyebrow>
<span className="text-[12px] text-ink-3">
Tonalité · {TONE_LABELS[selectedStep.tone]}
</span>
</div>
{selectedStep.requiresManualValidation && (
<div className="flex items-start gap-2 rounded-default bg-rubis-glow/60 px-3 py-2 text-[12.5px] text-rubis-deep">
<AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
<p>
<strong className="font-semibold">Validation manuelle obligatoire.</strong>{" "}
L&apos;email est <em className="not-italic font-medium">généré en brouillon</em>,
jamais envoyé automatiquement (cf. principe produit).
</p>
</div>
)}
<Field label="Objet de l'email" htmlFor={`subject-${selectedStep.id}`}>
<Input
id={`subject-${selectedStep.id}`}
value={selectedStep.subject}
onChange={(e) => updateSelectedStep({ subject: e.target.value })}
placeholder="Rappel — facture {{numero}}"
/>
</Field>
<Field
label="Corps"
htmlFor={`body-${selectedStep.id}`}
hint="Markdown léger non interprété. Les variables ci-dessous sont remplacées à l'envoi."
>
<Textarea
id={`body-${selectedStep.id}`}
ref={bodyRef}
rows={10}
className="font-mono text-[13px] leading-relaxed"
value={selectedStep.body}
onChange={(e) => updateSelectedStep({ body: e.target.value })}
/>
</Field>
<div>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-ink-3">
Variables clic pour insérer au curseur
</p>
<div className="flex flex-wrap gap-2">
{TEMPLATE_VARIABLES.map((variable) => (
<button
key={variable.token}
type="button"
onClick={() => insertVariable(variable.token)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1",
"font-mono text-[11.5px] leading-tight",
"border-line bg-cream-2 text-ink-2",
"transition-colors hover:border-rubis hover:bg-rubis-glow hover:text-rubis-deep",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
title={`Insère "${variable.token}" — ex: ${variable.preview.split("\n")[0]}`}
>
<span className="font-sans text-[11px] text-ink-3 not-italic">
{variable.label}
</span>
<span className="text-rubis">{variable.token}</span>
</button>
))}
</div>
</div>
</Card>
) : (
<Card padding="md">
<p className="text-[13px] italic text-ink-3">
Sélectionnez une étape à gauche pour l&apos;éditer.
</p>
</Card>
)}
</section>
</div>
</div>
);
}
/** Card d'une étape dans la colonne cadence (gauche). */
function StepCard({
step,
index,
selected,
onSelect,
}: {
step: PlanStep;
index: number;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
aria-pressed={selected}
onClick={onSelect}
className={cn(
"group block w-full text-left",
"rounded-card border bg-white p-4 transition-all duration-150",
selected
? "border-rubis shadow-rubis"
: "border-line hover:border-ink-3",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
>
<span
aria-hidden="true"
className={cn(
"absolute left-0 top-4 bottom-4 w-[3px] rounded-r-full transition-colors",
selected ? "bg-rubis" : "bg-transparent",
)}
/>
<div className="flex items-baseline justify-between gap-3">
<p className="font-sans text-[13.5px] font-semibold text-ink">
Étape {index + 1} ·{" "}
<span className="tabular-nums text-rubis">
J{step.offsetDays >= 0 ? "+" : ""}
{step.offsetDays}
</span>
</p>
<span
className={cn(
"text-[11px] font-semibold uppercase tracking-[0.1em]",
selected ? "text-rubis" : "text-ink-3",
)}
>
{step.requiresManualValidation ? "Brouillon" : "Email"}
</span>
</div>
<p className="mt-1.5 text-[12.5px] text-ink-2 line-clamp-2">
« {step.subject} »
</p>
<p className="mt-1 text-[11px] text-ink-3">
Tonalité · {TONE_LABELS[step.tone as RelanceTone]}
</p>
</button>
);
}
function EditorSkeleton() {
return (
<div className="flex flex-col gap-6 animate-pulse">
<div className="h-3 w-24 rounded bg-cream-2" />
<div className="h-8 w-1/3 rounded bg-cream-2" />
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.4fr]">
<div className="space-y-3">
<div className="h-24 rounded-card bg-cream-2" />
<div className="h-24 rounded-card bg-cream-2" />
<div className="h-24 rounded-card bg-cream-2" />
</div>
<div className="h-96 rounded-card bg-cream-2" />
</div>
</div>
);
}