@womp/mcp-ui 0.1.14 → 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.
@@ -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
@@ -232,3 +232,44 @@ export function toggleFullscreen(app: App) {
232
232
  if (res?.mode) document.documentElement.dataset.displayMode = res.mode;
233
233
  });
234
234
  }
235
+
236
+ /** What ChatGPT's own `window.openai` adds for things MCP Apps has no message for. */
237
+ interface OpenAiBridge {
238
+ openExternal?: (args: { href: string; redirectUrl?: false }) => unknown;
239
+ sendFollowUpMessage?: (args: { prompt: string }) => unknown;
240
+ }
241
+ const openAiBridge = () => (window as Window & { openai?: OpenAiBridge }).openai;
242
+
243
+ /**
244
+ * Open a page outside the chat, such as a checkout. ChatGPT opens a URL on one
245
+ * of the card's declared redirect domains (`openai/widgetCSP.redirect_domains`)
246
+ * without making the user confirm the link, but only through its own
247
+ * `openExternal`; every other host gets the standard `ui/open-link`.
248
+ * `redirectUrl: false` stops ChatGPT adding a query parameter of its own.
249
+ */
250
+ export async function openExternal(app: App, url: string) {
251
+ const bridge = openAiBridge();
252
+ if (bridge?.openExternal) {
253
+ await bridge.openExternal({ href: url, redirectUrl: false });
254
+ return;
255
+ }
256
+ await app.openLink({ url });
257
+ }
258
+
259
+ /**
260
+ * Post a message in the chat as the user, for a button that stands for
261
+ * something they would otherwise type ("Order it"): the model answers it like
262
+ * any other turn. False when the host takes no messages from a card.
263
+ */
264
+ export async function sendChatMessage(app: App, text: string): Promise<boolean> {
265
+ if ((app.getHostCapabilities() as { message?: unknown } | undefined)?.message) {
266
+ const res = await app.sendMessage({ role: "user", content: [{ type: "text", text }] });
267
+ return !res.isError;
268
+ }
269
+ const bridge = openAiBridge();
270
+ if (bridge?.sendFollowUpMessage) {
271
+ await bridge.sendFollowUpMessage({ prompt: text });
272
+ return true;
273
+ }
274
+ return false;
275
+ }