@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/README.md +9 -6
- package/dist/index.d.ts +4 -0
- package/dist/index.js +17 -8
- package/dist/openai-camera/index.html +9 -91
- package/dist/openai-quote/index.html +223 -0
- package/package.json +4 -3
- package/src/cart/main.ts +1 -1
- package/src/openai-camera/index.html +2 -84
- package/src/openai-camera/main.ts +23 -80
- package/src/openai-quote/index.html +149 -0
- package/src/openai-quote/main.ts +131 -0
- package/src/print/main.ts +3 -3
- package/src/shared/bridge.ts +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@womp/mcp-ui",
|
|
3
|
-
"version": "0.1.
|
|
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 `/
|
|
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"
|
|
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
|
|
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…</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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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("
|
|
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
|
-
|
|
640
|
-
const
|
|
641
|
-
|
|
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("
|
|
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",
|
|
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…</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 /
|
|
32
|
-
* codes and payment run in the one existing cart UI. Hollow never orders
|
|
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
|