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>
  );
}