Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { Check, History, Search, ShieldCheck, Undo2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { CatalogTable, useRowViews, type Flash } from "./catalog-table";
import { CommandBar, type CommandBarHandle } from "./command-bar";
import { DEFAULT_GUARDRAILS, EXAMPLES, ROWS_SEED } from "./data";
import { applyOps, createLocalProposer, isAsyncIterable, makeFmt, revertOps } from "./engine";
import { HistoryPanel } from "./history-panel";
import { PlanCard, type Draft } from "./plan-card";
import type { Changeset, Commit, Guardrails, PlanEvent, ProposeRequest, ProposeResult, Row } from "./types";
import { CopilotMark, ROOT_VARS, focusRing, useDialog, useMedia } from "./ui";

export type { Row, Changeset, Guardrails, ProposeRequest, ProposeResult, PlanEvent, Commit };

export type ChangesetCopilotAppProps = {
  /** Catalogue rows. Default: 40 seeded home & kitchen SKUs. */
  rows?: Row[];
  title?: string;
  currency?: string;
  locale?: string;
  examples?: string[];
  guardrails?: Guardrails;
  /**
   * Replace the local engine with a model. Return a changeset (Promise) or stream `{ step }` lines
   * followed by `{ changeset }`. Ops are plain JSON: `{ rowId, field, from, to, reason }`.
   */
  propose?: (req: ProposeRequest) => ProposeResult;
  onApply?: (changeset: Changeset, rows: Row[]) => void;
  onRevert?: (commit: Commit, rows: Row[]) => void;
  className?: string;
};

const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

export function ChangesetCopilotApp({
  rows: rowsProp = ROWS_SEED,
  title = "Catalog Copilot",
  currency = "PLN",
  locale = "pl-PL",
  examples = EXAMPLES,
  guardrails: guardProp = DEFAULT_GUARDRAILS,
  propose,
  onApply,
  onRevert,
  className,
}: ChangesetCopilotAppProps) {
  const reduced = useReducedMotion() ?? false;
  const fmt = React.useMemo(() => makeFmt(currency, locale), [currency, locale]);
  const local = React.useMemo(() => createLocalProposer(fmt), [fmt]);
  const xl = useMedia("(min-width: 1280px)");
  const md = useMedia("(min-width: 768px)");

  const [rows, setRows] = React.useState<Row[]>(rowsProp);
  const [guard, setGuard] = React.useState<Guardrails>(guardProp);
  const [draft, setDraft] = React.useState<Draft | null>(null);
  const [rejected, setRejected] = React.useState<Set<string>>(new Set());
  const [commits, setCommits] = React.useState<Commit[]>([]);
  const [flash, setFlash] = React.useState<Flash | null>(null);
  const [query, setQuery] = React.useState("");
  const [changedOnly, setChangedOnly] = React.useState(false);
  const [historyOpen, setHistoryOpen] = React.useState(false);
  const [guardOpen, setGuardOpen] = React.useState(false);
  const [announce, setAnnounce] = React.useState("");

  const cmdRef = React.useRef<CommandBarHandle>(null);
  const abortRef = React.useRef<AbortController | null>(null);
  const runRef = React.useRef(0);
  const tableRef = React.useRef<HTMLDivElement>(null);
  const histBtn = React.useRef<HTMLButtonElement>(null);
  const sheetRef = React.useRef<HTMLDivElement>(null);
  const guardBtn = React.useRef<HTMLButtonElement>(null);
  const guardRef = React.useRef<HTMLDivElement>(null);
  const seqRef = React.useRef(0);
  useDialog(historyOpen && !xl, sheetRef, () => setHistoryOpen(false), histBtn);

  const cs = draft?.status === "ready" ? draft.changeset ?? null : null;
  const views = useRowViews(rows, cs, query, changedOnly);
  const acceptedOps = React.useMemo(() => (cs ? cs.ops.filter((o) => !rejected.has(o.rowId)) : []), [cs, rejected]);
  const acceptedRows = new Set(acceptedOps.map((o) => o.rowId)).size;

  /* -------------------------------- propose -------------------------------- */

  const run = React.useCallback(
    async (command: string) => {
      abortRef.current?.abort();
      const ac = new AbortController();
      abortRef.current = ac;
      const id = ++runRef.current;
      const alive = () => runRef.current === id;
      setDraft({ command, steps: [], status: "planning" });
      setRejected(new Set());
      setAnnounce("Planning the change");
      try {
        const req: ProposeRequest = { command, rows, guardrails: guard, signal: ac.signal };
        const out = (propose ?? local)(req);
        const done = (c: Changeset) => {
          if (!alive()) return;
          setDraft((d) => (d ? { ...d, status: "ready", changeset: c, steps: d.steps.length ? d.steps : c.plan } : d));
          setChangedOnly(!window.matchMedia("(min-width: 768px)").matches);
          const cells = c.ops.length;
          setAnnounce(`Proposal ready: ${cells} cell change${cells === 1 ? "" : "s"} across ${new Set(c.ops.map((o) => o.rowId)).size} products, ${c.warnings.length} adjusted, ${c.skipped.length} skipped.`);
          requestAnimationFrame(() => {
            const first = tableRef.current?.querySelector<HTMLElement>("tr[tabindex='0'], li[tabindex='0']");
            if (first && tableRef.current) {
              const box = tableRef.current.getBoundingClientRect();
              const r = first.getBoundingClientRect();
              if (r.top < box.top || r.bottom > box.bottom) tableRef.current.scrollBy({ top: r.top - box.top - 60, behavior: reduced ? "auto" : "smooth" });
            }
          });
        };
        const clarify = (message: string) => alive() && setDraft((d) => (d ? { ...d, status: "clarify", message } : d));
        if (isAsyncIterable<PlanEvent>(out)) {
          for await (const ev of out) {
            if (!alive()) return;
            if ("step" in ev) setDraft((d) => (d ? { ...d, steps: [...d.steps, ev.step] } : d));
            else if ("changeset" in ev) done(ev.changeset);
            else clarify(ev.clarify);
          }
        } else {
          const res = await out;
          if (!alive()) return;
          if ("clarify" in res) clarify(res.clarify);
          else {
            for (const step of res.plan) {
              setDraft((d) => (d ? { ...d, steps: [...d.steps, step] } : d));
              await sleep(reduced ? 0 : 260);
              if (!alive()) return;
            }
            done(res);
          }
        }
      } catch (e) {
        if (!alive() || (e instanceof DOMException && e.name === "AbortError")) return;
        setDraft((d) => (d ? { ...d, status: "error", message: e instanceof Error ? e.message : "Something went wrong." } : d));
      }
    },
    [guard, local, propose, reduced, rows],
  );

  const discard = React.useCallback(() => {
    abortRef.current?.abort();
    runRef.current++;
    setDraft(null);
    setRejected(new Set());
    setAnnounce("Proposal discarded");
  }, []);

  const now = () => new Date().toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });

  const flashRows = (ids: string[]) => {
    const map = new Map(ids.map((id, i) => [id, i]));
    const key = ++seqRef.current;
    setFlash({ ids: map, key });
    setTimeout(() => setFlash((f) => (f && f.key === key ? null : f)), 1400);
  };

  const apply = React.useCallback(() => {
    if (!cs || !acceptedOps.length) return;
    const next = applyOps(rows, acceptedOps);
    const n = commits.length + 1;
    const commit: Commit = { id: `c${n}-${cs.id}`, n, title: cs.command, ops: acceptedOps, at: now() };
    setRows(next);
    setCommits((c) => [commit, ...c]);
    setDraft(null);
    setRejected(new Set());
    flashRows([...new Set(acceptedOps.map((o) => o.rowId))]);
    setAnnounce(`Applied ${acceptedOps.length} changes as commit ${n}`);
    onApply?.({ ...cs, ops: acceptedOps }, next);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cs, acceptedOps, rows, commits.length, onApply]);

  const revert = React.useCallback(
    (c: Commit) => {
      const { ops, conflicts } = revertOps(rows, c.ops);
      if (!ops.length) {
        setAnnounce("Nothing to revert: those cells changed since.");
        return;
      }
      const next = applyOps(rows, ops);
      const n = commits.length + 1;
      const commit: Commit = { id: `c${n}-r`, n, title: `Revert #${c.n}: ${c.title}`, ops, at: now(), revertOf: c.id };
      setRows(next);
      setCommits((list) => [commit, ...list.map((x) => (x.id === c.id ? { ...x, reverted: true } : x))]);
      flashRows([...new Set(ops.map((o) => o.rowId))]);
      setAnnounce(`Reverted commit ${c.n}${conflicts.length ? `; ${conflicts.length} cell${conflicts.length === 1 ? " was" : "s were"} changed later and kept` : ""}`);
      onRevert?.(c, next);
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [rows, commits.length, onRevert],
  );

  const toggle = (id: string) =>
    setRejected((s) => {
      const n = new Set(s);
      if (n.has(id)) n.delete(id);
      else n.add(id);
      return n;
    });
  const acceptAll = () => setRejected(new Set());
  const rejectAll = () => cs && setRejected(new Set(cs.ops.map((o) => o.rowId)));

  /* ------------------------------- shortcuts ------------------------------- */

  const live = React.useRef({ apply, discard, acceptAll, rejectAll, revert, commits, cs, draft });
  React.useEffect(() => {
    live.current = { apply, discard, acceptAll, rejectAll, revert, commits, cs, draft };
  });
  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const t = e.target as HTMLElement | null;
      const typing = !!t?.closest("input:not([type=checkbox]), textarea, select, [contenteditable='true']");
      const L = live.current;
      const mod = e.metaKey || e.ctrlKey;
      if (mod && e.key === "Enter" && L.cs) {
        e.preventDefault();
        L.apply();
        return;
      }
      if (mod && (e.key === "k" || e.key === "K")) {
        e.preventDefault();
        cmdRef.current?.focus();
        return;
      }
      if (typing) return;
      if (e.key === "/") {
        e.preventDefault();
        cmdRef.current?.focus();
      } else if (mod && (e.key === "z" || e.key === "Z")) {
        const last = L.commits.find((c) => !c.reverted);
        if (last) {
          e.preventDefault();
          L.revert(last);
        }
      } else if (!mod && L.cs && (e.key === "a" || e.key === "A")) L.acceptAll();
      else if (!mod && L.cs && (e.key === "r" || e.key === "R")) L.rejectAll();
      else if (e.key === "Escape" && L.draft && !document.querySelector("[data-cc-sheet]")) L.discard();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  React.useEffect(() => {
    if (!guardOpen) return;
    const onDown = (e: PointerEvent) => {
      if (!guardRef.current?.contains(e.target as Node) && !guardBtn.current?.contains(e.target as Node)) setGuardOpen(false);
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [guardOpen]);

  React.useEffect(() => () => abortRef.current?.abort(), []);

  const lastCommit = commits.find((c) => !c.reverted);
  const busy = draft?.status === "planning";
  const guardCount = 1 + 1 + (guard.lockPromo ? 1 : 0) + (guard.rounding !== "none" ? 1 : 0);
  const historyProps = { commits, draft: cs ? { command: cs.command, cells: acceptedOps.length } : null, onRevert: revert };

  return (
    <MotionConfig reducedMotion="user">
      <div className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", ROOT_VARS, className)}>
        <div className="flex min-w-0 flex-1 flex-col" inert={historyOpen && !xl ? true : undefined}>
          {/* Header */}
          <header className="flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2.5 sm:px-4 md:flex-nowrap">
            <div className="flex min-w-0 items-center gap-2 md:w-56 md:shrink-0">
              <CopilotMark />
              <div className="min-w-0">
                <p className="truncate text-[14px] font-semibold leading-tight">{title}</p>
                <p className="truncate text-[11px] text-muted-foreground">{rows.length} products · diff-reviewed edits</p>
              </div>
            </div>
            <div className="order-last w-full md:order-none md:w-auto md:flex-1">
              <CommandBar ref={cmdRef} examples={examples} busy={busy} onSubmit={(c) => void run(c)} onStop={discard} onEscape={() => draft && discard()} />
            </div>
            <div className="ml-auto flex items-center gap-1">
              <div className="relative">
                <button
                  ref={guardBtn}
                  type="button"
                  onClick={() => setGuardOpen((o) => !o)}
                  aria-expanded={guardOpen}
                  aria-haspopup="dialog"
                  className={cn("inline-flex h-9 items-center gap-1.5 rounded-xl px-2.5 text-[12.5px] font-medium hover:bg-accent", guardOpen && "bg-accent", focusRing)}
                >
                  <ShieldCheck className="size-4 text-[var(--cc-up)]" aria-hidden />
                  <span className="hidden lg:inline">Guardrails</span>
                  <span className="rounded-full bg-muted px-1.5 text-[10.5px] tabular-nums">{guardCount}</span>
                </button>
                <AnimatePresence>
                  {guardOpen && (
                    <motion.div
                      ref={guardRef}
                      role="dialog"
                      aria-label="Guardrails"
                      initial={{ opacity: 0, y: -4 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0, y: -4 }}
                      onKeyDown={(e) => {
                        if (e.key === "Escape") {
                          e.stopPropagation();
                          setGuardOpen(false);
                          guardBtn.current?.focus();
                        }
                      }}
                      className="absolute right-0 top-11 z-50 w-72 space-y-3 rounded-2xl border bg-popover p-4 text-[12.5px] text-popover-foreground shadow-xl"
                    >
                      <p className="text-[11.5px] text-muted-foreground">Every proposal is checked against these before you see it.</p>
                      <label className="flex items-center justify-between gap-3">
                        Margin floor
                        <span className="inline-flex items-center gap-1">
                          <input type="number" min={0} max={90} value={guard.marginFloor} onChange={(e) => setGuard({ ...guard, marginFloor: Math.max(0, Math.min(90, Number(e.target.value) || 0)) })} className="h-8 w-16 rounded-lg border bg-background px-2 text-right tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring" />%
                        </span>
                      </label>
                      <label className="flex items-center justify-between gap-3">
                        Max price change per run
                        <span className="inline-flex items-center gap-1">
                          <input type="number" min={1} max={100} value={guard.maxChange} onChange={(e) => setGuard({ ...guard, maxChange: Math.max(1, Math.min(100, Number(e.target.value) || 1)) })} className="h-8 w-16 rounded-lg border bg-background px-2 text-right tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring" />%
                        </span>
                      </label>
                      <label className="flex cursor-pointer items-center justify-between gap-3">
                        Lock prices of promo items
                        <input type="checkbox" checked={guard.lockPromo} onChange={(e) => setGuard({ ...guard, lockPromo: e.target.checked })} className="size-4 accent-[var(--cc-accent)]" />
                      </label>
                      <fieldset>
                        <legend className="mb-1.5">Default rounding</legend>
                        <div className="grid grid-cols-4 gap-1 rounded-xl bg-muted p-1">
                          {(["none", ".99", ".49", "whole"] as const).map((r) => (
                            <label key={r} className={cn("cursor-pointer rounded-lg py-1 text-center text-[12px] capitalize has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring", guard.rounding === r ? "bg-background font-semibold shadow-sm" : "text-muted-foreground")}>
                              <input type="radio" name="cc-round" value={r} checked={guard.rounding === r} onChange={() => setGuard({ ...guard, rounding: r })} className="sr-only" />
                              {r}
                            </label>
                          ))}
                        </div>
                      </fieldset>
                    </motion.div>
                  )}
                </AnimatePresence>
              </div>
              {lastCommit && (
                <button type="button" onClick={() => revert(lastCommit)} aria-label={`Undo commit ${lastCommit.n}`} aria-keyshortcuts="Control+Z Meta+Z" className={cn("hidden h-9 items-center gap-1.5 rounded-xl px-2.5 text-[12.5px] font-medium hover:bg-accent sm:inline-flex", focusRing)}>
                  <Undo2 className="size-4" aria-hidden /> <span className="hidden lg:inline">Undo</span>
                </button>
              )}
              {!xl && (
                <button ref={histBtn} type="button" onClick={() => setHistoryOpen(true)} aria-label={`History, ${commits.length} commit${commits.length === 1 ? "" : "s"}`} className={cn("relative grid size-9 place-items-center rounded-xl hover:bg-accent", focusRing)}>
                  <History className="size-4.5" aria-hidden />
                  {commits.length > 0 && <span className="absolute -right-0.5 -top-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-[var(--cc-accent)] px-1 text-[10px] font-semibold text-white">{commits.length}</span>}
                </button>
              )}
            </div>
          </header>

          <div className="flex min-h-0 flex-1 flex-col gap-2 p-2 sm:p-3">
            <AnimatePresence>
              {draft && (
                <PlanCard
                  key={`plan-${draft.command}`}
                  draft={draft}
                  fmt={fmt}
                  acceptedRows={acceptedRows}
                  acceptedCells={acceptedOps.length}
                  totalRows={rows.length}
                  examples={examples}
                  onAcceptAll={acceptAll}
                  onRejectAll={rejectAll}
                  onDiscard={discard}
                  onApply={apply}
                  onExample={(e) => void run(e)}
                  onRetry={() => draft && void run(draft.command)}
                  compact={!md}
                />
              )}
            </AnimatePresence>

            <AnimatePresence initial={false}>
              {!draft && !commits.length && (
                <motion.div
                  key="hint"
                  initial={{ opacity: 0, height: 0 }}
                  animate={{ opacity: 1, height: "auto" }}
                  exit={{ opacity: 0, height: 0 }}
                  className="shrink-0 overflow-hidden"
                >
                  <div className="flex items-center gap-2 overflow-x-auto rounded-2xl border border-dashed bg-[var(--cc-accent)]/[0.04] px-3 py-2 [scrollbar-width:none]">
                    <span className="shrink-0 text-[12px] font-medium text-muted-foreground">Try</span>
                    {examples.slice(0, 3).map((e) => (
                      <button key={e} type="button" onClick={() => void run(e)} className={cn("h-7 shrink-0 rounded-full border bg-background px-3 text-[12px] transition hover:border-[var(--cc-accent)]/50 hover:text-[var(--cc-accent)]", focusRing)}>
                        {e}
                      </button>
                    ))}
                  </div>
                </motion.div>
              )}
            </AnimatePresence>

            <section aria-label="Catalogue" className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border bg-card">
              <div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2">
                <div className="relative min-w-32 flex-1 sm:max-w-64">
                  <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
                  <label htmlFor="cc-filter" className="sr-only">
                    Filter products
                  </label>
                  <input id="cc-filter" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter products" className="h-8 w-full rounded-lg border bg-background pl-8 pr-2 text-[12.5px] outline-none focus-visible:ring-2 focus-visible:ring-ring" />
                </div>
                {cs && (
                  <button type="button" role="switch" aria-checked={changedOnly} onClick={() => setChangedOnly((v) => !v)} className={cn("inline-flex h-8 items-center gap-2 rounded-lg px-2 text-[12px] font-medium", focusRing)}>
                    <span className={cn("relative h-5 w-8 rounded-full transition-colors", changedOnly ? "bg-[var(--cc-accent)]" : "bg-muted-foreground/30")}>
                      <motion.span layout className={cn("absolute top-0.5 size-4 rounded-full bg-white shadow", changedOnly ? "right-0.5" : "left-0.5")} transition={{ type: "spring", stiffness: 600, damping: 34 }} />
                    </span>
                    Changed only
                  </button>
                )}
                <span className={cn("ml-auto text-[11.5px] text-muted-foreground tabular-nums", cs && "hidden sm:inline")}>
                  {cs ? (
                    <>
                      <strong className="text-[var(--cc-accent)]">{acceptedOps.length}</strong> of {cs.ops.length} changes accepted
                      <span className="hidden sm:inline"> · Space toggles a row · A / R all</span>
                    </>
                  ) : (
                    `${views.length} of ${rows.length} products`
                  )}
                </span>
              </div>
              <div ref={tableRef} className="min-h-0 flex-1 overflow-auto">
                <CatalogTable views={views} fmt={fmt} hasProposal={!!cs} rejected={rejected} onToggle={toggle} flash={flash} marginFloor={guard.marginFloor} />
              </div>
            </section>
          </div>

          {/* Mobile action bar */}
          <AnimatePresence>
            {cs && (
              <motion.div initial={{ y: 60 }} animate={{ y: 0 }} exit={{ y: 60 }} className="flex shrink-0 items-center gap-2 border-t bg-background p-2.5 md:hidden">
                <button type="button" onClick={discard} className={cn("h-11 rounded-xl border px-4 text-[13px] font-medium", focusRing)}>
                  Discard
                </button>
                <button type="button" onClick={apply} disabled={!acceptedOps.length} className={cn("inline-flex h-11 flex-1 items-center justify-center gap-1.5 rounded-xl bg-[var(--cc-accent)] text-[13.5px] font-semibold text-white disabled:opacity-40", focusRing)}>
                  <Check className="size-4" aria-hidden /> Apply {acceptedOps.length}
                </button>
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        {/* History rail */}
        {xl && (
          <aside aria-label="History" className="w-72 shrink-0 border-l bg-muted/20">
            <HistoryPanel {...historyProps} />
          </aside>
        )}
        <AnimatePresence>
          {historyOpen && !xl && (
            <>
              <motion.div key="scrim" className="absolute inset-0 z-40 bg-black/40" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setHistoryOpen(false)} aria-hidden />
              <motion.div
                key="sheet"
                ref={sheetRef}
                data-cc-sheet
                role="dialog"
                aria-modal="true"
                aria-label="History"
                className="absolute inset-y-0 right-0 z-50 w-[86%] max-w-80 border-l bg-background shadow-2xl"
                initial={reduced ? { opacity: 0 } : { x: "100%" }}
                animate={reduced ? { opacity: 1 } : { x: 0 }}
                exit={reduced ? { opacity: 0 } : { x: "100%" }}
                transition={{ type: "spring", stiffness: 380, damping: 38 }}
              >
                <HistoryPanel {...historyProps} onClose={() => setHistoryOpen(false)} />
              </motion.div>
            </>
          )}
        </AnimatePresence>

        <p className="sr-only" aria-live="polite">
          {announce}
        </p>
      </div>
    </MotionConfig>
  );
}

More in AI

View all →