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