fix(plans/wizard): variables dans le sujet + UX mobile resserrée
Variables - Le clic sur un chip de variable insère désormais au curseur du dernier champ focus (sujet OU corps), pas seulement dans le corps. On capture la position via onSelect/onClick/onKeyUp/onBlur et on utilise mousedown + preventDefault sur les chips pour que le focus ne quitte pas le champ ciblé avant l'insertion. Le label sous les chips indique en live quel champ est ciblé. - OffsetInput (étape Cadence) : composant string-controlled qui accepte les états intermédiaires "" et "-" pour ne plus avoir le 0 fantôme quand on efface pour ressaisir un offset négatif. Mobile - Bottom nav (Annuler/Continuer) sticky en bas sur mobile, en flux normal sur desktop. Safe-area inset respectée. - Header du wizard : back button compact (icône seule sous sm), compteur d'étape toujours visible, stepper centré. - Card padding adaptatif (p-5 sm:p-7 lg:p-9). - Step 3 — sélecteur d'étape : scroll horizontal sur mobile (au lieu de wrap), évite l'effet escalier avec 5 étapes. - Step 3 — body textarea : min-h adaptatif (180px mobile, 260px sm+). - CadenceTimeline : rail horizontal masqué sous lg ; en mobile, ligne verticale fine entre les nœuds (cohérent identité ◆) ; bouton retirer visible en permanence sur mobile (les hover-only ne marchent pas tactile). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9e531e32a9
commit
24cbf35902
@ -49,8 +49,8 @@ export function CadenceTimeline({
|
|||||||
onSelectStep?: (idx: number) => void;
|
onSelectStep?: (idx: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-card border border-line bg-cream/40 p-6">
|
<div className="rounded-card border border-line bg-cream/40 p-4 sm:p-6">
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-4 sm:mb-5">
|
||||||
<p className="text-[12px] uppercase tracking-[0.1em] text-ink-3 font-semibold">
|
<p className="text-[12px] uppercase tracking-[0.1em] text-ink-3 font-semibold">
|
||||||
Cadence
|
Cadence
|
||||||
</p>
|
</p>
|
||||||
@ -60,27 +60,35 @@ export function CadenceTimeline({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Rail */}
|
{/* 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 && (
|
{steps.length > 0 && (
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute left-4 right-4 top-[26px] h-px bg-rubis-glow lg:block"
|
className="hidden lg:block absolute left-6 right-6 top-[26px] h-px bg-rubis-glow"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ol className="relative flex flex-col gap-3 lg:flex-row lg:items-start lg:gap-2">
|
<ol className="relative flex flex-col lg:flex-row lg:items-start lg:gap-2">
|
||||||
{steps.map((step, idx) => {
|
{steps.map((step, idx) => {
|
||||||
const isSelected = idx === selectedIndex;
|
const isSelected = idx === selectedIndex;
|
||||||
|
const isLast = idx === steps.length - 1 && steps.length >= 8;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={idx}
|
key={idx}
|
||||||
className="group relative flex items-center gap-3 lg:flex-1 lg:flex-col lg:items-center"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectStep?.(idx)}
|
onClick={() => onSelectStep?.(idx)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex size-13 items-center justify-center rotate-45 border-2 transition-all",
|
"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",
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||||
TONE_NODE_CLASS[step.tone],
|
TONE_NODE_CLASS[step.tone],
|
||||||
isSelected && "ring-4 ring-rubis-glow scale-105",
|
isSelected && "ring-4 ring-rubis-glow scale-105",
|
||||||
@ -94,8 +102,8 @@ export function CadenceTimeline({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex flex-col items-start lg:items-center min-w-0 lg:mt-2">
|
<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-[120px]">
|
<p className="font-display text-[13px] font-semibold text-ink truncate max-w-[180px] lg:max-w-[120px]">
|
||||||
{TONE_LABELS[step.tone]}
|
{TONE_LABELS[step.tone]}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-ink-3 tabular-nums">
|
<p className="text-[11px] text-ink-3 tabular-nums">
|
||||||
@ -112,15 +120,17 @@ export function CadenceTimeline({
|
|||||||
onRemoveStep(idx);
|
onRemoveStep(idx);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute -top-1 -right-1 lg:right-auto lg:left-[calc(50%+18px)]",
|
// Mobile : visible à droite de la ligne, plus accessible
|
||||||
"flex size-5 items-center justify-center rounded-full",
|
"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",
|
"bg-white border border-line text-ink-3",
|
||||||
"opacity-0 group-hover:opacity-100 focus:opacity-100",
|
"lg:opacity-0 lg:group-hover:opacity-100 lg:focus:opacity-100",
|
||||||
"hover:text-rubis-deep hover:border-rubis transition-opacity",
|
"hover:text-rubis-deep hover:border-rubis transition-opacity",
|
||||||
)}
|
)}
|
||||||
aria-label="Retirer cette étape"
|
aria-label="Retirer cette étape"
|
||||||
>
|
>
|
||||||
<X size={11} />
|
<X size={13} className="lg:hidden" />
|
||||||
|
<X size={11} className="hidden lg:block" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
@ -129,12 +139,17 @@ export function CadenceTimeline({
|
|||||||
|
|
||||||
{/* + add at the end */}
|
{/* + add at the end */}
|
||||||
{steps.length < 8 && (
|
{steps.length < 8 && (
|
||||||
<li className="flex items-center gap-3 lg:flex-col lg:items-center">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAddStep}
|
onClick={onAddStep}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-13 items-center justify-center rounded-full",
|
"shrink-0 flex size-13 items-center justify-center rounded-full",
|
||||||
"border-2 border-dashed border-line bg-white text-ink-3",
|
"border-2 border-dashed border-line bg-white text-ink-3",
|
||||||
"hover:border-rubis hover:text-rubis transition-colors",
|
"hover:border-rubis hover:text-rubis transition-colors",
|
||||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||||
@ -144,7 +159,9 @@ export function CadenceTimeline({
|
|||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Plus size={20} />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-[11px] text-ink-3 lg:mt-2">Ajouter</span>
|
<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>
|
</ol>
|
||||||
|
|||||||
@ -199,20 +199,27 @@ function PlanCreateWizard() {
|
|||||||
const canProceed = stepCanProceed(step, draft);
|
const canProceed = stepCanProceed(step, draft);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-5 pb-[env(safe-area-inset-bottom)]">
|
||||||
<div className="flex items-center justify-between gap-4">
|
{/* Header — sur mobile, on cache le label "Plans" : juste l'icône
|
||||||
<Button asChild variant="ghost" size="sm">
|
+ compteur d'étape. Stepper centré, plus compact. */}
|
||||||
<Link to="/plans">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<ArrowLeft size={14} /> Plans
|
<Button asChild variant="ghost" size="sm" className="px-2">
|
||||||
|
<Link to="/plans" aria-label="Retour aux plans">
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
<span className="hidden sm:inline">Plans</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Stepper steps={[...STEPS]} currentIndex={step - 1} className="flex-1 max-w-md" />
|
<Stepper
|
||||||
<span className="hidden sm:block text-[12px] text-ink-3 tabular-nums">
|
steps={[...STEPS]}
|
||||||
|
currentIndex={step - 1}
|
||||||
|
className="flex-1 max-w-md min-w-0"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-ink-3 tabular-nums shrink-0">
|
||||||
{step}/4
|
{step}/4
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card padding="lg" className="overflow-visible">
|
<Card padding="none" className="p-5 sm:p-7 lg:p-9 overflow-visible">
|
||||||
{step === 1 && <StepIdentity draft={draft} onChange={setDraft} />}
|
{step === 1 && <StepIdentity draft={draft} onChange={setDraft} />}
|
||||||
{step === 2 && <StepCadence draft={draft} onChange={setDraft} />}
|
{step === 2 && <StepCadence draft={draft} onChange={setDraft} />}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
@ -221,7 +228,16 @@ function PlanCreateWizard() {
|
|||||||
{step === 4 && <StepRecap draft={draft} />}
|
{step === 4 && <StepRecap draft={draft} />}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
{/* Bottom nav — sticky en bas sur mobile pour rester accessible quand
|
||||||
|
le formulaire est long, normal en flux sur desktop. */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"sticky bottom-0 z-10 -mx-4 px-4 py-3 sm:mx-0 sm:px-0 sm:py-0 sm:static",
|
||||||
|
"bg-cream/95 backdrop-blur-sm border-t border-line sm:bg-transparent sm:border-0 sm:backdrop-blur-none",
|
||||||
|
"flex items-center justify-between gap-3",
|
||||||
|
)}
|
||||||
|
style={{ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="md"
|
size="md"
|
||||||
@ -457,17 +473,10 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
|
|||||||
htmlFor="step-offset"
|
htmlFor="step-offset"
|
||||||
hint="Négatif = avant échéance. 0 = jour J. 30 = J+30."
|
hint="Négatif = avant échéance. 0 = jour J. 30 = J+30."
|
||||||
>
|
>
|
||||||
<Input
|
<OffsetInput
|
||||||
id="step-offset"
|
id="step-offset"
|
||||||
type="number"
|
|
||||||
min={-30}
|
|
||||||
max={180}
|
|
||||||
value={selected.offsetDays}
|
value={selected.offsetDays}
|
||||||
onChange={(e) =>
|
onCommit={(n) => updateStep(selectedIdx, { offsetDays: n })}
|
||||||
updateStep(selectedIdx, {
|
|
||||||
offsetDays: parseInt(e.target.value, 10) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Tonalité" htmlFor="step-tone">
|
<Field label="Tonalité" htmlFor="step-tone">
|
||||||
@ -521,7 +530,15 @@ function StepMessages({
|
|||||||
}) {
|
}) {
|
||||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||||
const [aiOpen, setAiOpen] = useState(false);
|
const [aiOpen, setAiOpen] = useState(false);
|
||||||
|
const subjectRef = useRef<HTMLInputElement | null>(null);
|
||||||
const bodyRef = useRef<HTMLTextAreaElement | null>(null);
|
const bodyRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
// Quel champ a été focus en dernier — détermine où on insère une variable
|
||||||
|
// quand l'utilisateur clique sur un chip. Capturé sur focus ; on capture
|
||||||
|
// aussi la position du curseur sur blur pour pouvoir insérer même après
|
||||||
|
// que le clic sur le chip ait blur le champ.
|
||||||
|
const [focusedField, setFocusedField] = useState<"subject" | "body">("body");
|
||||||
|
const subjectSelRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 });
|
||||||
|
const bodySelRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 });
|
||||||
const selected = draft.steps[selectedIdx];
|
const selected = draft.steps[selectedIdx];
|
||||||
|
|
||||||
const updateSelected = (patch: Partial<DraftStep>) => {
|
const updateSelected = (patch: Partial<DraftStep>) => {
|
||||||
@ -538,19 +555,48 @@ function StepMessages({
|
|||||||
[draft.steps, clients],
|
[draft.steps, clients],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const captureSubjectSelection = () => {
|
||||||
|
const el = subjectRef.current;
|
||||||
|
if (el) subjectSelRef.current = { start: el.selectionStart ?? el.value.length, end: el.selectionEnd ?? el.value.length };
|
||||||
|
};
|
||||||
|
const captureBodySelection = () => {
|
||||||
|
const el = bodyRef.current;
|
||||||
|
if (el) bodySelRef.current = { start: el.selectionStart, end: el.selectionEnd };
|
||||||
|
};
|
||||||
|
|
||||||
const insertVariable = (token: string) => {
|
const insertVariable = (token: string) => {
|
||||||
if (!bodyRef.current) {
|
if (!selected) return;
|
||||||
updateSelected({ body: (selected?.body ?? "") + token });
|
if (focusedField === "subject") {
|
||||||
|
const el = subjectRef.current;
|
||||||
|
const current = selected.subject;
|
||||||
|
const { start, end } = subjectSelRef.current;
|
||||||
|
const insertAt = Math.min(Math.max(start, 0), current.length);
|
||||||
|
const insertEnd = Math.min(Math.max(end, insertAt), current.length);
|
||||||
|
const next = current.slice(0, insertAt) + token + current.slice(insertEnd);
|
||||||
|
updateSelected({ subject: next.slice(0, 200) });
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
const caret = insertAt + token.length;
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
subjectSelRef.current = { start: caret, end: caret };
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ta = bodyRef.current;
|
// body (défaut)
|
||||||
const start = ta.selectionStart;
|
const el = bodyRef.current;
|
||||||
const end = ta.selectionEnd;
|
const current = selected.body;
|
||||||
const next = (selected?.body ?? "").slice(0, start) + token + (selected?.body ?? "").slice(end);
|
const { start, end } = bodySelRef.current;
|
||||||
updateSelected({ body: next });
|
const insertAt = Math.min(Math.max(start, 0), current.length);
|
||||||
|
const insertEnd = Math.min(Math.max(end, insertAt), current.length);
|
||||||
|
const next = current.slice(0, insertAt) + token + current.slice(insertEnd);
|
||||||
|
updateSelected({ body: next.slice(0, 5000) });
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
ta.focus();
|
if (!el) return;
|
||||||
ta.setSelectionRange(start + token.length, start + token.length);
|
el.focus();
|
||||||
|
const caret = insertAt + token.length;
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
bodySelRef.current = { start: caret, end: caret };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -567,8 +613,9 @@ function StepMessages({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sélecteur d'étape */}
|
{/* Sélecteur d'étape — scroll horizontal sur mobile, wrap au-delà.
|
||||||
<div className="flex flex-wrap gap-2">
|
Le -mx + px compense pour pouvoir scroller jusqu'au bord de la card. */}
|
||||||
|
<div className="-mx-1 px-1 flex gap-2 overflow-x-auto sm:flex-wrap sm:overflow-visible scrollbar-thin">
|
||||||
{draft.steps.map((s, idx) => {
|
{draft.steps.map((s, idx) => {
|
||||||
const filled = s.subject.trim() !== "" && s.body.trim() !== "";
|
const filled = s.subject.trim() !== "" && s.body.trim() !== "";
|
||||||
return (
|
return (
|
||||||
@ -577,7 +624,7 @@ function StepMessages({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedIdx(idx)}
|
onClick={() => setSelectedIdx(idx)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-2 rounded-default border px-3 py-1.5",
|
"shrink-0 inline-flex items-center gap-2 rounded-default border px-3 py-1.5",
|
||||||
"text-[12.5px] font-medium transition-all",
|
"text-[12.5px] font-medium transition-all",
|
||||||
idx === selectedIdx
|
idx === selectedIdx
|
||||||
? "border-rubis bg-rubis-glow text-rubis-deep"
|
? "border-rubis bg-rubis-glow text-rubis-deep"
|
||||||
@ -616,9 +663,18 @@ function StepMessages({
|
|||||||
<Field label="Sujet de l'email" htmlFor="step-subject">
|
<Field label="Sujet de l'email" htmlFor="step-subject">
|
||||||
<Input
|
<Input
|
||||||
id="step-subject"
|
id="step-subject"
|
||||||
|
ref={subjectRef}
|
||||||
placeholder="Ex. Petit rappel — facture {{numero}}"
|
placeholder="Ex. Petit rappel — facture {{numero}}"
|
||||||
value={selected.subject}
|
value={selected.subject}
|
||||||
onChange={(e) => updateSelected({ subject: e.target.value })}
|
onChange={(e) => updateSelected({ subject: e.target.value })}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocusedField("subject");
|
||||||
|
captureSubjectSelection();
|
||||||
|
}}
|
||||||
|
onSelect={captureSubjectSelection}
|
||||||
|
onBlur={captureSubjectSelection}
|
||||||
|
onKeyUp={captureSubjectSelection}
|
||||||
|
onClick={captureSubjectSelection}
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@ -627,9 +683,18 @@ function StepMessages({
|
|||||||
<Textarea
|
<Textarea
|
||||||
id="step-body"
|
id="step-body"
|
||||||
ref={bodyRef}
|
ref={bodyRef}
|
||||||
rows={12}
|
rows={8}
|
||||||
|
className="min-h-[180px] sm:min-h-[260px]"
|
||||||
value={selected.body}
|
value={selected.body}
|
||||||
onChange={(e) => updateSelected({ body: e.target.value })}
|
onChange={(e) => updateSelected({ body: e.target.value })}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocusedField("body");
|
||||||
|
captureBodySelection();
|
||||||
|
}}
|
||||||
|
onSelect={captureBodySelection}
|
||||||
|
onBlur={captureBodySelection}
|
||||||
|
onKeyUp={captureBodySelection}
|
||||||
|
onClick={captureBodySelection}
|
||||||
placeholder="Bonjour {{client.contactFirstName}}, ..."
|
placeholder="Bonjour {{client.contactFirstName}}, ..."
|
||||||
maxLength={5000}
|
maxLength={5000}
|
||||||
/>
|
/>
|
||||||
@ -637,14 +702,25 @@ function StepMessages({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11.5px] text-ink-3 mb-2">
|
<p className="text-[11.5px] text-ink-3 mb-2">
|
||||||
Cliquez pour insérer une variable au curseur :
|
Cliquez pour insérer une variable dans{" "}
|
||||||
|
<strong className="text-ink-2">
|
||||||
|
{focusedField === "subject" ? "le sujet" : "le corps"}
|
||||||
|
</strong>{" "}
|
||||||
|
au curseur :
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{TEMPLATE_VARIABLES.map((v) => (
|
{TEMPLATE_VARIABLES.map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v.token}
|
key={v.token}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertVariable(v.token)}
|
// onMouseDown au lieu de onClick : le mousedown se déclenche
|
||||||
|
// AVANT le blur de l'input, donc subjectSelRef/bodySelRef
|
||||||
|
// contiennent encore la position correcte. preventDefault
|
||||||
|
// empêche le focus de quitter le champ visé.
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertVariable(v.token);
|
||||||
|
}}
|
||||||
className="inline-flex items-center rounded-full border border-line bg-cream-2/50 px-2.5 py-1 text-[11.5px] text-ink-2 hover:border-rubis hover:text-rubis-deep transition-colors"
|
className="inline-flex items-center rounded-full border border-line bg-cream-2/50 px-2.5 py-1 text-[11.5px] text-ink-2 hover:border-rubis hover:text-rubis-deep transition-colors"
|
||||||
>
|
>
|
||||||
{v.label}
|
{v.label}
|
||||||
@ -782,6 +858,59 @@ 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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user