Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { Check, FileSignature, Gauge, Hand, LogOut, Pause, Play, RotateCcw, Scale, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { ContractDialog } from "./contract";
import { DEALS, TODAY } from "./data";
import { callPolicy, checkGuardrails, createLocalBuyer, createLocalSupplier, effectivePrice, lastOffer, makeFmt, savingsVsLast, totalCases } from "./engine";
import { GuardrailsPanel } from "./guardrails";
import { OfferThread } from "./offer-thread";
import { TermSheet } from "./term-sheet";
import { TugOfWar } from "./tug-of-war";
import type { Agreement, Deal, Guardrails, HumanOption, Move, Nudge, Policy, PolicyReply, PolicyState, Side, Status, Terms } from "./types";
import { Avatar, Kbd, ROOT_VARS, RoomMark, focusRing, useDialog, useMedia } from "./ui";

export type { Agreement, Deal, Guardrails, Move, Policy, PolicyReply, PolicyState, Terms };

export type NegotiationRoomAppProps = {
  /** Deals to negotiate. Default: 3 seeded reorders (water, wine, bags). */
  deals?: Deal[];
  initialDealId?: string;
  /**
   * Replace the local buyer agent (e.g. with an LLM). Called once per buyer turn with the full
   * thread and the human's guardrails; must return plain JSON. The shell still refuses
   * to sign anything outside the guardrails.
   */
  buyerPolicy?: Policy;
  /** Replace the local supplier agent (e.g. a supplier's real A2A endpoint). Never receives guardrails. */
  supplierPolicy?: Policy;
  onAgree?: (agreement: Agreement) => void;
  onWalkAway?: (info: { deal: Deal; lastOffer: Terms | null; reason: string }) => void;
  onMove?: (move: Move) => void;
  /** Called when the human signs and sends the purchase order from the contract dialog. */
  onSendPurchaseOrder?: (agreement: Agreement, poNumber: string) => void;
  currency?: string;
  locale?: string;
  /** ISO date used for delivery dates. */
  today?: string;
  maxRounds?: number;
  speed?: 1 | 2;
  autoStart?: boolean;
  title?: string;
  className?: string;
};

type Tab = "thread" | "guardrails" | "terms";

export function NegotiationRoomApp({
  deals = DEALS,
  initialDealId,
  buyerPolicy,
  supplierPolicy,
  onAgree,
  onWalkAway,
  onMove,
  onSendPurchaseOrder,
  currency = "PLN",
  locale = "pl-PL",
  today = TODAY,
  maxRounds = 12,
  speed: speedProp = 1,
  autoStart = false,
  title = "Negotiation Room",
  className,
}: NegotiationRoomAppProps) {
  const reduced = useReducedMotion() ?? false;
  const wide = useMedia("(min-width: 1024px)");
  const fmt = React.useMemo(() => makeFmt(currency, locale, today), [currency, locale, today]);
  const localBuyer = React.useMemo(() => createLocalBuyer(fmt), [fmt]);
  const localSupplier = React.useMemo(() => createLocalSupplier(fmt), [fmt]);

  const [dealId, setDealId] = React.useState(initialDealId ?? deals[0]?.id);
  const deal = deals.find((d) => d.id === dealId) ?? deals[0];
  const [guards, setGuards] = React.useState<Record<string, Guardrails>>(() => Object.fromEntries(deals.map((d) => [d.id, d.guardrails])));
  const g = guards[deal.id] ?? deal.guardrails;
  const [moves, setMoves] = React.useState<Move[]>([]);
  const [status, setStatus] = React.useState<Status>(autoStart ? "running" : "idle");
  const [typing, setTyping] = React.useState<Side | null>(null);
  const [nudge, setNudge] = React.useState<Nudge>(null);
  const [decisions, setDecisions] = React.useState<HumanOption[]>([]);
  const [error, setError] = React.useState<string | null>(null);
  const [retry, setRetry] = React.useState(0);
  const [agreement, setAgreement] = React.useState<Agreement | null>(null);
  const [contractOpen, setContractOpen] = React.useState(false);
  const [po, setPo] = React.useState<string | null>(null);
  const [confirmWalk, setConfirmWalk] = React.useState(false);
  const [speed, setSpeed] = React.useState<1 | 2>(speedProp);
  const [tab, setTab] = React.useState<Tab>("thread");
  const [announce, setAnnounce] = React.useState("");

  const n = totalCases(deal);
  const buyerOffer = lastOffer(moves, "buyer");
  const supplierOffer = lastOffer(moves, "supplier");
  const pending = moves.length && moves[moves.length - 1].action === "ask-human" && status === "stalled" ? moves[moves.length - 1] : null;
  const rounds = moves.reduce((r, m) => Math.max(r, m.round), 0);
  const active = status === "running" || status === "paused" || status === "stalled";
  const canAccept = active && !!supplierOffer && checkGuardrails(g, supplierOffer.terms, n).ok;

  /* ------------------------------ lifecycle ------------------------------ */


  function agree(terms: Terms, by: Agreement["acceptedBy"], all: Move[]) {
    const nonAsk = all.filter((m) => m.action !== "ask-human");
    const a: Agreement = { deal, terms, rounds: Math.ceil(nonAsk.length / 2), savings: savingsVsLast(deal, terms), total: Math.round(terms.unitPrice * n * 100) / 100, acceptedBy: by };
    setAgreement(a);
    setStatus("agreed");
    setAnnounce(`Agreement reached at ${fmt.money(terms.unitPrice)} per ${deal.unit}.`);
    onAgree?.(a);
  }

  const live = React.useRef({ deal, g, nudge, decisions, moves, buyerPolicy, supplierPolicy, localBuyer, localSupplier, onMove, onWalkAway, maxRounds, agree });
  React.useEffect(() => {
    live.current = { deal, g, nudge, decisions, moves, buyerPolicy, supplierPolicy, localBuyer, localSupplier, onMove, onWalkAway, maxRounds, agree };
  });

  React.useEffect(() => {
    if (status !== "running" || error) return;
    const all = live.current.moves;
    const last = all[all.length - 1];
    if (last && (last.action === "accept" || last.action === "walk")) return;
    const nonAsk = all.filter((m) => m.action !== "ask-human");
    const side: Side = nonAsk.length % 2 === 0 ? "buyer" : "supplier";
    const round = Math.floor(nonAsk.length / 2) + 1;
    const ac = new AbortController();
    let cancelled = false;
    const start = setTimeout(() => setTyping(side), reduced ? 0 : 120);
    const delay = reduced ? 220 : (side === "buyer" ? 900 : 1100) / speed;
    const timer = setTimeout(async () => {
      const L = live.current;
      const policy = side === "buyer" ? (L.buyerPolicy ?? L.localBuyer) : (L.supplierPolicy ?? L.localSupplier);
      const state: PolicyState = {
        side,
        deal: L.deal,
        round,
        history: all,
        lastOther: [...all].reverse().find((m) => m.side !== side && m.action !== "ask-human"),
        lastOwn: [...all].reverse().find((m) => m.side === side && m.action !== "ask-human"),
        guardrails: side === "buyer" ? L.g : undefined,
        nudge: side === "buyer" ? L.nudge : null,
        decisions: L.decisions,
        maxRounds: L.maxRounds,
      };
      try {
        const reply = await callPolicy(policy, state, ac.signal);
        if (cancelled) return;
        const other = lastOffer(all, side === "buyer" ? "supplier" : "buyer");
        if (reply.action === "offer" && !reply.terms) throw new Error("The agent replied without terms.");
        if (reply.action === "accept" && !other) throw new Error("The agent accepted before any offer was made.");
        const terms = reply.action === "offer" ? reply.terms : (other?.terms ?? L.deal.lastOrder);
        // The shell, not the agent, enforces the guardrails on anything the buyer signs.
        if (reply.action === "accept" && side === "buyer" && other && !checkGuardrails(L.g, other.terms, totalCases(L.deal)).ok) throw new Error("The buyer agent tried to accept an offer outside your guardrails. Blocked.");
        const move: Move = {
          ...reply,
          id: `m${all.length + 1}`,
          side,
          round,
          terms,
          tone: reply.action === "offer" ? (reply.tone ?? "counter") : reply.action === "ask-human" ? "ask" : reply.action,
        } as Move;
        const next = [...all, move];
        setTyping(null);
        setMoves(next);
        if (side === "buyer") setNudge(null);
        L.onMove?.(move);
        if (move.action === "accept") L.agree(terms, side, next);
        else if (move.action === "walk") {
          setStatus("walked");
          setAnnounce(`${side === "buyer" ? "Your agent" : L.deal.supplier.name} walked away.`);
          L.onWalkAway?.({ deal: L.deal, lastOffer: lastOffer(next, "supplier")?.terms ?? null, reason: move.rationale });
        } else if (move.action === "ask-human") {
          setStatus("stalled");
          setAnnounce(`Stalled. ${move.question.message}`);
          setTab("thread");
        } else {
          setAnnounce(`${side === "buyer" ? "Buyer" : "Supplier"} offers ${fmt.money(move.terms.unitPrice)}, round ${round}.`);
        }
      } catch (e) {
        if (cancelled || (e instanceof DOMException && e.name === "AbortError")) return;
        setTyping(null);
        setError(e instanceof Error ? e.message : "The agent failed.");
      }
    }, delay);
    return () => {
      cancelled = true;
      ac.abort();
      clearTimeout(start);
      clearTimeout(timer);
      setTyping(null);
    };
  }, [status, moves, error, retry, speed, reduced, fmt]);

  React.useEffect(() => {
    if (status !== "agreed") return;
    const t = setTimeout(() => setContractOpen(true), reduced ? 0 : 900);
    return () => clearTimeout(t);
  }, [status, reduced]);

  const reset = (run: boolean) => {
    setMoves([]);
    setNudge(null);
    setDecisions([]);
    setError(null);
    setAgreement(null);
    setContractOpen(false);
    setPo(null);
    setConfirmWalk(false);
    setStatus(run ? "running" : "idle");
    setAnnounce(run ? "Negotiation started" : "");
  };

  const pickDeal = (id: string) => {
    setDealId(id);
    reset(false);
  };

  const togglePause = () => setStatus((s) => (s === "running" ? "paused" : s === "paused" ? "running" : s === "idle" ? "running" : s));

  const humanAccept = () => {
    const offer = supplierOffer;
    if (!offer || !checkGuardrails(g, offer.terms, n).ok) return;
    const nonAsk = moves.filter((m) => m.action !== "ask-human");
    const move: Move = { action: "accept", rationale: `You accepted ${fmt.money(offer.terms.unitPrice)} on ${offer.terms.paymentDays}-day terms.`, id: `m${moves.length + 1}`, side: "buyer", round: Math.floor(nonAsk.length / 2) + 1, terms: offer.terms, tone: "accept" };
    const next = [...moves, move];
    setMoves(next);
    onMove?.(move);
    agree(offer.terms, "human", next);
  };

  const humanWalk = () => {
    const nonAsk = moves.filter((m) => m.action !== "ask-human");
    const offer = supplierOffer;
    const move: Move = { action: "walk", rationale: "You ended the negotiation.", id: `m${moves.length + 1}`, side: "buyer", round: Math.floor(nonAsk.length / 2) + 1, terms: offer?.terms ?? deal.lastOrder, tone: "walk" };
    setMoves((m) => [...m, move]);
    setConfirmWalk(false);
    setStatus("walked");
    setAnnounce("You walked away.");
    onMove?.(move);
    onWalkAway?.({ deal, lastOffer: offer?.terms ?? null, reason: "Walked away by the human." });
  };

  const choose = (o: HumanOption) => {
    const q = pending && pending.action === "ask-human" ? pending.question : null;
    if (o === "walk-away") {
      humanWalk();
      return;
    }
    setDecisions((d) => [...d, o]);
    if (o === "concede-terms") setNudge("concede-terms");
    if (o === "final-offer") setNudge("final-offer");
    if (o === "raise-walk" && q?.suggestedWalkAway !== undefined) {
      const w = q.suggestedWalkAway;
      setGuards((all) => ({ ...all, [deal.id]: { ...g, walkAwayPrice: w } }));
    }
    setStatus("running");
  };

  const sendNudge = (nd: Exclude<Nudge, null>) => {
    setNudge(nd);
    if (nd === "concede-terms") setDecisions((d) => (d.includes("concede-terms") ? d : [...d, "concede-terms"]));
    setAnnounce(nd === "hold-firm" ? "Nudge queued: hold firm" : "Nudge queued: concede on payment terms");
  };

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

  const keys = React.useRef({ togglePause, humanAccept, canAccept, active, contractOpen, confirmWalk, status });
  React.useEffect(() => {
    keys.current = { togglePause, humanAccept, canAccept, active, contractOpen, confirmWalk, status };
  });
  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const t = e.target as HTMLElement | null;
      if (t?.closest("input, textarea, select, button, a, [role='dialog'], [role='alertdialog']")) return;
      const K = keys.current;
      if (K.contractOpen || K.confirmWalk || e.metaKey || e.ctrlKey || e.altKey) return;
      if (e.key === " ") {
        if (K.status === "idle" || K.status === "running" || K.status === "paused") {
          e.preventDefault();
          K.togglePause();
        }
      } else if (e.key === "Enter" && K.canAccept) {
        e.preventDefault();
        K.humanAccept();
      } else if (e.key === "Escape" && K.active) {
        e.preventDefault();
        setConfirmWalk(true);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  /* -------------------------------- render ------------------------------- */

  const statusPill: Record<Status, { label: string; cls: string }> = {
    idle: { label: "Ready", cls: "bg-muted text-muted-foreground" },
    running: { label: "Negotiating", cls: "bg-[var(--nr-buyer)]/12 text-[var(--nr-buyer)]" },
    paused: { label: "Paused", cls: "bg-muted text-foreground" },
    stalled: { label: "Needs you", cls: "bg-[var(--nr-warn)]/15 text-[var(--nr-warn)]" },
    agreed: { label: "Agreed", cls: "bg-[var(--nr-zone)]/15 text-[var(--nr-zone)]" },
    walked: { label: "Walked away", cls: "bg-[var(--nr-bad)]/12 text-[var(--nr-bad)]" },
  };

  const guardPanel = <GuardrailsPanel deal={deal} value={g} onChange={(v) => setGuards((all) => ({ ...all, [deal.id]: v }))} fmt={fmt} locked={status === "running"} />;
  const termSheet = <TermSheet deal={deal} terms={agreement?.terms ?? supplierOffer?.terms ?? null} agreed={!!agreement} guardrails={g} fmt={fmt} />;

  const empty = (
    <div className="mx-auto flex max-w-xl flex-col items-center py-2 text-center">
      <span className="grid size-12 place-items-center rounded-2xl bg-gradient-to-br from-[var(--nr-buyer)]/20 to-[var(--nr-supplier)]/20">
        <Scale className="size-6 text-[var(--nr-buyer)]" aria-hidden />
      </span>
      <h2 className="mt-3 text-[16px] font-semibold tracking-tight">Two agents, one deal, your rules</h2>
      <p className="mt-1 max-w-md text-[12.5px] text-muted-foreground">Your buyer agent trades price, payment terms, delivery and free cases with the supplier&apos;s agent — never outside your guardrails. Pick a deal and press Start.</p>
      <ul className="mt-4 grid w-full gap-2 text-left sm:grid-cols-3">
        {deals.map((d) => (
          <li key={d.id}>
            <button
              type="button"
              onClick={() => pickDeal(d.id)}
              aria-pressed={d.id === deal.id}
              className={cn("h-full w-full rounded-2xl border bg-card p-3 text-left transition hover:border-[var(--nr-buyer)]/50", d.id === deal.id && "border-[var(--nr-buyer)] ring-2 ring-[var(--nr-buyer)]/25", focusRing)}
            >
              <span className="flex items-center gap-2">
                <Avatar label={d.supplier.name} tint={d.supplier.tint} className="size-7 text-[10px]" />
                <span className="min-w-0 truncate text-[12px] font-semibold">{d.supplier.name}</span>
              </span>
              <span className="mt-2 block text-[13px] font-medium">{d.title}</span>
              <span className="mt-0.5 block text-[11.5px] text-muted-foreground">{d.subtitle}</span>
              <span className="mt-2 block text-[11.5px] tabular-nums">
                Last: <strong>{fmt.money(d.lastOrder.unitPrice)}</strong>/{d.unit}
              </span>
            </button>
          </li>
        ))}
      </ul>
      <button
        type="button"
        onClick={() => reset(true)}
        className={cn("mt-4 inline-flex h-11 items-center gap-2 rounded-xl bg-[var(--nr-buyer)] px-5 text-[13.5px] font-semibold text-white shadow-lg shadow-indigo-600/25 hover:brightness-110 dark:text-indigo-950", focusRing)}
      >
        <Play className="size-4" aria-hidden /> Start negotiation
      </button>
      <p className="mt-2 text-[11px] text-muted-foreground">
        <Kbd>Space</Kbd> start / pause · <Kbd>Enter</Kbd> accept offer · <Kbd>Esc</Kbd> walk away
      </p>
    </div>
  );

  const outcome =
    status === "agreed" && agreement ? (
      <motion.li key="outcome" initial={reduced ? false : { opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="flex justify-center pt-1">
        <button type="button" onClick={() => setContractOpen(true)} className={cn("inline-flex items-center gap-2 rounded-full border border-[var(--nr-zone)]/40 bg-[var(--nr-zone)]/10 px-4 py-2 text-[12.5px] font-semibold text-[var(--nr-zone)]", focusRing)}>
          <FileSignature className="size-4" aria-hidden /> View signed contract · {fmt.money(agreement.terms.unitPrice)}/{deal.unit}
        </button>
      </motion.li>
    ) : status === "walked" ? (
      <motion.li key="outcome" initial={reduced ? false : { opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="flex justify-center pt-1">
        <div className="max-w-md rounded-2xl border bg-card p-3.5 text-center text-[12.5px]">
          <p className="font-semibold">No deal this time.</p>
          <p className="mt-0.5 text-muted-foreground">
            {supplierOffer ? `Their last offer: ${fmt.money(effectivePrice(supplierOffer.terms, n))} vs your walk-away ${fmt.money(g.walkAwayPrice)}.` : "No offers were exchanged."} Adjust the guardrails and try again.
          </p>
        </div>
      </motion.li>
    ) : null;

  const control = (label: string, icon: React.ReactNode, onClick: () => void, opts: { disabled?: boolean; primary?: boolean; danger?: boolean; kbd?: string; pressed?: boolean; title?: string } = {}) => (
    <button
      type="button"
      onClick={onClick}
      disabled={opts.disabled}
      aria-pressed={opts.pressed}
      aria-keyshortcuts={opts.kbd}
      title={opts.title}
      className={cn(
        "inline-flex min-h-11 flex-col items-center justify-center gap-0.5 rounded-xl px-2 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 sm:min-h-10 sm:flex-row sm:gap-1.5 sm:px-3 sm:text-[12.5px]",
        opts.primary ? "bg-[var(--nr-buyer)] text-white shadow-md shadow-indigo-600/20 hover:brightness-110 dark:text-indigo-950" : opts.danger ? "border text-[var(--nr-bad)] hover:bg-[var(--nr-bad)]/10" : "border bg-background hover:bg-accent",
        opts.pressed && "border-[var(--nr-buyer)] bg-[var(--nr-buyer)]/10 text-[var(--nr-buyer)]",
        focusRing,
      )}
    >
      {icon}
      <span className="whitespace-nowrap">{label}</span>
    </button>
  );

  const controls = (
    <div className="shrink-0 border-t bg-background/95 px-2 py-2 backdrop-blur sm:px-4">
      {status === "idle" ? (
        <p className="py-2 text-center text-[12px] text-muted-foreground">Guardrails are editable until you start.</p>
      ) : status === "agreed" || status === "walked" ? (
        <div className="flex items-center justify-center gap-2">
          {status === "agreed" && control("View contract", <FileSignature className="size-4" aria-hidden />, () => setContractOpen(true), { primary: true })}
          {status === "walked" && !wide && control("Guardrails", <Sparkles className="size-4" aria-hidden />, () => setTab("guardrails"))}
          {control("Run again", <RotateCcw className="size-4" aria-hidden />, () => reset(true), { primary: status === "walked" })}
          {control("Reset", <Scale className="size-4" aria-hidden />, () => reset(false))}
        </div>
      ) : (
        <div className="grid grid-cols-5 gap-1.5 sm:flex sm:flex-wrap sm:items-center sm:justify-center">
          {control(status === "running" ? "Pause" : status === "stalled" ? "Waiting" : "Resume", status === "running" ? <Pause className="size-4" aria-hidden /> : <Play className="size-4" aria-hidden />, togglePause, { disabled: status === "stalled", kbd: "Space" })}
          {control("Concede terms", <Sparkles className="size-4" aria-hidden />, () => sendNudge("concede-terms"), {
            disabled: status === "stalled" || decisions.includes("concede-terms") || g.paymentDaysAsk <= g.paymentDaysMin,
            pressed: nudge === "concede-terms",
            title: `Offer ${g.paymentDaysMin}-day payment in exchange for price`,
          })}
          {control("Hold firm", <Hand className="size-4" aria-hidden />, () => sendNudge("hold-firm"), { disabled: status === "stalled" || !buyerOffer, pressed: nudge === "hold-firm", title: "Repeat the current price on the next turn" })}
          {control("Accept", <Check className="size-4" aria-hidden />, humanAccept, { disabled: !canAccept, primary: canAccept, kbd: "Enter", title: canAccept ? "Accept the supplier's current offer" : "The current offer is outside your guardrails" })}
          {control("Walk away", <LogOut className="size-4" aria-hidden />, () => setConfirmWalk(true), { danger: true, kbd: "Escape" })}
        </div>
      )}
    </div>
  );

  return (
    <MotionConfig reducedMotion="user">
      <div className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased", ROOT_VARS, className)}>
        <div className="flex min-h-0 flex-1 flex-col" inert={contractOpen || confirmWalk ? true : undefined}>
          {/* Header */}
          <header className="flex shrink-0 items-center gap-2 border-b px-3 py-2.5 sm:px-4">
            <RoomMark />
            <div className="min-w-0 flex-1 lg:flex-none">
              <p className="truncate text-[14px] font-semibold leading-tight">{title}</p>
              <p className="truncate text-[11px] text-muted-foreground">
                {deal.buyerName} buyer agent × {deal.supplier.name}
              </p>
            </div>
            <nav aria-label="Deals" className="mx-2 hidden min-w-0 flex-1 justify-center gap-1 lg:flex">
              {deals.map((d) => (
                <button
                  key={d.id}
                  type="button"
                  onClick={() => pickDeal(d.id)}
                  aria-current={d.id === deal.id ? "true" : undefined}
                  className={cn("flex h-9 min-w-0 items-center gap-2 rounded-xl px-2.5 text-[12.5px] font-medium transition", d.id === deal.id ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60", focusRing)}
                >
                  <Avatar label={d.supplier.name} tint={d.supplier.tint} className="size-5 text-[8px] ring-0" />
                  <span className="truncate">{d.title}</span>
                </button>
              ))}
            </nav>
            <span className={cn("hidden rounded-full px-2.5 py-1 text-[11px] font-semibold sm:inline-flex", statusPill[status].cls)} aria-live="polite">
              {statusPill[status].label}
              {rounds > 0 && <span className="ml-1 font-normal tabular-nums opacity-80">· R{Math.min(rounds, maxRounds)}/{maxRounds}</span>}
            </span>
            <button
              type="button"
              onClick={() => setSpeed((s) => (s === 1 ? 2 : 1))}
              aria-label={`Speed ${speed}×, switch to ${speed === 1 ? 2 : 1}×`}
              className={cn("inline-flex h-9 items-center gap-1 rounded-xl border px-2 text-[12px] font-semibold tabular-nums hover:bg-accent", focusRing)}
            >
              <Gauge className="size-4" aria-hidden /> {speed}×
            </button>
            {status === "idle" || status === "running" || status === "paused" ? (
              <button
                type="button"
                onClick={togglePause}
                aria-keyshortcuts="Space"
                className={cn("inline-flex h-9 items-center gap-1.5 rounded-xl bg-[var(--nr-buyer)] px-3 text-[12.5px] font-semibold text-white hover:brightness-110 dark:text-indigo-950", focusRing)}
              >
                {status === "running" ? <Pause className="size-4" aria-hidden /> : <Play className="size-4" aria-hidden />}
                <span className="hidden sm:inline">{status === "running" ? "Pause" : status === "paused" ? "Resume" : "Start"}</span>
                <span className="sr-only sm:hidden">{status === "running" ? "Pause" : status === "paused" ? "Resume" : "Start"}</span>
              </button>
            ) : null}
          </header>

          {/* Mobile deal switcher */}
          <nav aria-label="Deals" className="flex shrink-0 gap-1 overflow-x-auto border-b px-3 py-1.5 [scrollbar-width:none] lg:hidden">
            {deals.map((d) => (
              <button
                key={d.id}
                type="button"
                onClick={() => pickDeal(d.id)}
                aria-current={d.id === deal.id ? "true" : undefined}
                className={cn("flex h-8 shrink-0 items-center gap-1.5 rounded-full border px-2.5 text-[12px] font-medium", d.id === deal.id ? "border-[var(--nr-buyer)]/50 bg-[var(--nr-buyer)]/10 text-foreground" : "text-muted-foreground", focusRing)}
              >
                <Avatar label={d.supplier.name} tint={d.supplier.tint} className="size-4 text-[7px] ring-0" />
                {d.supplier.name}
              </button>
            ))}
          </nav>

          {/* Tug of war */}
          <section aria-label="Price positions" className="shrink-0 border-b bg-gradient-to-b from-muted/40 to-transparent pb-1 pt-3">
            <div className="mb-1 flex items-center justify-between px-3 text-[11px] text-muted-foreground sm:px-6">
              <span className="flex items-center gap-1.5">
                <span className="size-2 rounded-full bg-[var(--nr-buyer)]" /> Buyer bid
              </span>
              <span className="hidden sm:inline">effective price per {deal.unit} · incl. free cases</span>
              <span className={cn("rounded-full px-2 py-0.5 text-[10.5px] font-semibold sm:hidden", statusPill[status].cls)}>
                {statusPill[status].label}
                {rounds > 0 && <span className="ml-1 font-normal tabular-nums opacity-80">· R{Math.min(rounds, maxRounds)}</span>}
              </span>
              <span className="flex items-center gap-1.5">
                Supplier ask <span className="size-2 rounded-full bg-[var(--nr-supplier)]" />
              </span>
            </div>
            <TugOfWar
              deal={deal}
              guardrails={g}
              fmt={fmt}
              buyer={buyerOffer ? effectivePrice(buyerOffer.terms, n) : null}
              supplier={supplierOffer ? effectivePrice(supplierOffer.terms, n) : null}
              agreed={agreement ? effectivePrice(agreement.terms, n) : null}
              status={status}
              buyerLabel={wide ? "Lumen agent" : "Bid"}
              supplierLabel={wide ? deal.supplier.name.split(" ")[0] : "Ask"}
            />
          </section>

          {/* Mobile tabs */}
          {!wide && (
            <div role="tablist" aria-label="Panels" className="flex shrink-0 gap-1 border-b px-3 py-1.5">
              {(["thread", "guardrails", "terms"] as const).map((t) => (
                <button
                  key={t}
                  type="button"
                  role="tab"
                  id={`nr-tab-${t}`}
                  aria-selected={tab === t}
                  aria-controls={`nr-panel-${t}`}
                  onClick={() => setTab(t)}
                  className={cn("relative h-9 flex-1 rounded-lg text-[12.5px] font-medium capitalize", tab === t ? "text-foreground" : "text-muted-foreground", focusRing)}
                >
                  {tab === t && <motion.span layoutId="nr-tab" className="absolute inset-0 rounded-lg bg-accent" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
                  <span className="relative">{t === "terms" ? "Term sheet" : t}</span>
                  {t === "thread" && status === "stalled" && <span className="absolute right-2 top-2 size-2 rounded-full bg-[var(--nr-warn)]" aria-label="needs your decision" />}
                </button>
              ))}
            </div>
          )}

          <div className="flex min-h-0 flex-1">
            {wide && (
              <aside aria-label="Guardrails" className="w-[280px] shrink-0 overflow-y-auto border-r p-4">
                {guardPanel}
              </aside>
            )}
            <main className={cn("flex min-w-0 flex-1 flex-col", !wide && tab !== "thread" && "hidden")} id="nr-panel-thread" role={wide ? undefined : "tabpanel"} aria-labelledby={wide ? undefined : "nr-tab-thread"}>
              <OfferThread
                deal={deal}
                moves={moves}
                fmt={fmt}
                typing={typing}
                pendingQuestion={pending}
                error={error}
                payAsk={g.paymentDaysAsk}
                payMin={g.paymentDaysMin}
                onChoice={choose}
                onRetry={() => {
                  setError(null);
                  setRetry((r) => r + 1);
                }}
                empty={empty}
                footer={outcome}
              />
              {nudge && active && (
                <p className="mx-auto mb-1 rounded-full bg-[var(--nr-buyer)]/10 px-3 py-1 text-[11.5px] font-medium text-[var(--nr-buyer)]" role="status">
                  Nudge queued for the next buyer turn: {nudge === "hold-firm" ? "hold firm" : nudge === "final-offer" ? "final offer" : `concede to ${g.paymentDaysMin}-day terms`}
                </p>
              )}
              {controls}
            </main>
            {!wide && tab === "guardrails" && (
              <div id="nr-panel-guardrails" role="tabpanel" aria-labelledby="nr-tab-guardrails" className="min-h-0 flex-1 overflow-y-auto p-4">
                {guardPanel}
              </div>
            )}
            {!wide && tab === "terms" && (
              <div id="nr-panel-terms" role="tabpanel" aria-labelledby="nr-tab-terms" className="min-h-0 flex-1 overflow-y-auto p-4">
                {termSheet}
              </div>
            )}
            {wide && (
              <aside aria-label="Term sheet" className="w-[300px] shrink-0 overflow-y-auto border-l bg-muted/20 p-4">
                {termSheet}
              </aside>
            )}
          </div>
        </div>

        <AnimatePresence>
          {contractOpen && agreement && (
            <ContractDialog
              key="contract"
              agreement={agreement}
              fmt={fmt}
              poNumber={po}
              onClose={() => setContractOpen(false)}
              onSend={() => {
                const num = `PO-${today.slice(0, 4)}-${String(1000 + Math.round(agreement.total) % 9000).padStart(4, "0")}`;
                setPo(num);
                setAnnounce(`Purchase order ${num} sent to ${deal.supplier.name}.`);
                onSendPurchaseOrder?.(agreement, num);
              }}
            />
          )}
          {confirmWalk && <ConfirmWalk key="walk" supplier={deal.supplier.name} onCancel={() => setConfirmWalk(false)} onConfirm={humanWalk} />}
        </AnimatePresence>

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

function ConfirmWalk({ supplier, onCancel, onConfirm }: { supplier: string; onCancel: () => void; onConfirm: () => void }) {
  const ref = React.useRef<HTMLDivElement>(null);
  useDialog(true, ref, onCancel);
  return (
    <div className="absolute inset-0 z-50 grid place-items-center p-4">
      <motion.div className="absolute inset-0 bg-black/40" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onCancel} aria-hidden />
      <motion.div
        ref={ref}
        role="alertdialog"
        aria-modal="true"
        aria-labelledby="nr-walk-title"
        aria-describedby="nr-walk-desc"
        initial={{ opacity: 0, scale: 0.95 }}
        animate={{ opacity: 1, scale: 1 }}
        exit={{ opacity: 0, scale: 0.95 }}
        className="relative w-full max-w-sm rounded-2xl border bg-background p-5 shadow-2xl"
      >
        <h2 id="nr-walk-title" className="text-[15px] font-semibold">
          Walk away from {supplier}?
        </h2>
        <p id="nr-walk-desc" className="mt-1 text-[12.5px] text-muted-foreground">
          The buyer agent will end the negotiation now. You can run it again with different guardrails.
        </p>
        <div className="mt-4 flex justify-end gap-2">
          <button type="button" data-autofocus onClick={onCancel} className={cn("h-10 rounded-xl border px-4 text-[13px] font-medium", focusRing)}>
            Keep negotiating
          </button>
          <button type="button" onClick={onConfirm} className={cn("h-10 rounded-xl bg-[var(--nr-bad)] px-4 text-[13px] font-semibold text-white dark:text-rose-950", focusRing)}>
            Walk away
          </button>
        </div>
      </motion.div>
    </div>
  );
}

More in Business

View all →