@womp/mcp-ui 0.1.15 → 0.1.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@womp/mcp-ui",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "MCP Apps widgets for Womp's MCP server — interactive cards rendered inline in Claude and other MCP hosts.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "scripts": {
17
17
  "build": "yarn build:ui && yarn build:bake",
18
- "build:ui": "yarn build:ui:image && yarn build:ui:mesh && yarn build:ui:print && yarn build:ui:cart && yarn build:ui:order && yarn build:ui:upload && yarn build:ui:openai-camera",
18
+ "build:ui": "yarn build:ui:image && yarn build:ui:mesh && yarn build:ui:print && yarn build:ui:cart && yarn build:ui:order && yarn build:ui:upload && yarn build:ui:openai-camera && yarn build:ui:openai-quote",
19
19
  "build:bake": "node scripts/bake-html.mjs",
20
20
  "dev": "vite",
21
21
  "dev:host": "vite",
@@ -31,7 +31,8 @@
31
31
  "build:ui:cart": "CARD=cart vite build",
32
32
  "build:ui:order": "CARD=order vite build",
33
33
  "build:ui:upload": "CARD=upload vite build",
34
- "build:ui:openai-camera": "CARD=openai-camera vite build"
34
+ "build:ui:openai-camera": "CARD=openai-camera vite build",
35
+ "build:ui:openai-quote": "CARD=openai-quote vite build"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@modelcontextprotocol/ext-apps": "^1.7.5",
package/src/cart/main.ts CHANGED
@@ -10,7 +10,7 @@ import { $, callTool, createCard, openLink, type GenerationResult } from "../sha
10
10
  * the app-callable `view_cart` until every thumbnail exists — the same
11
11
  * self-filling trick as the mesh card, bounded so a dead render can't poll
12
12
  * forever. Like every card, it never touches money: checkout is a
13
- * `ui/open-link` to `/pay/<ids>`.
13
+ * `ui/open-link` to `/checkout/<ids>`.
14
14
  */
15
15
 
16
16
  interface CartItem {
@@ -42,12 +42,6 @@
42
42
  height: auto;
43
43
  min-height: 0;
44
44
  }
