@noodleseed/agent-kit 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/manifest.json +575 -5
- package/package.json +1 -1
- package/skills/claude-code/SKILL.md +7 -2
- package/skills/claude-code/examples/acme-bistro/README.md +51 -0
- package/skills/claude-code/examples/acme-bistro/design/UX-Document.md +435 -0
- package/skills/claude-code/examples/acme-bistro/design/api-contract.md +161 -0
- package/skills/claude-code/examples/acme-bistro/design/wireframe.html +573 -0
- package/skills/claude-code/examples/acme-bistro/noodle.json +5 -0
- package/skills/claude-code/examples/acme-bistro/package.json +20 -0
- package/skills/claude-code/examples/acme-bistro/src/helpers.ts +7 -0
- package/skills/claude-code/examples/acme-bistro/src/server.ts +149 -0
- package/skills/claude-code/examples/acme-bistro/src/views/menu-cart.tsx +159 -0
- package/skills/claude-code/examples/acme-bistro/src/views/widget-style.css +228 -0
- package/skills/claude-code/examples/acme-bistro/test/server.test.ts +21 -0
- package/skills/claude-code/examples/acme-bistro/vitest.config.ts +7 -0
- package/skills/claude-code/examples/acme-discovery/README.md +85 -0
- package/skills/claude-code/examples/acme-discovery/design/UX-Document.md +441 -0
- package/skills/claude-code/examples/acme-discovery/design/wireframe.html +670 -0
- package/skills/claude-code/examples/acme-discovery/noodle.json +5 -0
- package/skills/claude-code/examples/acme-discovery/package.json +20 -0
- package/skills/claude-code/examples/acme-discovery/src/helpers.ts +7 -0
- package/skills/claude-code/examples/acme-discovery/src/server.ts +202 -0
- package/skills/claude-code/examples/acme-discovery/src/views/discovery-carousel.tsx +155 -0
- package/skills/claude-code/examples/acme-discovery/src/views/widget-style.css +207 -0
- package/skills/claude-code/examples/acme-discovery/test/server.test.ts +24 -0
- package/skills/claude-code/examples/acme-discovery/vitest.config.ts +7 -0
- package/skills/claude-code/examples/acme-tasks/README.md +80 -0
- package/skills/claude-code/examples/acme-tasks/design/UX-Document.md +277 -0
- package/skills/claude-code/examples/acme-tasks/design/wireframe.html +603 -0
- package/skills/claude-code/examples/acme-tasks/noodle.json +5 -0
- package/skills/claude-code/examples/acme-tasks/package.json +20 -0
- package/skills/claude-code/examples/acme-tasks/src/helpers.ts +6 -0
- package/skills/claude-code/examples/acme-tasks/src/server.ts +141 -0
- package/skills/claude-code/examples/acme-tasks/src/views/task-list.tsx +177 -0
- package/skills/claude-code/examples/acme-tasks/src/views/widget-style.css +227 -0
- package/skills/claude-code/examples/acme-tasks/test/server.test.ts +22 -0
- package/skills/claude-code/examples/acme-tasks/vitest.config.ts +7 -0
- package/skills/claude-code/examples/food-ordering/README.md +82 -0
- package/skills/claude-code/examples/food-ordering/noodle.json +5 -0
- package/skills/claude-code/examples/food-ordering/package.json +22 -0
- package/skills/claude-code/examples/food-ordering/src/helpers.ts +34 -0
- package/skills/claude-code/examples/food-ordering/src/server.ts +412 -0
- package/skills/claude-code/examples/food-ordering/src/views/capabilities-card.tsx +155 -0
- package/skills/claude-code/examples/food-ordering/src/views/ordering-flow.tsx +659 -0
- package/skills/claude-code/examples/food-ordering/src/views/widget-style.css +682 -0
- package/skills/claude-code/examples/food-ordering/test/server.test.ts +58 -0
- package/skills/claude-code/examples/food-ordering/vitest.config.ts +30 -0
- package/skills/claude-code/examples/hello/README.md +13 -0
- package/skills/claude-code/examples/hello/noodle.json +5 -0
- package/skills/claude-code/examples/hello/package.json +16 -0
- package/skills/claude-code/examples/hello/src/server.ts +29 -0
- package/skills/claude-code/examples/hello/test/server.test.ts +8 -0
- package/skills/claude-code/examples/weather/README.md +54 -0
- package/skills/claude-code/examples/weather/noodle.json +4 -0
- package/skills/claude-code/examples/weather/package.json +16 -0
- package/skills/claude-code/examples/weather/src/server.ts +190 -0
- package/skills/claude-code/examples/weather/test/server.test.ts +8 -0
- package/skills/claude-code/references/chatgpt-compliance.md +63 -0
- package/skills/claude-code/references/connect-an-api.md +76 -0
- package/skills/claude-code/references/examples.md +16 -4
- package/skills/claude-code/references/experience-design.md +142 -0
- package/skills/codex/SKILL.md +7 -2
- package/skills/codex/examples/acme-bistro/README.md +51 -0
- package/skills/codex/examples/acme-bistro/design/UX-Document.md +435 -0
- package/skills/codex/examples/acme-bistro/design/api-contract.md +161 -0
- package/skills/codex/examples/acme-bistro/design/wireframe.html +573 -0
- package/skills/codex/examples/acme-bistro/noodle.json +5 -0
- package/skills/codex/examples/acme-bistro/package.json +20 -0
- package/skills/codex/examples/acme-bistro/src/helpers.ts +7 -0
- package/skills/codex/examples/acme-bistro/src/server.ts +149 -0
- package/skills/codex/examples/acme-bistro/src/views/menu-cart.tsx +159 -0
- package/skills/codex/examples/acme-bistro/src/views/widget-style.css +228 -0
- package/skills/codex/examples/acme-bistro/test/server.test.ts +21 -0
- package/skills/codex/examples/acme-bistro/vitest.config.ts +7 -0
- package/skills/codex/examples/acme-discovery/README.md +85 -0
- package/skills/codex/examples/acme-discovery/design/UX-Document.md +441 -0
- package/skills/codex/examples/acme-discovery/design/wireframe.html +670 -0
- package/skills/codex/examples/acme-discovery/noodle.json +5 -0
- package/skills/codex/examples/acme-discovery/package.json +20 -0
- package/skills/codex/examples/acme-discovery/src/helpers.ts +7 -0
- package/skills/codex/examples/acme-discovery/src/server.ts +202 -0
- package/skills/codex/examples/acme-discovery/src/views/discovery-carousel.tsx +155 -0
- package/skills/codex/examples/acme-discovery/src/views/widget-style.css +207 -0
- package/skills/codex/examples/acme-discovery/test/server.test.ts +24 -0
- package/skills/codex/examples/acme-discovery/vitest.config.ts +7 -0
- package/skills/codex/examples/acme-tasks/README.md +80 -0
- package/skills/codex/examples/acme-tasks/design/UX-Document.md +277 -0
- package/skills/codex/examples/acme-tasks/design/wireframe.html +603 -0
- package/skills/codex/examples/acme-tasks/noodle.json +5 -0
- package/skills/codex/examples/acme-tasks/package.json +20 -0
- package/skills/codex/examples/acme-tasks/src/helpers.ts +6 -0
- package/skills/codex/examples/acme-tasks/src/server.ts +141 -0
- package/skills/codex/examples/acme-tasks/src/views/task-list.tsx +177 -0
- package/skills/codex/examples/acme-tasks/src/views/widget-style.css +227 -0
- package/skills/codex/examples/acme-tasks/test/server.test.ts +22 -0
- package/skills/codex/examples/acme-tasks/vitest.config.ts +7 -0
- package/skills/codex/examples/food-ordering/README.md +82 -0
- package/skills/codex/examples/food-ordering/noodle.json +5 -0
- package/skills/codex/examples/food-ordering/package.json +22 -0
- package/skills/codex/examples/food-ordering/src/helpers.ts +34 -0
- package/skills/codex/examples/food-ordering/src/server.ts +412 -0
- package/skills/codex/examples/food-ordering/src/views/capabilities-card.tsx +155 -0
- package/skills/codex/examples/food-ordering/src/views/ordering-flow.tsx +659 -0
- package/skills/codex/examples/food-ordering/src/views/widget-style.css +682 -0
- package/skills/codex/examples/food-ordering/test/server.test.ts +58 -0
- package/skills/codex/examples/food-ordering/vitest.config.ts +30 -0
- package/skills/codex/examples/hello/README.md +13 -0
- package/skills/codex/examples/hello/noodle.json +5 -0
- package/skills/codex/examples/hello/package.json +16 -0
- package/skills/codex/examples/hello/src/server.ts +29 -0
- package/skills/codex/examples/hello/test/server.test.ts +8 -0
- package/skills/codex/examples/weather/README.md +54 -0
- package/skills/codex/examples/weather/noodle.json +4 -0
- package/skills/codex/examples/weather/package.json +16 -0
- package/skills/codex/examples/weather/src/server.ts +190 -0
- package/skills/codex/examples/weather/test/server.test.ts +8 -0
- package/skills/codex/references/chatgpt-compliance.md +63 -0
- package/skills/codex/references/connect-an-api.md +76 -0
- package/skills/codex/references/examples.md +16 -4
- package/skills/codex/references/experience-design.md +142 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActionBar,
|
|
4
|
+
AppShell,
|
|
5
|
+
AsyncBoundary,
|
|
6
|
+
ChoiceGroup,
|
|
7
|
+
createViewStore,
|
|
8
|
+
DataCard,
|
|
9
|
+
DataList,
|
|
10
|
+
Field,
|
|
11
|
+
HandoffButton,
|
|
12
|
+
QuantityStepper,
|
|
13
|
+
ShellNav,
|
|
14
|
+
StatusBadge,
|
|
15
|
+
SubmitButton,
|
|
16
|
+
useAppFlow,
|
|
17
|
+
useCallTool,
|
|
18
|
+
useHandoff,
|
|
19
|
+
useLayout,
|
|
20
|
+
useSendFollowUpMessage,
|
|
21
|
+
useToolInfo,
|
|
22
|
+
useViewState,
|
|
23
|
+
View,
|
|
24
|
+
ViewStack,
|
|
25
|
+
} from '../helpers.js';
|
|
26
|
+
import './widget-style.css';
|
|
27
|
+
|
|
28
|
+
type ViewName = 'stores' | 'menu' | 'item' | 'cart' | 'review' | 'handoff';
|
|
29
|
+
|
|
30
|
+
type Store = {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly cuisine: string;
|
|
34
|
+
readonly address: string;
|
|
35
|
+
readonly open: boolean;
|
|
36
|
+
readonly etaMinutes: number;
|
|
37
|
+
readonly rating: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type MenuItem = {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly storeId: string;
|
|
43
|
+
readonly category: string;
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly price: number;
|
|
46
|
+
readonly description: string;
|
|
47
|
+
readonly modifiers: readonly string[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type CartLine = {
|
|
51
|
+
readonly itemId: string;
|
|
52
|
+
readonly quantity: number;
|
|
53
|
+
readonly modifiers: readonly string[];
|
|
54
|
+
readonly note?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type CartState = {
|
|
58
|
+
readonly selectedStoreId?: string;
|
|
59
|
+
readonly lines: readonly CartLine[];
|
|
60
|
+
readonly customer: string;
|
|
61
|
+
readonly notes?: string;
|
|
62
|
+
readonly subtotal: number;
|
|
63
|
+
readonly status: 'draft' | 'review' | 'handoff';
|
|
64
|
+
readonly checkoutUrl?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const zeroCart: CartState = {
|
|
68
|
+
lines: [],
|
|
69
|
+
customer: 'Guest',
|
|
70
|
+
subtotal: 0,
|
|
71
|
+
status: 'draft',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const useCartStore = createViewStore<CartState>('cart', zeroCart);
|
|
75
|
+
const useCartRevisionStore = createViewStore('cart_revision', { value: 0 });
|
|
76
|
+
|
|
77
|
+
function structured<T>(value: unknown): T | undefined {
|
|
78
|
+
return (value as { structuredContent?: T } | undefined)?.structuredContent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function currency(value: number): string {
|
|
82
|
+
return `$${value.toFixed(2)}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function modifierLabel(value: string): string {
|
|
86
|
+
return value
|
|
87
|
+
.split('_')
|
|
88
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
89
|
+
.join(' ');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default function OrderingFlow() {
|
|
93
|
+
const { displayMode, theme } = useLayout();
|
|
94
|
+
const entry = structured<{
|
|
95
|
+
readonly customer?: string;
|
|
96
|
+
readonly stores?: readonly Store[];
|
|
97
|
+
readonly featuredItems?: readonly MenuItem[];
|
|
98
|
+
readonly status?: string;
|
|
99
|
+
}>(useToolInfo('open_ordering'));
|
|
100
|
+
const flow = useAppFlow<ViewName>({
|
|
101
|
+
key: 'ordering_flow',
|
|
102
|
+
initialView: 'stores',
|
|
103
|
+
views: ['stores', 'menu', 'item', 'cart', 'review', 'handoff'],
|
|
104
|
+
});
|
|
105
|
+
const view = flow.activeView;
|
|
106
|
+
const setView = flow.navigate;
|
|
107
|
+
const handoff = useHandoff();
|
|
108
|
+
const sendFollowUpMessage = useSendFollowUpMessage();
|
|
109
|
+
const searchStores = useCallTool('search_stores');
|
|
110
|
+
const loadMenu = useCallTool('load_menu');
|
|
111
|
+
const loadItem = useCallTool('load_item');
|
|
112
|
+
const readCart = useCallTool('read_cart');
|
|
113
|
+
const syncCart = useCallTool('sync_cart');
|
|
114
|
+
const prepareCheckout = useCallTool('prepare_checkout');
|
|
115
|
+
|
|
116
|
+
const [query, setQuery] = useViewState('query', '');
|
|
117
|
+
const [customer, setCustomer] = useViewState('customer', entry?.customer ?? 'Guest');
|
|
118
|
+
const [selectedStoreId, setSelectedStoreId] = useViewState<string | undefined>(
|
|
119
|
+
'selected_store',
|
|
120
|
+
entry?.stores?.[0]?.id,
|
|
121
|
+
);
|
|
122
|
+
const [selectedItemId, setSelectedItemId] = useViewState<string | undefined>(
|
|
123
|
+
'selected_item',
|
|
124
|
+
entry?.featuredItems?.[0]?.id,
|
|
125
|
+
);
|
|
126
|
+
const cartStore = useCartStore();
|
|
127
|
+
const revisionStore = useCartRevisionStore((state) => state.value);
|
|
128
|
+
const cart =
|
|
129
|
+
cartStore.state.customer === 'Guest' && entry?.customer
|
|
130
|
+
? { ...cartStore.state, customer: entry.customer }
|
|
131
|
+
: cartStore.state;
|
|
132
|
+
const revision = revisionStore.selected;
|
|
133
|
+
const setCart = cartStore.setState;
|
|
134
|
+
const setRevision = (value: number) => revisionStore.setState({ value });
|
|
135
|
+
const [quantity, setQuantity] = useState(1);
|
|
136
|
+
const [selectedModifiers, setSelectedModifiers] = useState<readonly string[]>([]);
|
|
137
|
+
|
|
138
|
+
const storeData = structured<{ readonly stores?: readonly Store[] }>(searchStores.data);
|
|
139
|
+
const menuData = structured<{
|
|
140
|
+
readonly stores?: readonly Store[];
|
|
141
|
+
readonly items?: readonly MenuItem[];
|
|
142
|
+
}>(loadMenu.data);
|
|
143
|
+
const itemData = structured<{ readonly items?: readonly MenuItem[] }>(loadItem.data);
|
|
144
|
+
const checkoutData = structured<{ readonly checkoutUrl?: string; readonly cart?: CartState }>(
|
|
145
|
+
prepareCheckout.data,
|
|
146
|
+
);
|
|
147
|
+
const stores = storeData?.stores ?? entry?.stores ?? [];
|
|
148
|
+
const queryText = query.trim().toLowerCase();
|
|
149
|
+
const displayedStores = stores.filter(
|
|
150
|
+
(store) =>
|
|
151
|
+
queryText.length === 0 ||
|
|
152
|
+
store.name.toLowerCase().includes(queryText) ||
|
|
153
|
+
store.cuisine.toLowerCase().includes(queryText),
|
|
154
|
+
);
|
|
155
|
+
const allItems = menuData?.items ?? itemData?.items ?? entry?.featuredItems ?? [];
|
|
156
|
+
const items = allItems.filter((item) => item.storeId === selectedStoreId);
|
|
157
|
+
const currentStore =
|
|
158
|
+
stores.find((store) => store.id === selectedStoreId) ?? menuData?.stores?.[0] ?? stores[0];
|
|
159
|
+
const currentItem = allItems.find((item) => item.id === selectedItemId) ?? items[0];
|
|
160
|
+
const lineItems = useMemo(
|
|
161
|
+
() =>
|
|
162
|
+
cart.lines.map((line) => ({
|
|
163
|
+
...line,
|
|
164
|
+
item: [...(entry?.featuredItems ?? []), ...allItems].find(
|
|
165
|
+
(item) => item.id === line.itemId,
|
|
166
|
+
),
|
|
167
|
+
})),
|
|
168
|
+
[cart.lines, entry?.featuredItems, allItems],
|
|
169
|
+
);
|
|
170
|
+
const llmSummary = `${view} view for ${customer}; ${cart.lines.length} cart lines; subtotal ${currency(
|
|
171
|
+
cart.subtotal,
|
|
172
|
+
)}`;
|
|
173
|
+
|
|
174
|
+
async function chooseStore(store: Store) {
|
|
175
|
+
setSelectedStoreId(store.id);
|
|
176
|
+
setView('menu');
|
|
177
|
+
const result = await loadMenu.callTool({ storeId: store.id });
|
|
178
|
+
const loaded = structured<{ readonly items?: readonly MenuItem[] }>(result);
|
|
179
|
+
setSelectedItemId(loaded?.items?.find((item) => item.storeId === store.id)?.id);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function chooseItem(item: MenuItem) {
|
|
183
|
+
setSelectedItemId(item.id);
|
|
184
|
+
setSelectedModifiers([]);
|
|
185
|
+
setQuantity(1);
|
|
186
|
+
setView('item');
|
|
187
|
+
await loadItem.callTool({ itemId: item.id });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function persistCart(nextCart: CartState, nextView: ViewName) {
|
|
191
|
+
const result = await syncCart.callTool({
|
|
192
|
+
selectedStoreId: nextCart.selectedStoreId,
|
|
193
|
+
lines: nextCart.lines,
|
|
194
|
+
customer: nextCart.customer,
|
|
195
|
+
notes: nextCart.notes,
|
|
196
|
+
subtotal: nextCart.subtotal,
|
|
197
|
+
expectedRevision: revision,
|
|
198
|
+
});
|
|
199
|
+
const synced = structured<{ readonly cart?: CartState; readonly revision?: number }>(result);
|
|
200
|
+
setCart(synced?.cart ?? nextCart);
|
|
201
|
+
setRevision(synced?.revision ?? revision + 1);
|
|
202
|
+
setView(nextView);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function addCurrentItem() {
|
|
206
|
+
if (!currentItem) return;
|
|
207
|
+
const nextLines = [
|
|
208
|
+
...cart.lines,
|
|
209
|
+
{
|
|
210
|
+
itemId: currentItem.id,
|
|
211
|
+
quantity,
|
|
212
|
+
modifiers: selectedModifiers,
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
await persistCart(
|
|
216
|
+
{
|
|
217
|
+
...cart,
|
|
218
|
+
selectedStoreId: currentStore?.id ?? selectedStoreId,
|
|
219
|
+
lines: nextLines,
|
|
220
|
+
customer,
|
|
221
|
+
subtotal: nextLines.reduce(
|
|
222
|
+
(sum, line) =>
|
|
223
|
+
sum +
|
|
224
|
+
([...(entry?.featuredItems ?? []), ...allItems].find((item) => item.id === line.itemId)
|
|
225
|
+
?.price ?? 0) *
|
|
226
|
+
line.quantity,
|
|
227
|
+
0,
|
|
228
|
+
),
|
|
229
|
+
status: 'draft',
|
|
230
|
+
},
|
|
231
|
+
'cart',
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function prepareHandoff() {
|
|
236
|
+
const result = await prepareCheckout.callTool({
|
|
237
|
+
selectedStoreId: cart.selectedStoreId,
|
|
238
|
+
lines: cart.lines,
|
|
239
|
+
customer,
|
|
240
|
+
notes: cart.notes,
|
|
241
|
+
subtotal: cart.subtotal,
|
|
242
|
+
expectedRevision: revision,
|
|
243
|
+
});
|
|
244
|
+
const prepared = structured<{
|
|
245
|
+
readonly cart?: CartState;
|
|
246
|
+
readonly revision?: number;
|
|
247
|
+
readonly checkoutUrl?: string;
|
|
248
|
+
}>(result);
|
|
249
|
+
setCart(prepared?.cart ?? cart);
|
|
250
|
+
setRevision(prepared?.revision ?? revision + 1);
|
|
251
|
+
setView('handoff');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<AppShell
|
|
256
|
+
className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}
|
|
257
|
+
data-llm={llmSummary}
|
|
258
|
+
title="Food Ordering"
|
|
259
|
+
subtitle={entry?.status ?? 'Find a store, build a cart, and hand off checkout.'}
|
|
260
|
+
icon={<BowlIcon />}
|
|
261
|
+
badge={displayMode === 'fullscreen' ? 'Fullscreen' : view}
|
|
262
|
+
footer={
|
|
263
|
+
<>
|
|
264
|
+
<span className="nw-meta">
|
|
265
|
+
<BagIcon />
|
|
266
|
+
{cart.lines.length} items
|
|
267
|
+
</span>
|
|
268
|
+
<span className="nw-meta">Revision {revision}</span>
|
|
269
|
+
</>
|
|
270
|
+
}
|
|
271
|
+
>
|
|
272
|
+
<ShellNav
|
|
273
|
+
activeView={view}
|
|
274
|
+
aria-label="Ordering steps"
|
|
275
|
+
items={(['stores', 'menu', 'cart', 'review', 'handoff'] as const).map((step) => ({
|
|
276
|
+
view: step,
|
|
277
|
+
label: step,
|
|
278
|
+
}))}
|
|
279
|
+
onNavigate={setView}
|
|
280
|
+
/>
|
|
281
|
+
<div className="nw-body">
|
|
282
|
+
<ViewStack flow={flow}>
|
|
283
|
+
<View name="stores">
|
|
284
|
+
<div className="nw-field-grid">
|
|
285
|
+
<Field className="nw-field" label="Customer">
|
|
286
|
+
<input
|
|
287
|
+
className="nw-input"
|
|
288
|
+
value={customer}
|
|
289
|
+
onChange={(event) => setCustomer(event.currentTarget.value)}
|
|
290
|
+
/>
|
|
291
|
+
</Field>
|
|
292
|
+
<Field className="nw-field" label="Search">
|
|
293
|
+
<input
|
|
294
|
+
className="nw-input"
|
|
295
|
+
value={query}
|
|
296
|
+
placeholder="Noodles"
|
|
297
|
+
onChange={(event) => setQuery(event.currentTarget.value)}
|
|
298
|
+
/>
|
|
299
|
+
</Field>
|
|
300
|
+
</div>
|
|
301
|
+
<ActionBar className="nw-actions">
|
|
302
|
+
<SubmitButton
|
|
303
|
+
className="nw-button nw-button-primary"
|
|
304
|
+
pending={searchStores.isPending}
|
|
305
|
+
pendingLabel="Searching..."
|
|
306
|
+
onClick={() => searchStores.callTool({ query, openOnly: false })}
|
|
307
|
+
>
|
|
308
|
+
<SearchIcon />
|
|
309
|
+
Search stores
|
|
310
|
+
</SubmitButton>
|
|
311
|
+
<SubmitButton
|
|
312
|
+
className="nw-button"
|
|
313
|
+
pending={readCart.isPending}
|
|
314
|
+
pendingLabel="Loading..."
|
|
315
|
+
onClick={async () => {
|
|
316
|
+
const result = await readCart.callTool({});
|
|
317
|
+
const stored = structured<{
|
|
318
|
+
readonly value?: CartState;
|
|
319
|
+
readonly revision?: number;
|
|
320
|
+
}>(result);
|
|
321
|
+
if (stored?.value?.lines) setCart(stored.value);
|
|
322
|
+
setRevision(stored?.revision ?? revision);
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
<RefreshIcon />
|
|
326
|
+
Load cart
|
|
327
|
+
</SubmitButton>
|
|
328
|
+
</ActionBar>
|
|
329
|
+
<AsyncBoundary
|
|
330
|
+
state={searchStores}
|
|
331
|
+
isEmpty={displayedStores.length === 0}
|
|
332
|
+
empty="No matching stores"
|
|
333
|
+
>
|
|
334
|
+
<StoreList stores={displayedStores} onChoose={chooseStore} />
|
|
335
|
+
</AsyncBoundary>
|
|
336
|
+
</View>
|
|
337
|
+
|
|
338
|
+
<View name="menu">
|
|
339
|
+
<SectionHeader title={currentStore?.name ?? 'Menu'} detail={currentStore?.address} />
|
|
340
|
+
<AsyncBoundary
|
|
341
|
+
state={loadMenu}
|
|
342
|
+
isEmpty={items.length === 0}
|
|
343
|
+
empty="No menu items loaded"
|
|
344
|
+
>
|
|
345
|
+
<ItemList items={items} onChoose={chooseItem} />
|
|
346
|
+
</AsyncBoundary>
|
|
347
|
+
</View>
|
|
348
|
+
|
|
349
|
+
<View name="item">
|
|
350
|
+
{currentItem ? (
|
|
351
|
+
<>
|
|
352
|
+
<SectionHeader title={currentItem.name} detail={currentItem.description} />
|
|
353
|
+
<div className="nw-field-grid">
|
|
354
|
+
<Field className="nw-field" label="Quantity">
|
|
355
|
+
<QuantityStepper value={quantity} min={1} onChange={setQuantity} />
|
|
356
|
+
</Field>
|
|
357
|
+
<div className="nw-summary nw-summary-compact">
|
|
358
|
+
<div className="nw-summary-row">
|
|
359
|
+
<dt>Price</dt>
|
|
360
|
+
<dd>{currency(currentItem.price)}</dd>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
<ChoiceGroup
|
|
365
|
+
className="nw-modifier-grid"
|
|
366
|
+
values={currentItem.modifiers}
|
|
367
|
+
selected={selectedModifiers}
|
|
368
|
+
onChange={setSelectedModifiers}
|
|
369
|
+
labelFor={modifierLabel}
|
|
370
|
+
/>
|
|
371
|
+
<ActionBar className="nw-actions">
|
|
372
|
+
<SubmitButton
|
|
373
|
+
className="nw-button nw-button-primary"
|
|
374
|
+
pending={syncCart.isPending}
|
|
375
|
+
pendingLabel="Adding..."
|
|
376
|
+
onClick={addCurrentItem}
|
|
377
|
+
>
|
|
378
|
+
<BagIcon />
|
|
379
|
+
Add to cart
|
|
380
|
+
</SubmitButton>
|
|
381
|
+
<button className="nw-button" type="button" onClick={() => setView('menu')}>
|
|
382
|
+
Back to menu
|
|
383
|
+
</button>
|
|
384
|
+
</ActionBar>
|
|
385
|
+
</>
|
|
386
|
+
) : null}
|
|
387
|
+
</View>
|
|
388
|
+
|
|
389
|
+
<View name="cart">
|
|
390
|
+
<SectionHeader
|
|
391
|
+
title="Cart"
|
|
392
|
+
detail={`${cart.lines.length} line${cart.lines.length === 1 ? '' : 's'}`}
|
|
393
|
+
/>
|
|
394
|
+
<CartSummary lineItems={lineItems} subtotal={cart.subtotal} />
|
|
395
|
+
<Field className="nw-field" label="Notes">
|
|
396
|
+
<input
|
|
397
|
+
className="nw-input"
|
|
398
|
+
value={cart.notes ?? ''}
|
|
399
|
+
placeholder="Utensils, pickup name, or allergies"
|
|
400
|
+
onChange={(event) => setCart({ ...cart, notes: event.currentTarget.value })}
|
|
401
|
+
/>
|
|
402
|
+
</Field>
|
|
403
|
+
<ActionBar className="nw-actions">
|
|
404
|
+
<SubmitButton
|
|
405
|
+
className="nw-button"
|
|
406
|
+
pending={syncCart.isPending}
|
|
407
|
+
pendingLabel="Saving..."
|
|
408
|
+
onClick={() => persistCart({ ...cart, customer, status: 'draft' }, 'review')}
|
|
409
|
+
>
|
|
410
|
+
<SaveIcon />
|
|
411
|
+
Save cart
|
|
412
|
+
</SubmitButton>
|
|
413
|
+
<SubmitButton
|
|
414
|
+
className="nw-button nw-button-primary"
|
|
415
|
+
disabled={cart.lines.length === 0}
|
|
416
|
+
pending={prepareCheckout.isPending}
|
|
417
|
+
pendingLabel="Preparing..."
|
|
418
|
+
onClick={prepareHandoff}
|
|
419
|
+
>
|
|
420
|
+
<ExternalIcon />
|
|
421
|
+
Prepare checkout
|
|
422
|
+
</SubmitButton>
|
|
423
|
+
</ActionBar>
|
|
424
|
+
</View>
|
|
425
|
+
|
|
426
|
+
<View name="review">
|
|
427
|
+
<SectionHeader
|
|
428
|
+
title="Review order"
|
|
429
|
+
detail={`${cart.lines.length} line${cart.lines.length === 1 ? '' : 's'}`}
|
|
430
|
+
/>
|
|
431
|
+
<CartSummary lineItems={lineItems} subtotal={cart.subtotal} />
|
|
432
|
+
<ActionBar className="nw-actions">
|
|
433
|
+
<button className="nw-button" type="button" onClick={() => setView('cart')}>
|
|
434
|
+
Edit cart
|
|
435
|
+
</button>
|
|
436
|
+
<SubmitButton
|
|
437
|
+
className="nw-button nw-button-primary"
|
|
438
|
+
disabled={cart.lines.length === 0}
|
|
439
|
+
pending={prepareCheckout.isPending}
|
|
440
|
+
pendingLabel="Preparing..."
|
|
441
|
+
onClick={prepareHandoff}
|
|
442
|
+
>
|
|
443
|
+
<ExternalIcon />
|
|
444
|
+
Prepare checkout
|
|
445
|
+
</SubmitButton>
|
|
446
|
+
</ActionBar>
|
|
447
|
+
</View>
|
|
448
|
+
|
|
449
|
+
<View name="handoff">
|
|
450
|
+
<SectionHeader
|
|
451
|
+
title="Checkout handoff"
|
|
452
|
+
detail="Payment stays with the ordering site."
|
|
453
|
+
/>
|
|
454
|
+
<CartSummary lineItems={lineItems} subtotal={cart.subtotal} />
|
|
455
|
+
<p className="nw-note">
|
|
456
|
+
The MCP App prepared a synthetic checkout URL and will open only the allowlisted
|
|
457
|
+
example domain.
|
|
458
|
+
</p>
|
|
459
|
+
<ActionBar className="nw-actions">
|
|
460
|
+
<HandoffButton
|
|
461
|
+
handoff={handoff}
|
|
462
|
+
target={checkoutData?.checkoutUrl ?? cart.checkoutUrl ?? ''}
|
|
463
|
+
className="nw-button nw-button-primary"
|
|
464
|
+
type="button"
|
|
465
|
+
pendingLabel="Opening..."
|
|
466
|
+
>
|
|
467
|
+
<ExternalIcon />
|
|
468
|
+
Continue checkout
|
|
469
|
+
</HandoffButton>
|
|
470
|
+
<button
|
|
471
|
+
className="nw-button"
|
|
472
|
+
type="button"
|
|
473
|
+
onClick={() =>
|
|
474
|
+
sendFollowUpMessage({
|
|
475
|
+
prompt: `Summarize my food order for ${customer} before checkout.`,
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
>
|
|
479
|
+
<SparkIcon />
|
|
480
|
+
Ask assistant
|
|
481
|
+
</button>
|
|
482
|
+
</ActionBar>
|
|
483
|
+
</View>
|
|
484
|
+
</ViewStack>
|
|
485
|
+
</div>
|
|
486
|
+
</AppShell>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function SectionHeader({ title, detail }: { readonly title: string; readonly detail?: string }) {
|
|
491
|
+
return (
|
|
492
|
+
<div className="nw-section-head">
|
|
493
|
+
<p className="nw-section-title">{title}</p>
|
|
494
|
+
{detail ? <p className="nw-section-detail">{detail}</p> : null}
|
|
495
|
+
</div>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function StoreList({
|
|
500
|
+
stores,
|
|
501
|
+
onChoose,
|
|
502
|
+
}: {
|
|
503
|
+
readonly stores: readonly Store[];
|
|
504
|
+
readonly onChoose: (store: Store) => void;
|
|
505
|
+
}) {
|
|
506
|
+
return (
|
|
507
|
+
<DataList className="nw-menu-list">
|
|
508
|
+
{stores.map((store) => (
|
|
509
|
+
<DataCard
|
|
510
|
+
as="button"
|
|
511
|
+
className="nw-menu-item"
|
|
512
|
+
key={store.id}
|
|
513
|
+
type="button"
|
|
514
|
+
onClick={() => onChoose(store)}
|
|
515
|
+
>
|
|
516
|
+
<span>
|
|
517
|
+
<span className="nw-menu-name">{store.name}</span>
|
|
518
|
+
<span className="nw-menu-desc">
|
|
519
|
+
{store.cuisine} · {store.address}
|
|
520
|
+
</span>
|
|
521
|
+
</span>
|
|
522
|
+
<StatusBadge className="nw-status" tone={store.open ? 'success' : 'neutral'}>
|
|
523
|
+
{store.open ? `${store.etaMinutes}m` : 'Closed'}
|
|
524
|
+
</StatusBadge>
|
|
525
|
+
</DataCard>
|
|
526
|
+
))}
|
|
527
|
+
</DataList>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function ItemList({
|
|
532
|
+
items,
|
|
533
|
+
onChoose,
|
|
534
|
+
}: {
|
|
535
|
+
readonly items: readonly MenuItem[];
|
|
536
|
+
readonly onChoose: (item: MenuItem) => void;
|
|
537
|
+
}) {
|
|
538
|
+
return (
|
|
539
|
+
<DataList className="nw-menu-list">
|
|
540
|
+
{items.map((item) => (
|
|
541
|
+
<DataCard
|
|
542
|
+
as="button"
|
|
543
|
+
className="nw-menu-item"
|
|
544
|
+
key={item.id}
|
|
545
|
+
type="button"
|
|
546
|
+
onClick={() => onChoose(item)}
|
|
547
|
+
>
|
|
548
|
+
<span>
|
|
549
|
+
<span className="nw-menu-name">{item.name}</span>
|
|
550
|
+
<span className="nw-menu-desc">{item.description}</span>
|
|
551
|
+
</span>
|
|
552
|
+
<span className="nw-price">{currency(item.price)}</span>
|
|
553
|
+
</DataCard>
|
|
554
|
+
))}
|
|
555
|
+
</DataList>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function CartSummary({
|
|
560
|
+
lineItems,
|
|
561
|
+
subtotal,
|
|
562
|
+
}: {
|
|
563
|
+
readonly lineItems: readonly (CartLine & { readonly item?: MenuItem })[];
|
|
564
|
+
readonly subtotal: number;
|
|
565
|
+
}) {
|
|
566
|
+
return (
|
|
567
|
+
<dl className="nw-summary">
|
|
568
|
+
{lineItems.length === 0 ? (
|
|
569
|
+
<div className="nw-summary-row">
|
|
570
|
+
<dt>No items yet</dt>
|
|
571
|
+
<dd>{currency(0)}</dd>
|
|
572
|
+
</div>
|
|
573
|
+
) : (
|
|
574
|
+
lineItems.map((line, index) => (
|
|
575
|
+
<div className="nw-summary-row" key={`${line.itemId}-${index}`}>
|
|
576
|
+
<dt>
|
|
577
|
+
{line.quantity} x {line.item?.name ?? line.itemId}
|
|
578
|
+
{line.modifiers.length > 0 ? (
|
|
579
|
+
<span className="nw-line-note">{line.modifiers.map(modifierLabel).join(', ')}</span>
|
|
580
|
+
) : null}
|
|
581
|
+
</dt>
|
|
582
|
+
<dd>{currency((line.item?.price ?? 0) * line.quantity)}</dd>
|
|
583
|
+
</div>
|
|
584
|
+
))
|
|
585
|
+
)}
|
|
586
|
+
<div className="nw-summary-row nw-total">
|
|
587
|
+
<dt>Subtotal</dt>
|
|
588
|
+
<dd>{currency(subtotal)}</dd>
|
|
589
|
+
</div>
|
|
590
|
+
</dl>
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function BowlIcon() {
|
|
595
|
+
return (
|
|
596
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
597
|
+
<path d="M4 11h16a7 7 0 0 1-7 7h-2a7 7 0 0 1-7-7Z" />
|
|
598
|
+
<path d="M7 21h10" />
|
|
599
|
+
<path d="M8 6c0-1 1-1 1-2" />
|
|
600
|
+
<path d="M12 6c0-1 1-1 1-2" />
|
|
601
|
+
<path d="M16 6c0-1 1-1 1-2" />
|
|
602
|
+
</svg>
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function SearchIcon() {
|
|
607
|
+
return (
|
|
608
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
609
|
+
<circle cx="11" cy="11" r="7" />
|
|
610
|
+
<path d="m20 20-4-4" />
|
|
611
|
+
</svg>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function RefreshIcon() {
|
|
616
|
+
return (
|
|
617
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
618
|
+
<path d="M20 12a8 8 0 1 1-2.3-5.6" />
|
|
619
|
+
<path d="M20 4v5h-5" />
|
|
620
|
+
</svg>
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function BagIcon() {
|
|
625
|
+
return (
|
|
626
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
627
|
+
<path d="M6 8h12l-1 12H7L6 8Z" />
|
|
628
|
+
<path d="M9 8a3 3 0 0 1 6 0" />
|
|
629
|
+
</svg>
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function SaveIcon() {
|
|
634
|
+
return (
|
|
635
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
636
|
+
<path d="M5 4h12l2 2v14H5V4Z" />
|
|
637
|
+
<path d="M8 4v6h8" />
|
|
638
|
+
<path d="M8 20v-5h8v5" />
|
|
639
|
+
</svg>
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function ExternalIcon() {
|
|
644
|
+
return (
|
|
645
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
646
|
+
<path d="M14 4h6v6" />
|
|
647
|
+
<path d="m20 4-9 9" />
|
|
648
|
+
<path d="M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5" />
|
|
649
|
+
</svg>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function SparkIcon() {
|
|
654
|
+
return (
|
|
655
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
656
|
+
<path d="m12 3 1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z" />
|
|
657
|
+
</svg>
|
|
658
|
+
);
|
|
659
|
+
}
|