diff --git a/web/src/app/app/campaigns/[id]/layout.tsx b/web/src/app/app/campaigns/[id]/layout.tsx index 4f6bfcb4..8f9db073 100644 --- a/web/src/app/app/campaigns/[id]/layout.tsx +++ b/web/src/app/app/campaigns/[id]/layout.tsx @@ -19,7 +19,7 @@ import { useConfirm } from "@/hooks/context/confirm"; const TABS = [ { label: "Overview", path: "", Icon: BarChart3Icon }, { label: "Leads", path: "/leads", Icon: UsersIcon }, - { label: "Sequences", path: "/sequences", Icon: ListChecksIcon }, + { label: "Steps", path: "/sequences", Icon: ListChecksIcon }, { label: "Schedule", path: "/schedule", Icon: CalendarIcon }, { label: "Settings", path: "/preferences", Icon: Settings2Icon }, ] as const; diff --git a/web/src/components/app/campaigns/sequences/StepRail.tsx b/web/src/components/app/campaigns/sequences/StepRail.tsx index 6ea772e0..bf70151a 100644 --- a/web/src/components/app/campaigns/sequences/StepRail.tsx +++ b/web/src/components/app/campaigns/sequences/StepRail.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { ClockIcon, Loader2Icon, PlusIcon, SendIcon } from "lucide-react"; +import { CheckIcon, ClockIcon, Loader2Icon, PlusIcon, SendIcon } from "lucide-react"; import toast from "react-hot-toast"; import { NumberInput } from "@/components/ui/field"; import SequenceBox from "./SequenceBox"; @@ -8,6 +8,7 @@ import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdate import useDeleteSequence from "@/lib/api/hooks/app/campaigns/sequences/useDeleteSequence"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; +import { useConfirm } from "@/hooks/context/confirm"; const MAX_STEPS = 5; @@ -22,7 +23,11 @@ function WaitConnector({ sequence: Sequence; }) { const update = useUpdateSequence(campaignId, sequence.id); + // `draft` is the live edited value; it only persists on a commit point + // (blur / Enter / stepper) — never on every keystroke — so an in-flight + // save can't snap the field back mid-typing. const [draft, setDraft] = React.useState(sequence.wait_after); + const [savedAt, setSavedAt] = React.useState(0); // Keep the local draft in lockstep with the canonical value when the cache // updates (e.g. after a save elsewhere). @@ -33,10 +38,11 @@ function WaitConnector({ const commit = (v: number) => { const next = Math.max(0, Math.round(v)); setDraft(next); - if (next === sequence.wait_after) return; + if (next === sequence.wait_after) return; // no change → nothing to save update.mutate( { wait_after: next }, { + onSuccess: () => setSavedAt((n) => n + 1), onError: (err) => { setDraft(sequence.wait_after); toast.error(buildError(err as unknown as AppError)); @@ -45,6 +51,15 @@ function WaitConnector({ ); }; + // Briefly flash a "saved" tick after a successful commit. + const [showSaved, setShowSaved] = React.useState(false); + React.useEffect(() => { + if (savedAt === 0) return; + setShowSaved(true); + const t = setTimeout(() => setShowSaved(false), 1400); + return () => clearTimeout(t); + }, [savedAt]); + return (
@@ -58,16 +73,22 @@ function WaitConnector({ Wait days - {update.isPending && ( + {update.isPending ? ( - )} + ) : showSaved ? ( + + + Saved + + ) : null}
@@ -89,30 +110,26 @@ export default function StepRail({ onCreate: () => void; creating: boolean; }) { - const [confirmId, setConfirmId] = React.useState(null); - const [deletingId, setDeletingId] = React.useState(null); - const deleteSequence = useDeleteSequence(campaignId, confirmId ?? ""); + const confirm = useConfirm(); + const deleteSequence = useDeleteSequence(campaignId); - const confirmTarget = sequences.find((s) => s.id === confirmId) ?? null; - const confirmIndex = sequences.findIndex((s) => s.id === confirmId); - - async function runDelete() { - if (!confirmId) return; - const id = confirmId; - setDeletingId(id); - try { - await deleteSequence.mutateAsync(id); - toast.success("Step removed."); - if (selectedId === id) { - const remaining = sequences.filter((s) => s.id !== id); - onSelect(remaining[0]?.id ?? ""); - } - } catch (err) { - toast.error(buildError(err as AppError)); - } finally { - setDeletingId(null); - setConfirmId(null); - } + function requestDelete(seq: Sequence, index: number) { + const name = seq.name || `Step ${index + 1}`; + confirm.show( + `Delete step ${index + 1}? "${name}" and its content will be removed from this campaign. This can't be undone.`, + async () => { + try { + await deleteSequence.mutateAsync(seq.id); + toast.success("Step removed."); + if (selectedId === seq.id) { + const remaining = sequences.filter((s) => s.id !== seq.id); + onSelect(remaining[0]?.id ?? ""); + } + } catch (err) { + toast.error(buildError(err as AppError)); + } + }, + ); } return ( @@ -133,7 +150,7 @@ export default function StepRail({ subject={seq.subject} active={seq.id === selectedId} onClick={() => onSelect(seq.id)} - onDelete={() => setConfirmId(seq.id)} + onDelete={() => requestDelete(seq, i)} /> ))} @@ -159,40 +176,6 @@ export default function StepRail({ )} - {confirmTarget && ( -
-
-

- Delete step {confirmIndex + 1}? -

-

- {confirmTarget.name || `Step ${confirmIndex + 1}`} and its content will - be removed from this campaign. This can't be undone. -

-
- - -
-
-
- )} ); } diff --git a/web/src/components/ui/field.tsx b/web/src/components/ui/field.tsx index 02ec9ac0..e75679dd 100644 --- a/web/src/components/ui/field.tsx +++ b/web/src/components/ui/field.tsx @@ -124,6 +124,7 @@ export function FieldRow({ children, className }: { children: React.ReactNode; c export function NumberInput({ value, onChange, + onCommit, min, max, step = 1, @@ -135,6 +136,12 @@ export function NumberInput({ }: { value: number; onChange: (value: number) => void; + // Optional "commit point" distinct from the live onChange: fires on blur, + // on Enter, and on each stepper click — but NOT on every keystroke. Use it + // when the consumer wants to persist (e.g. a network save) only once the + // user settles on a value, instead of mid-typing. Omitting it preserves the + // original onChange-only behavior for every existing call site. + onCommit?: (value: number) => void; min?: number; max?: number; step?: number; @@ -150,9 +157,12 @@ export function NumberInput({ if (max !== undefined && n > max) return max; return n; }; + const commitValue = () => onCommit?.(clamp(Number.isFinite(value) ? value : min ?? 0)); const bump = (dir: 1 | -1) => { if (disabled) return; - onChange(clamp((Number.isFinite(value) ? value : 0) + dir * step)); + const next = clamp((Number.isFinite(value) ? value : 0) + dir * step); + onChange(next); + onCommit?.(next); }; const atMax = max !== undefined && value >= max; const atMin = min !== undefined && value <= min; @@ -174,6 +184,18 @@ export function NumberInput({ const raw = e.target.value; onChange(raw === "" ? min ?? 0 : clamp(Number(raw))); }} + onBlur={onCommit ? commitValue : undefined} + onKeyDown={ + onCommit + ? (e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitValue(); + (e.target as HTMLInputElement).blur(); + } + } + : undefined + } className={cn( "w-full min-w-0 h-full bg-transparent outline-none px-2.5 text-[12.5px] text-slate-900 tabular-nums disabled:text-slate-400", "[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", diff --git a/web/src/lib/api/hooks/app/campaigns/sequences/useDeleteSequence.ts b/web/src/lib/api/hooks/app/campaigns/sequences/useDeleteSequence.ts index 115b8aed..1c967bc4 100644 --- a/web/src/lib/api/hooks/app/campaigns/sequences/useDeleteSequence.ts +++ b/web/src/lib/api/hooks/app/campaigns/sequences/useDeleteSequence.ts @@ -2,20 +2,23 @@ import deleteSequence from "@/lib/api/client/app/campaigns/sequences/deleteSeque import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -export default function useDeleteSequence(campaign_id: string, sequence_id: string) { +// Delete a campaign sequence step. The id to delete is the mutate() argument +// (so one mutation instance can delete any step — important for the per-row +// useConfirm flow in StepRail). onSuccess prunes the deleted id from the cache +// using the variable passed to mutate, not a render-time-bound id. +export default function useDeleteSequence(campaign_id: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (sequence_id: string) => deleteSequence(campaign_id, sequence_id), - onSuccess: () => { + onSuccess: (_data, sequence_id) => { queryClient.setQueryData( ["campaigns", campaign_id, "sequences"], (oldData) => { if (!oldData) return oldData; - - return oldData.filter((s) => s.id !== sequence_id) - } - ) - } - }) + return oldData.filter((s) => s.id !== sequence_id); + }, + ); + }, + }); }