mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
refactor(desktop): simplify market channel listing
Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -1,49 +1,41 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
CircleDollarSign,
|
||||
Clock3,
|
||||
LockKeyhole,
|
||||
MessageSquareOff,
|
||||
PackageCheck,
|
||||
Radio,
|
||||
ReceiptText,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
Store,
|
||||
TriangleAlert,
|
||||
WalletCards,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import {
|
||||
MARKET_SCENARIOS,
|
||||
MARKET_SCENARIO_IDS,
|
||||
type MarketActivity,
|
||||
type MarketScenario,
|
||||
type MarketScenarioId,
|
||||
type MarketTerm,
|
||||
} from "@/features/market/lib/marketPrototypeData";
|
||||
import { ProjectContextRail } from "@/features/projects/ui/ProjectContextRail";
|
||||
import { ProjectHomeColumn } from "@/features/projects/ui/ProjectHomeColumn";
|
||||
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
|
||||
import { SIDEBAR_WIDTH_MIN } from "@/shared/layout/sidebarLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { useOptionalSidebar } from "@/shared/ui/sidebar";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
const MARKET_CONTEXT_WIDTH_KEY = "buzz.desktop.market-context-width";
|
||||
|
||||
const SCENARIO_LABELS: Record<MarketScenarioId, string> = {
|
||||
finite: "Fixed · finite",
|
||||
unlimited: "Fixed · unlimited",
|
||||
auction: "Auction",
|
||||
tender: "Tender",
|
||||
awarded: "Awarded edge",
|
||||
};
|
||||
const REPRESENTING_AGENTS = [
|
||||
{ name: "Forensic Finch", role: "Research & incident analysis" },
|
||||
{ name: "Cartograph", role: "Repository mapping" },
|
||||
{ name: "Sentinel", role: "Operations & safety" },
|
||||
] as const;
|
||||
|
||||
const COMMERCIAL_TERM_LABELS = new Set([
|
||||
"Price",
|
||||
@@ -55,37 +47,6 @@ const COMMERCIAL_TERM_LABELS = new Set([
|
||||
"Award count",
|
||||
]);
|
||||
|
||||
const WALLET_DETAILS: Record<
|
||||
MarketScenarioId,
|
||||
{ account: string; balance: string; settlement: string }
|
||||
> = {
|
||||
finite: {
|
||||
account: "escrow1report…13c8",
|
||||
balance: "150 sats funded",
|
||||
settlement: "1 paid · 2 reserved",
|
||||
},
|
||||
unlimited: {
|
||||
account: "wallet1mapper…c241",
|
||||
balance: "80 sats escrowed",
|
||||
settlement: "760 sats paid",
|
||||
},
|
||||
auction: {
|
||||
account: "escrow1strings…9f10",
|
||||
balance: "600 sats funded",
|
||||
settlement: "Held until award",
|
||||
},
|
||||
tender: {
|
||||
account: "escrow1tender…1b73",
|
||||
balance: "2,000 sats funded",
|
||||
settlement: "Held during selection",
|
||||
},
|
||||
awarded: {
|
||||
account: "escrow1tender…1b73",
|
||||
balance: "1,750 sats escrowed",
|
||||
settlement: "Release on signed receipt",
|
||||
},
|
||||
};
|
||||
|
||||
const ACTIVITY_STYLE: Record<
|
||||
MarketActivity["state"],
|
||||
{
|
||||
@@ -113,26 +74,9 @@ const ACTIVITY_STYLE: Record<
|
||||
|
||||
export function MarketScreen({ scenarioId }: { scenarioId: MarketScenarioId }) {
|
||||
const scenario = MARKET_SCENARIOS[scenarioId];
|
||||
const navigate = useNavigate();
|
||||
const sidebar = useOptionalSidebar();
|
||||
const contextWidth = useThreadPanelWidth(undefined, {
|
||||
minWidthPx: SIDEBAR_WIDTH_MIN,
|
||||
sessionKey: MARKET_CONTEXT_WIDTH_KEY,
|
||||
});
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
const isTerminal = scenario.status !== "Open";
|
||||
const contractTerms = scenario.terms.filter(
|
||||
(term) => !COMMERCIAL_TERM_LABELS.has(term.label),
|
||||
);
|
||||
const commercialTerms = scenario.terms.filter((term) =>
|
||||
COMMERCIAL_TERM_LABELS.has(term.label),
|
||||
);
|
||||
|
||||
const selectScenario = React.useCallback(
|
||||
(nextScenario: MarketScenarioId) => {
|
||||
void navigate({ to: "/market", search: { scenario: nextScenario } });
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -145,56 +89,17 @@ export function MarketScreen({ scenarioId }: { scenarioId: MarketScenarioId }) {
|
||||
>
|
||||
<section className="mb-2 ml-px mt-px flex min-h-0 min-w-60 flex-1 flex-col overflow-hidden rounded-2xl bg-background">
|
||||
<ChatHeader
|
||||
actions={
|
||||
<span className="hidden items-center gap-1.5 text-xs text-muted-foreground lg:flex">
|
||||
<Scale className="h-3.5 w-3.5" /> Agents participate · Humans
|
||||
observe
|
||||
</span>
|
||||
}
|
||||
channelType="stream"
|
||||
description={scenario.summary}
|
||||
leadingContent={<Store className="h-4 w-4 text-muted-foreground" />}
|
||||
statusBadge={<StatusBadge status={scenario.status} />}
|
||||
title={scenario.title}
|
||||
/>
|
||||
|
||||
<nav
|
||||
className="flex shrink-0 items-center gap-1 border-b bg-muted/20 px-6 py-2"
|
||||
aria-label="Market prototype scenarios"
|
||||
>
|
||||
{MARKET_SCENARIO_IDS.map((id) => (
|
||||
<Button
|
||||
className="h-8 rounded-lg"
|
||||
key={id}
|
||||
onClick={() => selectScenario(id)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={id === scenarioId ? "secondary" : "ghost"}
|
||||
>
|
||||
{SCENARIO_LABELS[id]}
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{scenario.eyebrow}</Badge>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{scenario.contractId}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-muted-foreground">
|
||||
{scenario.summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col px-6 py-5">
|
||||
<ContractCard
|
||||
closeAt={scenario.closeAt}
|
||||
mode={scenario.mode}
|
||||
terms={contractTerms}
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col px-6 py-6">
|
||||
<OfferCard
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
scenario={scenario}
|
||||
/>
|
||||
|
||||
<section className="pt-5" data-testid="market-agent-timeline">
|
||||
@@ -222,6 +127,12 @@ export function MarketScreen({ scenarioId }: { scenarioId: MarketScenarioId }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateMarketDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
scenario={scenario}
|
||||
/>
|
||||
|
||||
<footer className="shrink-0 border-t bg-background px-5 py-3">
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 rounded-xl border border-dashed bg-muted/35 px-4 py-3">
|
||||
<MessageSquareOff className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
@@ -243,238 +154,231 @@ export function MarketScreen({ scenarioId }: { scenarioId: MarketScenarioId }) {
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<ProjectContextRail
|
||||
open
|
||||
panelWidthPx={contextWidth.widthPx}
|
||||
resizing={contextWidth.isResizing}
|
||||
rounded={false}
|
||||
testId="market-context-rail"
|
||||
>
|
||||
<ProjectHomeColumn
|
||||
bodyClassName="overflow-hidden"
|
||||
canResetWidth={contextWidth.canReset}
|
||||
onResetWidth={contextWidth.onResetWidth}
|
||||
onResizeStart={contextWidth.onResizeStart}
|
||||
testId="market-context-column"
|
||||
widthPx={contextWidth.widthPx}
|
||||
>
|
||||
<MarketContextPanel
|
||||
commercialTerms={commercialTerms}
|
||||
direction={scenario.direction}
|
||||
liveMetrics={scenario.liveMetrics}
|
||||
scenarioId={scenarioId}
|
||||
status={scenario.status}
|
||||
statusDetail={scenario.statusDetail}
|
||||
/>
|
||||
</ProjectHomeColumn>
|
||||
</ProjectContextRail>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ContractCard({
|
||||
closeAt,
|
||||
mode,
|
||||
terms,
|
||||
function OfferCard({
|
||||
onCreate,
|
||||
scenario,
|
||||
}: {
|
||||
closeAt: string;
|
||||
mode: string;
|
||||
terms: MarketTerm[];
|
||||
onCreate: () => void;
|
||||
scenario: MarketScenario;
|
||||
}) {
|
||||
const author =
|
||||
scenario.terms.find(
|
||||
(term) => term.label === "Seller" || term.label === "Requester",
|
||||
)?.value ?? "Market agent";
|
||||
const commercial = scenario.terms.find((term) =>
|
||||
COMMERCIAL_TERM_LABELS.has(term.label),
|
||||
);
|
||||
const listingKind = scenario.eyebrow.startsWith("Offer")
|
||||
? "Agent offer"
|
||||
: "Agent request";
|
||||
|
||||
return (
|
||||
<section className="border-b pb-5">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LockKeyhole className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="font-semibold">Market contract</h2>
|
||||
<section
|
||||
className="overflow-hidden rounded-2xl border bg-card"
|
||||
data-testid="market-offer-card"
|
||||
>
|
||||
<div className="grid sm:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<div className="flex min-h-44 items-center justify-center border-b bg-muted/60 text-muted-foreground sm:border-b-0 sm:border-r">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<PackageCheck className="h-8 w-8" strokeWidth={1.5} />
|
||||
<span className="text-xs">Product image</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Immutable signed scope · material changes require v2
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock3 className="h-3.5 w-3.5" /> {closeAt}
|
||||
</span>
|
||||
<Badge variant="outline">v1 locked</Badge>
|
||||
<div className="flex min-w-0 flex-col p-5">
|
||||
<Badge className="w-fit" variant="secondary">
|
||||
{listingKind} · {scenario.mode}
|
||||
</Badge>
|
||||
<h2 className="mt-3 text-xl font-semibold tracking-tight">
|
||||
{scenario.title}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{scenario.summary}
|
||||
</p>
|
||||
<div className="mt-4 rounded-xl bg-muted/60 px-3 py-2.5">
|
||||
<p className="text-sm font-semibold">
|
||||
{commercial?.value ?? scenario.direction}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{scenario.direction}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<UserAvatar
|
||||
accent
|
||||
avatarUrl={null}
|
||||
className="rounded-[30%] grayscale"
|
||||
displayName={author}
|
||||
fallbackDelayMs={0}
|
||||
shape="squircle"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{author}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created this {listingKind.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={onCreate} size="sm" variant="outline">
|
||||
Create one like this
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="grid gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
<TermRows terms={[{ label: "Mode", value: mode }, ...terms]} />
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketContextPanel({
|
||||
commercialTerms,
|
||||
direction,
|
||||
liveMetrics,
|
||||
scenarioId,
|
||||
status,
|
||||
statusDetail,
|
||||
function CreateMarketDialog({
|
||||
onOpenChange,
|
||||
open,
|
||||
scenario,
|
||||
}: {
|
||||
commercialTerms: MarketTerm[];
|
||||
direction: string;
|
||||
liveMetrics: MarketTerm[];
|
||||
scenarioId: MarketScenarioId;
|
||||
status: "Open" | "Closed" | "Awarded" | "Fulfilled";
|
||||
statusDetail: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
scenario: MarketScenario;
|
||||
}) {
|
||||
const wallet = WALLET_DETAILS[scenarioId];
|
||||
const [selectedAgent, setSelectedAgent] = React.useState<string>(
|
||||
REPRESENTING_AGENTS[0].name,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-sidebar text-sidebar-foreground"
|
||||
data-testid="market-context-panel"
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-3 pb-8 pt-5">
|
||||
<div className="flex h-8 items-center justify-between gap-2 px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ReceiptText className="h-4 w-4 text-sidebar-foreground/65" />
|
||||
<h2 className="text-sm font-semibold">Market context</h2>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<ContextSection icon={CircleDollarSign} title="Commercial terms">
|
||||
<p className="rounded-xl border border-sidebar-border bg-sidebar-accent p-3 text-sm font-medium leading-relaxed">
|
||||
{direction}
|
||||
</p>
|
||||
<dl className="space-y-3 pt-1">
|
||||
<TermRows terms={commercialTerms} />
|
||||
</dl>
|
||||
</ContextSection>
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="max-h-[88vh] max-w-xl overflow-y-auto"
|
||||
data-testid="create-market-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a market</DialogTitle>
|
||||
<DialogDescription>
|
||||
Publish an offer or request through an agent representing you.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ContextSection icon={Radio} title="Live market state">
|
||||
<dl className="grid grid-cols-2 gap-2">
|
||||
{liveMetrics.map((term) => (
|
||||
<div
|
||||
className="rounded-xl border bg-background/70 p-3"
|
||||
key={term.label}
|
||||
<div className="space-y-5">
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">Market type</legend>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
className="rounded-xl border-2 border-foreground bg-muted px-3 py-3 text-left"
|
||||
type="button"
|
||||
>
|
||||
<dt className="text-xs text-muted-foreground">{term.label}</dt>
|
||||
<dd className="mt-0.5 text-base font-semibold tracking-tight">
|
||||
{term.value}
|
||||
</dd>
|
||||
{term.detail ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{term.detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-sidebar-border bg-sidebar-accent p-3 text-xs">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-sidebar-foreground/65" />
|
||||
<span>
|
||||
{statusDetail}. Client timers never determine acceptance.
|
||||
</span>
|
||||
</div>
|
||||
</ContextSection>
|
||||
<span className="block text-sm font-medium">Offer</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Sell a product or service
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl border px-3 py-3 text-left"
|
||||
type="button"
|
||||
>
|
||||
<span className="block text-sm font-medium">Request</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Ask agents to deliver something
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<ContextSection icon={WalletCards} title="Wallet & settlement">
|
||||
<dl className="space-y-3 rounded-xl border bg-background/70 p-3">
|
||||
<ContextValue label="Contract wallet" value={wallet.account} mono />
|
||||
<ContextValue label="Balance" value={wallet.balance} />
|
||||
<ContextValue label="Settlement" value={wallet.settlement} />
|
||||
<ContextValue label="Network" value="Sandbox Lightning" />
|
||||
</dl>
|
||||
</ContextSection>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Title">
|
||||
<Input defaultValue={scenario.title} />
|
||||
</Field>
|
||||
<Field label="Price or reward">
|
||||
<Input defaultValue="50 sats" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Short description">
|
||||
<Textarea defaultValue={scenario.summary} />
|
||||
</Field>
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed bg-muted/30 px-4 py-5 text-sm text-muted-foreground"
|
||||
type="button"
|
||||
>
|
||||
<PackageCheck className="h-5 w-5" /> Add product image
|
||||
</button>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
Agent representing you
|
||||
</legend>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This agent creates the market and handles responses on your
|
||||
behalf.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{REPRESENTING_AGENTS.map((agent) => {
|
||||
const selected = selectedAgent === agent.name;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left",
|
||||
selected && "border-foreground bg-muted",
|
||||
)}
|
||||
key={agent.name}
|
||||
onClick={() => setSelectedAgent(agent.name)}
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
accent
|
||||
avatarUrl={null}
|
||||
className="rounded-[30%] grayscale"
|
||||
displayName={agent.name}
|
||||
fallbackDelayMs={0}
|
||||
shape="squircle"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">
|
||||
{agent.name}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{agent.role}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-4 w-4 rounded-full border",
|
||||
selected && "border-4 border-foreground",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button>Ask {selectedAgent} to publish</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextSection({
|
||||
function Field({
|
||||
children,
|
||||
icon: Icon,
|
||||
title,
|
||||
label,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Icon className="h-4 w-4 text-sidebar-foreground/65" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-sidebar-foreground/65">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextValue({
|
||||
label,
|
||||
mono = false,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
mono?: boolean;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"text-right text-sm font-medium",
|
||||
mono && "font-mono text-xs",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
<div className="space-y-2 text-sm font-medium">
|
||||
<p>{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TermRows({ terms }: { terms: MarketTerm[] }) {
|
||||
return terms.map((term) => (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
term.label === "Public criteria" && "sm:col-span-2",
|
||||
)}
|
||||
key={term.label}
|
||||
>
|
||||
<dt className="text-xs font-medium text-muted-foreground">
|
||||
{term.label}
|
||||
</dt>
|
||||
<dd className="mt-0.5 text-sm font-medium leading-relaxed">
|
||||
{term.value}
|
||||
</dd>
|
||||
{term.detail ? (
|
||||
<p className="text-xs text-muted-foreground">{term.detail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: "Open" | "Closed" | "Awarded" | "Fulfilled";
|
||||
}) {
|
||||
const terminal = status !== "Open";
|
||||
return (
|
||||
<Badge
|
||||
className="border-sidebar-border bg-sidebar-accent text-sidebar-foreground"
|
||||
variant="outline"
|
||||
>
|
||||
{terminal ? (
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<Radio className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentMessage({ activity }: { activity: MarketActivity }) {
|
||||
const style = ACTIVITY_STYLE[activity.state];
|
||||
const Icon = style.icon;
|
||||
|
||||
@@ -5,15 +5,15 @@ import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/market-prototype";
|
||||
const scenarios = [
|
||||
["finite", "01-fixed-finite.png", "7 of 10"],
|
||||
["unlimited", "02-fixed-unlimited.png", "Unlimited"],
|
||||
["auction", "03-auction.png", "430 sats"],
|
||||
["tender", "04-sealed-tender.png", "3"],
|
||||
["awarded", "05-awarded-edge.png", "Awarded"],
|
||||
["finite", "01-fixed-finite.png"],
|
||||
["unlimited", "02-fixed-unlimited.png"],
|
||||
["auction", "03-auction.png"],
|
||||
["tender", "04-sealed-tender.png"],
|
||||
["awarded", "05-awarded-edge.png"],
|
||||
] as const;
|
||||
|
||||
test.describe("market channel prototype", () => {
|
||||
for (const [scenario, fileName, expectedMetric] of scenarios) {
|
||||
for (const [scenario, fileName] of scenarios) {
|
||||
test(`${scenario} market state`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await installMockBridge(page);
|
||||
@@ -36,9 +36,6 @@ test.describe("market channel prototype", () => {
|
||||
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||
borderTopLeftRadius: "0px",
|
||||
});
|
||||
await expect(
|
||||
market.getByText("Agents participate · Humans observe"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(
|
||||
scenario === "finite"
|
||||
? "Incident pattern report"
|
||||
@@ -48,16 +45,11 @@ test.describe("market channel prototype", () => {
|
||||
? "Translate support strings"
|
||||
: "Design a relay abuse-response playbook",
|
||||
);
|
||||
await expect(market.getByText("Market contract")).toBeVisible();
|
||||
await expect(market.getByTestId("market-offer-card")).toBeVisible();
|
||||
await expect(market.getByText("Agent market channel")).toBeVisible();
|
||||
await expect(market.getByTestId("market-agent-avatar")).toHaveCount(
|
||||
scenario === "unlimited" ? 3 : 4,
|
||||
);
|
||||
await expect(market.getByTestId("market-context-panel")).toBeVisible();
|
||||
await expect(market.getByText("Live market state")).toBeVisible();
|
||||
await expect(
|
||||
market.getByText(expectedMetric, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("open-market-view")).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
@@ -73,3 +65,26 @@ test.describe("market channel prototype", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("create market modal", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await installMockBridge(page);
|
||||
await page.goto("/#/market?scenario=finite", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Create one like this" }).click();
|
||||
const dialog = page.getByTestId("create-market-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByText("Agent representing you", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByText("Forensic Finch", { exact: true }),
|
||||
).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
fullPage: true,
|
||||
path: `${SHOTS}/06-create-market-modal.png`,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user