"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { animate, AnimatePresence, LayoutGroup, motion, useMotionValue } from "motion/react";
import { cn } from "@/lib/utils";
import DecidedRail from "./decided-rail";
import StepPanel from "./step-panel";
import { useApprovalFlow } from "./use-approval-flow";
import {
freeformOption,
summarise,
type ApprovalAnswers,
type ApprovalStep,
} from "./types";
export interface ApprovalFlowProps {
steps?: ApprovalStep[];
onSubmit?: (answers: ApprovalAnswers) => void;
layout?: "rail" | "inline";
morphDuration?: number;
stiffness?: number;
damping?: number;
density?: "comfortable" | "compact";
revealStagger?: number;
shakeIntensity?: number;
shakeCycles?: number;
showProgress?: boolean;
autoAdvance?: boolean;
accentColor?: string;
dangerColor?: string;
reducedMotion?: boolean;
className?: string;
}
const RECEIPT_ID = "__receipt__";
export default function ApprovalFlow({
steps = [],
onSubmit,
layout = "rail",
morphDuration = 420,
stiffness = 380,
damping = 34,
density = "comfortable",
revealStagger = 45,
shakeIntensity = 6,
shakeCycles = 3,
showProgress = true,
autoAdvance = false,
accentColor = "#a855f7",
dangerColor = "#f87171",
reducedMotion = false,
className,
}: ApprovalFlowProps) {
const uid = useId().replace(/:/g, "");
const flow = useApprovalFlow(steps);
const {
index,
step,
answer,
answers,
error,
rejected,
direction,
submitted,
last,
passes,
decided,
} = flow;
const [moved, setMoved] = useState(false);
const live = useRef({ next: flow.next, onSubmit, answers });
live.current = { next: flow.next, onSubmit, answers };
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
useEffect(() => {
if (submitted) live.current.onSubmit?.(live.current.answers);
}, [submitted]);
const spring = reducedMotion
? { duration: 0 }
: ({ type: "spring", stiffness, damping } as const);
const go = (run: () => void) => {
setMoved(true);
run();
};
const handleToggle = (optionId: string) => {
flow.toggleOption(optionId);
if (!autoAdvance || reducedMotion || !step || last) return;
if (step.kind !== "single" || freeformOption(step)?.id === optionId) return;
clearTimeout(timer.current);
timer.current = setTimeout(() => go(live.current.next), Math.max(180, morphDuration * 0.6));
};
const boxRef = useRef<HTMLDivElement>(null);
const boxH = useMotionValue(0);
const settled = useRef<number | null>(null);
const [sizing, setSizing] = useState<null | "grow" | "shrink">(null);
useLayoutEffect(() => {
const el = boxRef.current;
if (!el) return;
el.style.height = "";
const key = submitted ? RECEIPT_ID : step?.id;
const panels = Array.from(el.querySelectorAll<HTMLElement>("[data-step-id]"));
const live = panels.find((node) => node.dataset.stepId === key);
const prev = settled.current;
if (!live || prev === null) return;
const next = live.offsetHeight;
if (reducedMotion || prev === next) return;
boxH.jump(prev);
el.style.height = `${prev}px`;
setSizing(next > prev ? "grow" : "shrink");
const run = animate(boxH, next, spring);
const release = () => {
setSizing(null);
if (boxRef.current) boxRef.current.style.height = "";
};
void run.then(release);
return () => {
run.stop();
release();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step?.id, submitted, reducedMotion]);
useLayoutEffect(() => {
const el = boxRef.current;
if (!el || el.style.height) return;
if (el.querySelectorAll("[data-step-id]").length !== 1) return;
settled.current = el.offsetHeight;
});
if (!step) return null;
const rail = decided.length > 0 || submitted;
const done = submitted ? steps.length : index;
const record = (
<DecidedRail
steps={submitted ? steps : decided}
answers={answers}
uid={uid}
accentColor={accentColor}
density={density}
spring={spring}
reducedMotion={reducedMotion}
onEdit={(to) => go(() => flow.edit(to))}
/>
);
return (
<div
className={cn(
"w-full rounded-lg border border-hairline bg-panel p-5 sm:p-6",
className,
)}
>
{showProgress ? (
<div className="mb-5 flex items-center gap-3">
<div
className="h-px flex-1 overflow-hidden bg-hairline"
role="progressbar"
aria-valuemin={0}
aria-valuemax={steps.length}
aria-valuenow={done}
aria-label="Approval progress"
>
<motion.div
className="h-full origin-left"
style={{ backgroundColor: accentColor }}
initial={false}
animate={{ scaleX: steps.length ? done / steps.length : 0 }}
transition={reducedMotion ? { duration: 0 } : spring}
/>
</div>
</div>
) : null}
<LayoutGroup id={uid}>
<div
className={cn(
layout === "rail"
? "grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,15rem)]"
: "flex flex-col gap-6",
)}
>
{layout === "inline" && rail ? record : null}
<motion.div
ref={boxRef}
style={sizing ? { height: boxH } : undefined}
className={cn(
"relative min-w-0",
sizing === "grow" && "overflow-hidden",
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{submitted ? (
<motion.div
key="receipt"
data-step-live="true"
initial={reducedMotion ? false : { opacity: 0, x: 44 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0 }}
transition={
reducedMotion ? { duration: 0 } : { duration: morphDuration / 1000 }
}
className="flex flex-col gap-4"
>
<h3 className="font-display text-[15px] tracking-[0.06em] text-ink uppercase">
Authorisation granted
</h3>
<p className="font-sans text-[13px] leading-relaxed text-ink-dim">
{steps.length} decision{steps.length === 1 ? "" : "s"} recorded.
</p>
<ul className="sr-only">
{steps.map((s) => (
<li key={s.id}>
{s.title}: {summarise(s, answers[s.id] ?? { optionIds: [] })}
</li>
))}
</ul>
<div>
<button
type="button"
onClick={() => go(() => flow.edit(0))}
className="rounded-md border border-hairline px-4 py-2 font-display text-[11px] tracking-[0.18em] text-ink-dim uppercase transition-colors outline-none hover:bg-raised hover:text-ink focus-visible:ring-2 focus-visible:ring-accent"
>
Revise
</button>
</div>
</motion.div>
) : (
<StepPanel
key={step.id}
step={step}
answer={answer ?? { optionIds: [] }}
index={index}
total={steps.length}
direction={direction}
error={error}
rejected={rejected}
passes={passes}
last={last}
focusHeading={moved}
density={density}
accentColor={accentColor}
dangerColor={dangerColor}
shakeIntensity={shakeIntensity}
shakeCycles={shakeCycles}
revealStagger={revealStagger}
morphDuration={morphDuration}
spring={spring}
reducedMotion={reducedMotion}
uid={uid}
onToggle={handleToggle}
onOther={flow.setOther}
onNext={() => go(flow.next)}
onBack={() => go(flow.back)}
/>
)}
</AnimatePresence>
</motion.div>
{layout === "rail" ? (
<div className="min-w-0 md:border-l md:border-hairline md:pl-6">
<span className="mb-2.5 block font-display text-[9px] tracking-[0.28em] text-ink-mute uppercase">
Decided
</span>
{record}
{rail ? null : (
<p className="font-sans text-[12px] text-ink-mute/70">Nothing yet.</p>
)}
</div>
) : null}
</div>
</LayoutGroup>
</div>
);
}