SetupFlow.tsx7.8 KBView on GitHub import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ArrowLeft, CornerDownLeft, Loader2, LogOut, Moon, Sun } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { useSearchParams } from 'react-router';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import { signOut } from '@/modules/auth/utils/auth-client';
import { useTRPC } from '@/providers/query-provider';
import { cn } from '@/lib/utils';
import { buildSetupSteps } from './constants';
import { SetupRail } from './components/SetupRail';
import { SetupComplete } from './components/SetupComplete';
import { SetupPreviewProvider } from './components/SetupPreviewContext';
import { useSetupConnect } from './hooks/use-setup-connect';
import { STEP_VIEWS } from './steps';
interface SetupFlowProps {
/** Called when the user leaves the flow — finished, skipped, or done reading. */
onDone: () => void;
className?: string;
}
export function SetupFlow(props: SetupFlowProps) {
return (
<SetupPreviewProvider>
<SetupFlowInner {...props} />
</SetupPreviewProvider>
);
}
/**
* The setup flow: rail on the left, the step's controls in the middle, a live
* preview of the thing being configured on the right.
*
* Every step writes through to the real settings as the user touches them —
* there is no draft to commit at the end. That is what lets the preview be the
* explanation: the user is not previewing what WOULD happen, they are looking at
* what just did.
*/
function SetupFlowInner({ onDone, className }: SetupFlowProps) {
const trpc = useTRPC();
const { theme, setTheme } = useTheme();
const [searchParams] = useSearchParams();
const { available, isPending } = useSetupConnect();
const [index, setIndex] = useState(0);
const [isComplete, setIsComplete] = useState(false);
const steps = useMemo(() => buildSetupSteps(available), [available]);
const { mutate: saveSettings } = useMutation(trpc.settings.save.mutationOptions());
const { mutate: generateAccountContext } = useMutation(
trpc.settings.generateAccountContext.mutationOptions(),
);
// Coming back from Google OAuth, `?step=<id>` returns the user to the step that
// sent them away. Keyed by id, not position: splicing a step in later must not
// silently redirect the callback somewhere else. `last` is the legacy value the
// old flow used and is still honoured for links already in the wild.
const didRestoreStep = useRef(false);
useEffect(() => {
if (didRestoreStep.current || steps.length === 0) return;
const requested = searchParams.get('step');
if (!requested) return;
didRestoreStep.current = true;
if (requested === 'last') {
setIndex(steps.length - 1);
return;
}
const target = steps.findIndex((step) => step.id === requested);
if (target >= 0) setIndex(target);
}, [steps, searchParams]);
const step = steps[index];
const isLast = index === steps.length - 1;
const goBack = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
const finish = useCallback(() => {
saveSettings({ isOnboarded: true } as never);
generateAccountContext({});
setIsComplete(true);
}, [saveSettings, generateAccountContext]);
const onContinue = useCallback(() => {
if (isLast) finish();
else setIndex((i) => Math.min(steps.length - 1, i + 1));
}, [isLast, finish, steps.length]);
// Enter advances, matching the ⏎ hint on the button.
//
// It defers to whatever has focus. A text field owns its own Enter, and so does a
// focused control: Enter on a button fires that button natively, so advancing here
// too would toggle a template AND skip past the step in one keystroke.
useEffect(() => {
if (isComplete) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable ||
target.closest('button, a, [role="button"]'))
) {
return;
}
event.preventDefault();
onContinue();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onContinue, isComplete]);
const handleLogout = useCallback(async () => {
try {
await signOut();
} catch {
// Leaving is the point; a failed sign-out must not trap the user here.
}
window.location.assign('/login');
}, []);
if (isPending || !step) {
return (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
if (isComplete) {
return (
<div className="bg-background h-full w-full overflow-y-auto px-8">
<SetupComplete onEnterApp={onDone} />
</div>
);
}
const Body = STEP_VIEWS[step.id].Body;
const Preview = STEP_VIEWS[step.id].Preview;
return (
<div className={cn('bg-background flex h-full w-full overflow-hidden', className)}>
<SetupRail steps={steps} currentIndex={index} onSelectStep={setIndex} />
{/* ── Controls ── */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-8 py-10">
<AnimatePresence mode="wait">
<motion.div
key=[redacted]
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16 }}
className="mx-auto flex w-full max-w-xl flex-col gap-6"
>
<header className="flex items-baseline gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{step.headline}</h1>
<span className="text-muted-foreground text-xs tabular-nums">
{index + 1} / {steps.length}
</span>
</header>
<Body step={step} onContinue={onContinue} />
</motion.div>
</AnimatePresence>
</div>
{/* ── Footer nav ── */}
<div className="flex shrink-0 items-center justify-between gap-3 border-t px-8 py-4">
<div className="flex items-center gap-1">
<Button
variant="ghost"
className="cursor-pointer"
onClick={index === 0 ? onDone : goBack}
>
{index === 0 ? (
'Skip setup'
) : (
<>
<ArrowLeft className="mr-1 h-4 w-4" /> Back
</>
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
className="cursor-pointer"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Log out"
className="text-muted-foreground cursor-pointer"
onClick={handleLogout}
>
<LogOut className="h-4 w-4" />
</Button>
</div>
<Button className="cursor-pointer gap-2" onClick={onContinue}>
{isLast ? 'Finish' : 'Continue'}
<CornerDownLeft className="h-3.5 w-3.5 opacity-70" />
</Button>
</div>
</div>
{/* ── Live preview ── */}
<div className="bg-sidebar/60 hidden min-w-0 flex-1 flex-col p-6 lg:flex">
<Preview />
</div>
</div>
);
}