45
- /* While ordering, the design stays in view but gives the form the room. */
46
- .card[data-view="order"] .stage,
47
- .card[data-view="done"] .stage {
48
- height: 220px;
49
- flex: none;
50
- }
51
45
  .stage iframe {
52
46
  display: block;
53
47
  width: 100%;
@@ -78,8 +72,6 @@
78
72
  .fallback dt { color: var(--color-text-secondary, rgba(27, 27, 26, 0.62)); }
79
73
  .fallback dd { margin: 0; font-variant-numeric: tabular-nums; }
80
74
 
81
- .view[hidden] { display: none; }
82
-
83
75
  .controls {
84
76
  display: grid;
85
77
  gap: 8px;
@@ -246,51 +238,6 @@
246
238
  }
247
239
  button.action:disabled { opacity: 0.5; cursor: default; }
248
240
 
249
- form {
250
- display: grid;
251
- gap: 8px;
252
- padding: 12px 14px 0;
253
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
254
- }
255
- form .wide { grid-column: 1 / -1; }
256
- form h2 {
257
- margin: 0;
258
- font-size: var(--font-text-sm-size, 14px);
259
- }
260
- form .note {
261
- margin: 0;
262
- font-size: var(--font-text-xs-size, 12px);
263
- color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
264
- }
265
- label {
266
- display: grid;
267
- gap: 3px;
268
- min-width: 0;
269
- font-size: var(--font-text-xs-size, 12px);
270
- color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
271
- }
272
- input {
273
- width: 100%;
274
- box-sizing: border-box;
275
- font: inherit;
276
- font-size: var(--font-text-sm-size, 13px);
277
- color: var(--color-text-primary, #1b1b1a);
278
- background: var(--color-background-primary, #ffffff);
279
- border: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.14));
280
- border-radius: 8px;
281
- padding: 7px 9px;
282
- }
283
- input[aria-invalid="true"] { border-color: #c2410c; }
284
-
285
- .done {
286
- padding: 14px;
287
- display: grid;
288
- gap: 6px;
289
- font-size: var(--font-text-sm-size, 13px);
290
- }
291
- .done h2 { margin: 0; font-size: var(--font-text-md-size, 15px); }
292
- .done p { margin: 0; color: var(--color-text-secondary, rgba(27, 27, 26, 0.62)); }
293
-
294
241
  .status {
295
242
  padding: 0 14px 10px;
296
243
  font-size: var(--font-text-xs-size, 11px);
@@ -300,13 +247,13 @@
300
247
  </style>
301
248
  </head>
302
249
  <body>
303
- <div class="card" id="card" data-view="design">
250
+ <div class="card" id="card">
304
251
  <div class="stage">
305
252
  <iframe id="viewer" title="3D preview of your camera" allow="fullscreen" hidden></iframe>
306
253
  <div class="fallback" id="fallback" hidden></div>
307
254
  </div>
308
255
 
309
- <div class="view" id="view-design">
256
+ <div id="view-design">
310
257
  <div class="controls">
311
258
  <div class="row">
312
259
  <span>Shell</span>
@@ -348,35 +295,6 @@
348
295
  </div>
349
296
  </div>
350
297
 
351
- <div class="view" id="view-order" hidden>
352
- <form id="order-form" novalidate>
353
- <h2 class="wide">Where should we send it?</h2>
354
- <p class="note wide" id="order-summary"></p>
355
- <label class="wide">Full name<input name="name" autocomplete="name" maxlength="200" /></label>
356
- <label class="wide">Email<input name="email" type="email" autocomplete="email" maxlength="200" /></label>
357
- <label class="wide">Address<input name="address" autocomplete="street-address" maxlength="200" /></label>
358
- <label>City<input name="city" autocomplete="address-level2" maxlength="200" /></label>
359
- <label>Postcode / ZIP<input name="postcode" autocomplete="postal-code" maxlength="40" /></label>
360
- <label class="wide">Country<input name="country" autocomplete="country-name" maxlength="100" /></label>
361
- </form>
362
- <div class="foot">
363
- <div class="price">Test mode — nothing is charged.</div>
364
- <button class="action" id="order-back" type="button">Back</button>
365
- <button class="action primary" id="order-place" type="button">Place test order</button>
366
- </div>
367
- </div>
368
-
369
- <div class="view" id="view-done" hidden>
370
- <div class="done">
371
- <h2>Order received</h2>
372
- <p id="done-text"></p>
373
- </div>
374
- <div class="foot">
375
- <div class="price"></div>
376
- <button class="action" id="done-back" type="button">Back to design</button>
377
- </div>
378
- </div>
379
-
380
298
  <div class="status" id="status">Connecting&hellip;</div>
381
299
  </div>
382
300
  <script type="module" src="./main.ts"></script>
@@ -1,4 +1,4 @@
1
- import { $, callTool, createCard, pushModelContext, toggleFullscreen } from "../shared/bridge";
1
+ import { $, callTool, createCard, pushModelContext, sendChatMessage, toggleFullscreen } from "../shared/bridge";
2
2
 
3
3
  /**
4
4
  * The camera card — the OpenAI MCP's first project (swan-backend
@@ -24,9 +24,9 @@ import { $, callTool, createCard, pushModelContext, toggleFullscreen } from "../
24
24
  * colours come from the palette, the native colour picker, or the eyedropper
25
25
  * where the browser has one.
26
26
  *
27
- * Ordering happens in the card: Order (or a `checkout_camera` result, which
28
- * arrives with mode "checkout") opens the shipping form, and the form submits to
29
- * the app-only `place_order` for the revision on screen. Nothing is charged.
27
+ * Order asks for a quote in the chat, as if the user had typed it: the model
28
+ * answers with `checkout_camera`, whose quote card (src/openai-quote) carries the
29
+ * checkout link. Nothing about paying happens in here.
30
30
  */
31
31
 
32
32
  interface DesignOption {
@@ -39,7 +39,6 @@ interface DesignOption {
39
39
  interface Design {
40
40
  designId: string;
41
41
  rev: number;
42
- mode?: "design" | "checkout";
43
42
  viewerUrl: string;
44
43
  viewerOrigin: string;
45
44
  sceneUrl: string;
@@ -53,15 +52,8 @@ interface Design {
53
52
  lastChange?: { source: string; writer: string; fields: string[] };
54
53
  }
55
54
 
56
- interface OrderResult {
57
- orderId: string;
58
- rev: number;
59
- price: { label: string };
60
- }
61
-
62
55
  type Slot = "shell" | "button";
63
56
  const SLOTS: Slot[] = ["shell", "button"];
64
- type View = "design" | "order" | "done";
65
57
 
66
58
  /** The most colours a gradient takes (the server holds the same limit). */
67
59
  const MAX_COLORS = 3;
@@ -80,15 +72,11 @@ const ARROW_AT_0: Record<Slot, number> = { shell: 90, button: 0 };
80
72
  type EyeDropperCtor = new () => { open(): Promise<{ sRGBHex: string }> };
81
73
  const EyeDropper = (window as Window & { EyeDropper?: EyeDropperCtor }).EyeDropper;
82
74
 
83
- const CONTACT_FIELDS = ["name", "email", "address", "city", "postcode", "country"] as const;
84
-
85
75
  /** Marks this card's own writes, so its polling does not reload the viewer for them. */
86
76
  const writer = `card-${Math.random().toString(36).slice(2, 10)}`;
87
77
 
88
- const cardEl = $("card");
89
78
  const viewer = $("viewer") as HTMLIFrameElement;
90
79
  const fallback = $("fallback");
91
- const form = $("order-form") as HTMLFormElement;
92
80
 
93
81
  let design: Design | undefined;
94
82
  /** Revision the viewer is showing. */
@@ -123,13 +111,6 @@ function setStatus(text: string, clearAfterMs?: number) {
123
111
  if (clearAfterMs) statusTimer = window.setTimeout(() => ($("status").textContent = ""), clearAfterMs);
124
112
  }
125
113
 
126
- function showView(view: View) {
127
- cardEl.dataset.view = view;
128
- $("view-design").hidden = view !== "design";
129
- $("view-order").hidden = view !== "order";
130
- $("view-done").hidden = view !== "done";
131
- }
132
-
133
114
  const { app } = createCard("Camera", {
134
115
  onInput: () => setStatus("Setting up your camera…"),
135
116
  onResult: (raw) => {
@@ -151,7 +132,6 @@ function adopt(next: Design) {
151
132
  } else if (next.rev > loadedRev) {
152
133
  void loadViewer();
153
134
  }
154
- if (next.mode === "checkout") openOrderForm();
155
135
  }
156
136
 
157
137
  // ---- the viewer -------------------------------------------------------------
@@ -288,7 +268,7 @@ window.addEventListener("message", (event) => {
288
268
  } else if (msg?.type === "kakapo:print") {
289
269
  // The viewer's own Print button is our Order.
290
270
  onViewerParams(flatten(msg.scripts));
291
- openOrderForm();
271
+ void order();
292
272
  }
293
273
  });
294
274
 
@@ -468,10 +448,6 @@ function render() {
468
448
  $(`dropper-${slot}`).hidden = !EyeDropper;
469
449
  }
470
450
  $("price").textContent = `Full-colour print · ${design.price.label}`;
471
- $("order-summary").textContent =
472
- `Your camera, printed in full colour: ${design.price.label}. ` +
473
- `Shell ${paintText(design.colors.shell, design.gradientAngles.shell)}, ` +
474
- `button ${paintText(design.colors.button, design.gradientAngles.button)}.`;
475
451
  if (!fallback.hidden) showFallback();
476
452
  }
477
453
 
@@ -599,56 +575,30 @@ for (const slot of SLOTS) {
599
575
 
600
576
  // ---- ordering -----------------------------------------------------------------
601
577
 
602
- function openOrderForm() {
603
- if (!design) return;
604
- lastActivity = Date.now();
605
- showView("order");
606
- setStatus("");
607
- (form.elements.namedItem("name") as HTMLInputElement | null)?.focus();
608
- }
609
-
610
- /** The form's values, or undefined after marking what is missing. */
611
- function readContact(): Record<(typeof CONTACT_FIELDS)[number], string> | undefined {
612
- const contact = {} as Record<(typeof CONTACT_FIELDS)[number], string>;
613
- let ok = true;
614
- for (const name of CONTACT_FIELDS) {
615
- const input = form.elements.namedItem(name) as HTMLInputElement;
616
- const value = input.value.trim();
617
- const valid = name === "email" ? /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) : value.length > 0;
618
- input.setAttribute("aria-invalid", String(!valid));
619
- if (!valid) ok = false;
620
- contact[name] = value;
621
- }
622
- return ok ? contact : undefined;
623
- }
578
+ let ordering = false;
624
579
 
625
- async function placeOrder() {
626
- if (!design) return;
627
- const contact = readContact();
628
- if (!contact) {
629
- setStatus("Fill in the highlighted fields.");
630
- return;
631
- }
632
- const button = $("order-place") as HTMLButtonElement;
580
+ /**
581
+ * Ask for a quote in the chat. Whatever the user just changed is saved first,
582
+ * so the quote is for what is on screen, and fullscreen closes so the quote card
583
+ * that answers is in view.
584
+ */
585
+ async function order() {
586
+ if (!design || ordering) return;
587
+ ordering = true;
588
+ lastActivity = Date.now();
589
+ const button = $("order") as HTMLButtonElement;
633
590
  button.disabled = true;
634
- setStatus("Placing your order…");
591
+ setStatus("Getting your quote…");
635
592
  try {
636
- // Order what is on screen: save any pending knob turn first.
637
593
  await freshViewerParams();
638
594
  if (syncTimer !== undefined || syncing) await flushSync();
639
- const res = await callTool(app, "place_order", { designId: design.designId, rev: design.rev, writer, contact });
640
- const order = res.structuredContent as OrderResult | undefined;
641
- if (res.isError || !order) {
642
- setStatus("The order didn't go through. Check the details and try again.");
643
- return;
644
- }
645
- $("done-text").textContent = `Test order ${order.orderId} is recorded (${order.price.label}). Nothing was charged.`;
646
- showView("done");
647
- setStatus("");
648
- void pushModelContext(app, `The user placed test order ${order.orderId} for camera design ${design.designId}, revision ${order.rev}.`);
595
+ if (document.documentElement.dataset.displayMode === "fullscreen") await toggleFullscreen(app);
596
+ const sent = await sendChatMessage(app, "Order it");
597
+ setStatus(sent ? "" : "Ask in the chat to order it.");
649
598
  } catch {
650
- setStatus("The order didn't go through. Try again in a moment.");
599
+ setStatus("Couldn't ask for a quote. Ask in the chat to order it.");
651
600
  } finally {
601
+ ordering = false;
652
602
  button.disabled = false;
653
603
  }
654
604
  }
@@ -657,14 +607,7 @@ $("customize").addEventListener("click", () => {
657
607
  lastActivity = Date.now();
658
608
  void toggleFullscreen(app);
659
609
  });
660
- $("order").addEventListener("click", openOrderForm);
661
- $("order-back").addEventListener("click", () => showView("design"));
662
- $("order-place").addEventListener("click", () => void placeOrder());
663
- $("done-back").addEventListener("click", () => showView("design"));
664
- form.addEventListener("submit", (event) => {
665
- event.preventDefault();
666
- void placeOrder();
667
- });
610
+ $("order").addEventListener("click", () => void order());
668
611
 
669
612
  // The bridge sets data-display-mode from the host; keep the toggle's label honest.
670
613
  new MutationObserver(() => {
@@ -0,0 +1,149 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Quote</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ }
11
+ body {
12
+ margin: 0;
13
+ font-family: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
14
+ color: var(--color-text-primary, #1b1b1a);
15
+ }
16
+
17
+ .card {
18
+ display: grid;
19
+ gap: 12px;
20
+ max-width: 520px;
21
+ padding: 14px;
22
+ border-radius: var(--border-radius-lg, 14px);
23
+ background: var(--color-background-secondary, #f6f6f4);
24
+ border: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.1));
25
+ box-sizing: border-box;
26
+ }
27
+
28
+ .head {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 12px;
32
+ min-width: 0;
33
+ }
34
+ /* A picture of the design from its colours alone: the shell, and the shutter button on it. */
35
+ .thumb {
36
+ position: relative;
37
+ flex: none;
38
+ width: 56px;
39
+ height: 56px;
40
+ border-radius: 12px;
41
+ border: 1px solid rgba(0, 0, 0, 0.14);
42
+ box-sizing: border-box;
43
+ }
44
+ .thumb .cap {
45
+ position: absolute;
46
+ top: 9px;
47
+ right: 9px;
48
+ width: 14px;
49
+ height: 14px;
50
+ border-radius: 50%;
51
+ border: 1px solid rgba(0, 0, 0, 0.2);
52
+ }
53
+ .titles { min-width: 0; }
54
+ .titles h2 {
55
+ margin: 0;
56
+ font-size: var(--font-text-md-size, 15px);
57
+ }
58
+ .titles p {
59
+ margin: 2px 0 0;
60
+ font-size: var(--font-text-sm-size, 13px);
61
+ color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
62
+ }
63
+
64
+ .lines {
65
+ display: grid;
66
+ gap: 6px;
67
+ margin: 0;
68
+ padding-top: 12px;
69
+ border-top: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.1));
70
+ font-size: var(--font-text-sm-size, 13px);
71
+ }
72
+ .lines > div {
73
+ display: flex;
74
+ justify-content: space-between;
75
+ gap: 12px;
76
+ }
77
+ .lines dt { color: var(--color-text-secondary, rgba(27, 27, 26, 0.62)); }
78
+ .lines dd { margin: 0; font-variant-numeric: tabular-nums; }
79
+ .lines .total {
80
+ padding-top: 6px;
81
+ border-top: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.1));
82
+ font-weight: 600;
83
+ }
84
+ .lines .total dt { color: inherit; }
85
+
86
+ .note {
87
+ margin: 0;
88
+ font-size: var(--font-text-xs-size, 12px);
89
+ color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
90
+ }
91
+
92
+ .foot {
93
+ display: flex;
94
+ gap: 8px;
95
+ }
96
+ button.action {
97
+ font: inherit;
98
+ font-size: var(--font-text-sm-size, 13px);
99
+ font-weight: 600;
100
+ border-radius: var(--border-radius-full, 999px);
101
+ padding: 9px 14px;
102
+ cursor: pointer;
103
+ border: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.14));
104
+ background: transparent;
105
+ color: var(--color-text-primary, #1b1b1a);
106
+ }
107
+ button.action.primary {
108
+ flex: 1;
109
+ border-color: transparent;
110
+ background: var(--color-text-primary, #1b1b1a);
111
+ color: var(--color-background-primary, #ffffff);
112
+ }
113
+ button.action:disabled { opacity: 0.5; cursor: default; }
114
+
115
+ .status {
116
+ font-size: var(--font-text-xs-size, 11px);
117
+ color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
118
+ }
119
+ .status:empty { display: none; }
120
+ [hidden] { display: none !important; }
121
+ </style>
122
+ </head>
123
+ <body>
124
+ <div class="card">
125
+ <div class="head">
126
+ <div class="thumb" id="thumb" aria-hidden="true"><span class="cap" id="cap"></span></div>
127
+ <div class="titles">
128
+ <h2 id="title"></h2>
129
+ <p id="summary"></p>
130
+ </div>
131
+ </div>
132
+
133
+ <dl class="lines" id="lines" hidden>
134
+ <div><dt id="item-label"></dt><dd id="item-amount"></dd></div>
135
+ <div><dt id="shipping-label"></dt><dd id="shipping-amount"></dd></div>
136
+ <div class="total"><dt id="total-label">Total</dt><dd id="total"></dd></div>
137
+ </dl>
138
+ <p class="note" id="note" hidden></p>
139
+
140
+ <div class="foot" id="foot" hidden>
141
+ <button class="action" id="edit" type="button">Edit design</button>
142
+ <button class="action primary" id="checkout" type="button"></button>
143
+ </div>
144
+
145
+ <div class="status" id="status">Connecting&hellip;</div>
146
+ </div>
147
+ <script type="module" src="./main.ts"></script>
148
+ </body>
149
+ </html>
@@ -0,0 +1,131 @@
1
+ import { $, createCard, openExternal, pushModelContext, sendChatMessage, type GenerationResult } from "../shared/bridge";
2
+
3
+ /**
4
+ * The quote card: what `checkout_camera` answers on the OpenAI MCP (swan-backend
5
+ * docs/OPENAI_MCP.md). The camera's price, shipping to the user's country, the
6
+ * total and an arrival date, for the design as it was when the user asked.
7
+ *
8
+ * It never touches money or an address. Checkout opens the quote's checkout
9
+ * link on the web, where the user pays; every browser that opens it gets its
10
+ * own copy of this design in its cart. Edit design asks the chat to reopen the
11
+ * designer, as if the user had typed it.
12
+ */
13
+
14
+ interface Quote {
15
+ designId: string;
16
+ rev: number;
17
+ title: string;
18
+ summary: string;
19
+ /** Each part's colours as CSS, first to last. */
20
+ swatches: { shell: string[]; button: string[] };
21
+ currency: string;
22
+ item: { label: string; amount: number };
23
+ shipping: { country: string; countryName: string; amount: number } | null;
24
+ total: number;
25
+ /** YYYY-MM-DD */
26
+ arrivesBy: string | null;
27
+ checkoutUrl: string;
28
+ expiresAt: string;
29
+ }
30
+
31
+ let quote: Quote | undefined;
32
+
33
+ const setStatus = (text: string) => {
34
+ $("status").textContent = text;
35
+ };
36
+
37
+ /** One colour fills; two or three run top to bottom, as on the shell. */
38
+ const paint = (colors: string[]) => (colors.length > 1 ? `linear-gradient(180deg, ${colors.join(", ")})` : colors[0] || "transparent");
39
+
40
+ /** "Nov 2", or "Nov 2, 2027" when it isn't this year. A date-only string is the user's calendar day, not UTC midnight. */
41
+ function day(iso: string) {
42
+ const [y, m, d] = iso.split("-").map(Number);
43
+ const date = new Date(y, m - 1, d);
44
+ return date.toLocaleDateString("en-US", {
45
+ month: "short",
46
+ day: "numeric",
47
+ ...(y !== new Date().getFullYear() ? { year: "numeric" } : {}),
48
+ });
49
+ }
50
+
51
+ /** "womp.com" from the link, so the button names where it goes on every environment. */
52
+ function host(url: string) {
53
+ try {
54
+ return new URL(url).hostname.replace(/^www\./, "");
55
+ } catch {
56
+ return "the web";
57
+ }
58
+ }
59
+
60
+ const expired = (q: Quote) => Date.parse(q.expiresAt) <= Date.now();
61
+
62
+ function render(q: Quote) {
63
+ const money = (amount: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: q.currency }).format(amount);
64
+
65
+ $("thumb").style.background = paint(q.swatches.shell);
66
+ $("cap").style.background = paint(q.swatches.button);
67
+ $("title").textContent = q.title;
68
+ $("summary").textContent = q.summary;
69
+
70
+ $("item-label").textContent = q.item.label;
71
+ $("item-amount").textContent = money(q.item.amount);
72
+ if (q.shipping) {
73
+ $("shipping-label").textContent = `Shipping to ${q.shipping.countryName}`;
74
+ $("shipping-amount").textContent = money(q.shipping.amount);
75
+ $("total-label").textContent = "Total";
76
+ } else {
77
+ $("shipping-label").textContent = "Shipping";
78
+ $("shipping-amount").textContent = "Added at checkout";
79
+ $("total-label").textContent = "Total before shipping";
80
+ }
81
+ $("total").textContent = money(q.total);
82
+ $("lines").hidden = false;
83
+
84
+ const made = "Printed and assembled by Womp";
85
+ $("note").textContent = q.arrivesBy ? `Arrives by ${day(q.arrivesBy)} · ${made}` : made;
86
+ $("note").hidden = false;
87
+
88
+ const checkout = $("checkout") as HTMLButtonElement;
89
+ checkout.textContent = `Checkout on ${host(q.checkoutUrl)} ↗`;
90
+ checkout.disabled = expired(q);
91
+ $("foot").hidden = false;
92
+ setStatus(expired(q) ? "This quote has expired. Ask in the chat for a new one." : "");
93
+ }
94
+
95
+ const card = createCard("Quote", {
96
+ onInput: () => setStatus("Getting your quote…"),
97
+ onResult: (raw: GenerationResult) => {
98
+ const data = raw as unknown as Partial<Quote>;
99
+ // The bridge also calls this with a bare { status: "running" } placeholder.
100
+ if (typeof data.checkoutUrl !== "string" || typeof data.designId !== "string") return;
101
+ quote = data as Quote;
102
+ render(quote);
103
+ },
104
+ });
105
+
106
+ $("checkout").addEventListener("click", async () => {
107
+ if (!quote) return;
108
+ if (expired(quote)) {
109
+ render(quote);
110
+ return;
111
+ }
112
+ try {
113
+ await openExternal(card.app, quote.checkoutUrl);
114
+ setStatus("Checkout is open in your browser. Your design and total carry over.");
115
+ void pushModelContext(
116
+ card.app,
117
+ `The user opened checkout for camera design ${quote.designId}, revision ${quote.rev}. They enter their address and pay on that page.`,
118
+ );
119
+ } catch {
120
+ setStatus("Couldn't open checkout here. Ask in the chat for the checkout link.");
121
+ }
122
+ });
123
+
124
+ $("edit").addEventListener("click", async () => {
125
+ try {
126
+ const sent = await sendChatMessage(card.app, "Edit the design");
127
+ if (!sent) setStatus("Ask in the chat to change the design.");
128
+ } catch {
129
+ setStatus("Ask in the chat to change the design.");
130
+ }
131
+ });
package/src/print/main.ts CHANGED
@@ -28,9 +28,9 @@ import { expandHexInput, nearestPantone, normalizeHex, pantoneChips } from "./pa
28
28
  *
29
29
  * Deliberately NO payment surface. "Add to cart" calls the app-callable
30
30
  * `add_to_cart` (a free project + a real cart item) and then becomes "Checkout
31
- * on womp.com" — a `ui/open-link` to /pay/<ids>, where address, shipping, promo
32
- * codes and payment run in the one existing cart UI. Hollow never orders from
33
- * here at all: escape holes are placed in the editor, so its button is
31
+ * on womp.com" — a `ui/open-link` to /checkout/<ids>, where address, shipping,
32
+ * promo codes and payment run in the one existing cart UI. Hollow never orders
33
+ * from here at all: escape holes are placed in the editor, so its button is
34
34
  * "Continue on womp.com" via `prepare_print`.
35
35
  *
36
36
  * Colour: the panel snaps a pick to the nearest Pantone coated shade and sends