@womp/mcp-ui 0.1.3 → 0.1.5
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 +8 -2
- package/dist/cart/index.html +284 -0
- package/dist/image/index.html +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +24 -6
- package/dist/mesh/index.html +1 -1
- package/dist/order/index.html +187 -0
- package/dist/print/index.html +62 -18
- package/package.json +5 -3
- package/src/cart/index.html +210 -0
- package/src/cart/main.ts +157 -0
- package/src/order/index.html +113 -0
- package/src/order/main.ts +110 -0
- package/src/print/index.html +61 -17
- package/src/print/main.ts +210 -55
- package/src/shared/bridge.ts +22 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Womp orders</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root {
|
|
8
|
+
color-scheme: light dark;
|
|
9
|
+
}
|
|
10
|
+
body {
|
|
11
|
+
margin: 0;
|
|
12
|
+
font-family: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
|
|
13
|
+
color: var(--color-text-primary, #e8e8e8);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.card {
|
|
17
|
+
max-width: 400px;
|
|
18
|
+
border-radius: var(--border-radius-lg, 14px);
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
background: var(--color-background-secondary, #1b1b1d);
|
|
21
|
+
border: 1px solid var(--color-border-primary, rgba(255, 255, 255, 0.08));
|
|
22
|
+
padding: 14px;
|
|
23
|
+
box-sizing: border-box;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.orders { display: grid; gap: 12px; }
|
|
27
|
+
|
|
28
|
+
.order { display: grid; gap: 6px; }
|
|
29
|
+
.order + .order {
|
|
30
|
+
border-top: 1px solid var(--color-border-primary, rgba(255, 255, 255, 0.08));
|
|
31
|
+
padding-top: 12px;
|
|
32
|
+
}
|
|
33
|
+
.order-head {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
justify-content: space-between;
|
|
37
|
+
gap: 8px;
|
|
38
|
+
}
|
|
39
|
+
.order-id {
|
|
40
|
+
font-size: var(--font-text-sm-size, 13px);
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
}
|
|
43
|
+
/* Status chips mirror the orders page's wording; color encodes the band:
|
|
44
|
+
waiting (amber), moving (blue), done (green), stopped (red/gray). */
|
|
45
|
+
.chip {
|
|
46
|
+
font-size: 10px;
|
|
47
|
+
font-weight: 600;
|
|
48
|
+
letter-spacing: 0.04em;
|
|
49
|
+
padding: 2px 8px;
|
|
50
|
+
border-radius: var(--border-radius-full, 999px);
|
|
51
|
+
background: var(--color-background-tertiary, #2a2a2e);
|
|
52
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.7));
|
|
53
|
+
white-space: nowrap;
|
|
54
|
+
}
|
|
55
|
+
.chip.wait { background: rgba(217, 174, 85, 0.16); color: #d9ae55; }
|
|
56
|
+
.chip.move { background: rgba(122, 162, 255, 0.16); color: #7aa2ff; }
|
|
57
|
+
.chip.done { background: rgba(127, 199, 150, 0.16); color: #7fc796; }
|
|
58
|
+
.chip.stop { background: rgba(224, 141, 141, 0.16); color: #e08d8d; }
|
|
59
|
+
|
|
60
|
+
.lines {
|
|
61
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
62
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
63
|
+
display: grid;
|
|
64
|
+
gap: 2px;
|
|
65
|
+
}
|
|
66
|
+
.total-row {
|
|
67
|
+
display: flex;
|
|
68
|
+
justify-content: space-between;
|
|
69
|
+
font-size: var(--font-text-xs-size, 12px);
|
|
70
|
+
}
|
|
71
|
+
.total-row b { font-variant-numeric: tabular-nums; }
|
|
72
|
+
|
|
73
|
+
.action {
|
|
74
|
+
margin-top: 4px;
|
|
75
|
+
border-left: 3px solid #d9ae55;
|
|
76
|
+
background: rgba(217, 174, 85, 0.1);
|
|
77
|
+
border-radius: 6px;
|
|
78
|
+
padding: 8px 10px;
|
|
79
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
80
|
+
color: var(--color-text-primary, #e8e8e8);
|
|
81
|
+
}
|
|
82
|
+
.action[hidden] { display: none; }
|
|
83
|
+
|
|
84
|
+
.tracking {
|
|
85
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
86
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.7));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.empty {
|
|
90
|
+
padding: 14px 0;
|
|
91
|
+
font-size: var(--font-text-sm-size, 13px);
|
|
92
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
93
|
+
text-align: center;
|
|
94
|
+
}
|
|
95
|
+
.empty[hidden] { display: none; }
|
|
96
|
+
|
|
97
|
+
.status {
|
|
98
|
+
margin-top: 8px;
|
|
99
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
100
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
101
|
+
}
|
|
102
|
+
.status:empty { display: none; }
|
|
103
|
+
</style>
|
|
104
|
+
</head>
|
|
105
|
+
<body>
|
|
106
|
+
<div class="card" id="card">
|
|
107
|
+
<div class="orders" id="orders"></div>
|
|
108
|
+
<div class="empty" id="empty" hidden>No print orders yet.</div>
|
|
109
|
+
<div class="status" id="status">Connecting…</div>
|
|
110
|
+
</div>
|
|
111
|
+
<script type="module" src="./main.ts"></script>
|
|
112
|
+
</body>
|
|
113
|
+
</html>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { $, createCard, type GenerationResult } from "../shared/bridge";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The order-status card — renders get_print_order (one order) and
|
|
5
|
+
* list_print_orders ({orders}) with the orders page's own status wording,
|
|
6
|
+
* colored by band: waiting / moving / done / stopped. A pending diff charge
|
|
7
|
+
* renders as the amber action strip so a blocked order explains itself.
|
|
8
|
+
* Read-only: no tool calls, no links out beyond what the model narrates.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface OrderSummary {
|
|
12
|
+
orderId: number;
|
|
13
|
+
status: string;
|
|
14
|
+
totalCost: number;
|
|
15
|
+
discount: number;
|
|
16
|
+
items: { title: string; materialName: string; quantity: number }[];
|
|
17
|
+
tracking: { carrier: string; trackingNumber: string }[];
|
|
18
|
+
actionRequired?: { type: string; amount: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface OrderPayload {
|
|
22
|
+
orders?: OrderSummary[];
|
|
23
|
+
orderId?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const usd = (n: number) => `$${n.toFixed(2)}`;
|
|
27
|
+
|
|
28
|
+
/** The orders page's label set, mapped to a color band. */
|
|
29
|
+
function chipClass(status: string): string {
|
|
30
|
+
const s = status.toLowerCase();
|
|
31
|
+
if (s.includes("pending") || s.includes("review") || s.includes("action")) return "wait";
|
|
32
|
+
if (s.includes("printing") || s.includes("processing") || s.includes("way") || s.includes("returning"))
|
|
33
|
+
return "move";
|
|
34
|
+
if (s.includes("delivered")) return "done";
|
|
35
|
+
if (s.includes("canceled")) return "stop";
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function orderBlock(order: OrderSummary): HTMLElement {
|
|
40
|
+
const block = document.createElement("div");
|
|
41
|
+
block.className = "order";
|
|
42
|
+
|
|
43
|
+
const head = document.createElement("div");
|
|
44
|
+
head.className = "order-head";
|
|
45
|
+
const id = document.createElement("div");
|
|
46
|
+
id.className = "order-id";
|
|
47
|
+
id.textContent = `Order #${order.orderId}`;
|
|
48
|
+
const chip = document.createElement("span");
|
|
49
|
+
chip.className = `chip ${chipClass(order.status)}`.trim();
|
|
50
|
+
chip.textContent = order.status;
|
|
51
|
+
head.append(id, chip);
|
|
52
|
+
block.append(head);
|
|
53
|
+
|
|
54
|
+
const lines = document.createElement("div");
|
|
55
|
+
lines.className = "lines";
|
|
56
|
+
(order.items || []).forEach((item) => {
|
|
57
|
+
const line = document.createElement("div");
|
|
58
|
+
line.textContent = `${item.quantity}x ${item.title} (${item.materialName})`;
|
|
59
|
+
lines.append(line);
|
|
60
|
+
});
|
|
61
|
+
block.append(lines);
|
|
62
|
+
|
|
63
|
+
const total = document.createElement("div");
|
|
64
|
+
total.className = "total-row";
|
|
65
|
+
const label = document.createElement("span");
|
|
66
|
+
label.textContent = order.discount > 0 ? `Total (after ${usd(order.discount)} discount)` : "Total";
|
|
67
|
+
const value = document.createElement("b");
|
|
68
|
+
value.textContent = usd(order.totalCost);
|
|
69
|
+
total.append(label, value);
|
|
70
|
+
block.append(total);
|
|
71
|
+
|
|
72
|
+
(order.tracking || []).forEach((t) => {
|
|
73
|
+
const track = document.createElement("div");
|
|
74
|
+
track.className = "tracking";
|
|
75
|
+
track.textContent = `Tracking: ${t.carrier ? `${t.carrier} ` : ""}${t.trackingNumber}`;
|
|
76
|
+
block.append(track);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (order.actionRequired) {
|
|
80
|
+
const action = document.createElement("div");
|
|
81
|
+
action.className = "action";
|
|
82
|
+
action.textContent =
|
|
83
|
+
`Action required: the measured piece costs ${usd(order.actionRequired.amount)} more than quoted. ` +
|
|
84
|
+
`Approve the adjustment on womp.com under Profile -> Orders to unblock production.`;
|
|
85
|
+
block.append(action);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return block;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function render(data: OrderPayload) {
|
|
92
|
+
// list_print_orders sends {orders}; get_print_order sends one order flat.
|
|
93
|
+
const orders: OrderSummary[] = data.orders ?? (data.orderId ? [data as unknown as OrderSummary] : []);
|
|
94
|
+
const list = $("orders");
|
|
95
|
+
list.textContent = "";
|
|
96
|
+
orders.forEach((order) => list.append(orderBlock(order)));
|
|
97
|
+
$("empty").hidden = orders.length > 0;
|
|
98
|
+
$("status").textContent = "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
createCard("Womp orders", {
|
|
102
|
+
onResult: (raw: GenerationResult) => {
|
|
103
|
+
const data = raw as OrderPayload & GenerationResult;
|
|
104
|
+
if (data.status === "running") {
|
|
105
|
+
$("status").textContent = "Loading…";
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
render(data);
|
|
109
|
+
},
|
|
110
|
+
});
|
package/src/print/index.html
CHANGED
|
@@ -36,26 +36,52 @@
|
|
|
36
36
|
font-size: var(--font-text-sm-size, 13px);
|
|
37
37
|
font-weight: 600;
|
|
38
38
|
}
|
|
39
|
-
.
|
|
39
|
+
.size {
|
|
40
40
|
font-size: var(--font-text-xs-size, 11px);
|
|
41
41
|
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
42
42
|
white-space: nowrap;
|
|
43
|
-
overflow: hidden;
|
|
44
|
-
text-overflow: ellipsis;
|
|
45
43
|
}
|
|
44
|
+
.size:empty { display: none; }
|
|
46
45
|
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
/* The configurator: three labelled controls in a row. Hidden entirely in
|
|
47
|
+
static mode (a prepare_print result rendered by a model call). */
|
|
48
|
+
.controls {
|
|
49
|
+
margin-top: 10px;
|
|
50
|
+
display: grid;
|
|
51
|
+
grid-template-columns: 1fr 76px 64px;
|
|
52
|
+
gap: 8px;
|
|
53
|
+
}
|
|
54
|
+
.controls[hidden] { display: none; }
|
|
55
|
+
.field {
|
|
56
|
+
display: grid;
|
|
57
|
+
gap: 3px;
|
|
58
|
+
font-size: 10px;
|
|
50
59
|
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
51
60
|
}
|
|
52
|
-
.
|
|
61
|
+
.field select,
|
|
62
|
+
.field input {
|
|
63
|
+
font: inherit;
|
|
64
|
+
font-size: var(--font-text-xs-size, 12px);
|
|
65
|
+
color: var(--color-text-primary, #e8e8e8);
|
|
66
|
+
background: var(--color-background-tertiary, #2a2a2e);
|
|
67
|
+
border: 1px solid var(--color-border-primary, rgba(255, 255, 255, 0.1));
|
|
68
|
+
border-radius: var(--border-radius-md, 8px);
|
|
69
|
+
padding: 6px 8px;
|
|
70
|
+
min-width: 0;
|
|
71
|
+
box-sizing: border-box;
|
|
72
|
+
}
|
|
73
|
+
.field select:focus-visible,
|
|
74
|
+
.field input:focus-visible {
|
|
75
|
+
outline: 2px solid var(--color-ring-primary, #7aa2ff);
|
|
76
|
+
outline-offset: 1px;
|
|
77
|
+
}
|
|
53
78
|
|
|
54
79
|
.price {
|
|
55
|
-
margin-top:
|
|
80
|
+
margin-top: 12px;
|
|
56
81
|
display: flex;
|
|
57
82
|
align-items: baseline;
|
|
58
83
|
gap: 8px;
|
|
84
|
+
transition: opacity 120ms ease;
|
|
59
85
|
}
|
|
60
86
|
.price:empty { display: none; }
|
|
61
87
|
.amount {
|
|
@@ -79,9 +105,14 @@
|
|
|
79
105
|
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
80
106
|
display: grid;
|
|
81
107
|
gap: 2px;
|
|
108
|
+
transition: opacity 120ms ease;
|
|
82
109
|
}
|
|
83
110
|
.rows:empty { display: none; }
|
|
84
111
|
|
|
112
|
+
/* Re-quoting: dim the numbers that are about to change, keep the layout. */
|
|
113
|
+
.quoting .price,
|
|
114
|
+
.quoting .rows { opacity: 0.35; }
|
|
115
|
+
|
|
85
116
|
.cta {
|
|
86
117
|
margin-top: 12px;
|
|
87
118
|
display: flex;
|
|
@@ -99,12 +130,12 @@
|
|
|
99
130
|
color: #fff;
|
|
100
131
|
background: var(--color-background-accent, #5867dd);
|
|
101
132
|
}
|
|
102
|
-
.cta:hover { filter: brightness(1.1); }
|
|
133
|
+
.cta:hover:not(:disabled) { filter: brightness(1.1); }
|
|
103
134
|
.cta:focus-visible { outline: 2px solid var(--color-ring-primary, #7aa2ff); outline-offset: 1px; }
|
|
104
135
|
.cta[hidden] { display: none; }
|
|
136
|
+
.cta:disabled { opacity: 0.55; cursor: default; }
|
|
105
137
|
.cta svg { width: 14px; height: 14px; display: block; }
|
|
106
138
|
|
|
107
|
-
/* The disclaimer only after there is a price to disclaim. */
|
|
108
139
|
.note {
|
|
109
140
|
margin-top: 8px;
|
|
110
141
|
font-size: 10px;
|
|
@@ -118,26 +149,39 @@
|
|
|
118
149
|
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
119
150
|
}
|
|
120
151
|
.status:empty { display: none; }
|
|
121
|
-
|
|
122
|
-
/* Skeleton shimmer while the engine measures. */
|
|
123
|
-
.measuring .price,
|
|
124
|
-
.measuring .rows { opacity: 0.35; }
|
|
152
|
+
.status.error { color: var(--color-text-danger, #ff8888); }
|
|
125
153
|
</style>
|
|
126
154
|
</head>
|
|
127
155
|
<body>
|
|
128
156
|
<div class="card" id="card">
|
|
129
157
|
<div class="head">
|
|
130
158
|
<div class="title">3D print</div>
|
|
131
|
-
<div class="
|
|
159
|
+
<div class="size" id="size"></div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="controls" id="controls" hidden>
|
|
163
|
+
<label class="field">
|
|
164
|
+
material
|
|
165
|
+
<select id="material"></select>
|
|
166
|
+
</label>
|
|
167
|
+
<label class="field">
|
|
168
|
+
size (cm)
|
|
169
|
+
<input id="longest" type="number" min="2" max="60" step="0.5" />
|
|
170
|
+
</label>
|
|
171
|
+
<label class="field">
|
|
172
|
+
qty
|
|
173
|
+
<input id="quantity" type="number" min="1" max="100" step="1" />
|
|
174
|
+
</label>
|
|
132
175
|
</div>
|
|
133
|
-
|
|
176
|
+
|
|
134
177
|
<div class="price" id="price"></div>
|
|
135
178
|
<div class="rows" id="rows"></div>
|
|
179
|
+
|
|
136
180
|
<button class="cta" id="order" hidden>
|
|
137
181
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
138
182
|
<path d="M7 17 17 7M9 7h8v8" />
|
|
139
183
|
</svg>
|
|
140
|
-
|
|
184
|
+
<span id="order-label">Add to cart</span>
|
|
141
185
|
</button>
|
|
142
186
|
<div class="note" id="note" hidden>
|
|
143
187
|
Shipping is added at checkout; the final price is confirmed before printing.
|
package/src/print/main.ts
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
|
-
import { $, createCard, openLink, type GenerationResult } from "../shared/bridge";
|
|
1
|
+
import { $, callTool, createCard, openLink, type GenerationResult } from "../shared/bridge";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The print card — a
|
|
4
|
+
* The print card — a live configurator, and the way out to checkout.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* The whole point (why this is not a static receipt): the user turns the knobs
|
|
7
|
+
* — material, size, quantity — and the card re-quotes THROUGH THE HOST by
|
|
8
|
+
* calling `estimate_print` itself, so tuning a print never round-trips through
|
|
9
|
+
* the conversation. The model seeds the starting settings simply by calling
|
|
10
|
+
* the tool with them: the host announces the call's arguments (`ontoolinput`)
|
|
11
|
+
* before any result exists, and the controls initialize from that.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NO payment surface: "Add to cart" calls the app-callable
|
|
14
|
+
* `add_to_cart` (creates a free project + a real cart item), and the button
|
|
15
|
+
* then becomes "Checkout on womp.com" — a `ui/open-link` to the cart checkout
|
|
16
|
+
* at /pay/<ids>, where address, shipping, promo codes and payment all run in
|
|
17
|
+
* the one existing cart UI. Turning any knob after an add resets the button
|
|
18
|
+
* to "Add to cart" so the checkout link always matches the shown settings.
|
|
19
|
+
*
|
|
20
|
+
* Static mode: a result that already carries `printUrl` (the model called
|
|
21
|
+
* prepare_print directly) renders as a receipt — no controls, the button just
|
|
22
|
+
* opens the link.
|
|
11
23
|
*/
|
|
12
24
|
|
|
13
25
|
/** One priced variant, as swan-backend's QUOTE_ROW_SCHEMA emits it. */
|
|
@@ -22,87 +34,230 @@ interface QuoteRow {
|
|
|
22
34
|
interface PrintQuoteResult {
|
|
23
35
|
material?: string;
|
|
24
36
|
quantity?: number;
|
|
37
|
+
longestSideCm?: number;
|
|
25
38
|
dimensionsCm?: { width: number; height: number; depth: number };
|
|
26
39
|
solid?: QuoteRow;
|
|
27
40
|
hollow?: QuoteRow;
|
|
28
41
|
callerIsPro?: boolean;
|
|
42
|
+
/** Which mesh was quoted — what re-quotes and prepare_print are keyed on. */
|
|
43
|
+
source?: { generationId?: number; glbUrl?: string };
|
|
44
|
+
/** Present on estimate results only — populates the material picker. */
|
|
45
|
+
materials?: { id: string }[];
|
|
29
46
|
projectId?: number;
|
|
30
|
-
/** Present on prepare_print only
|
|
47
|
+
/** Present on prepare_print results only. */
|
|
31
48
|
printUrl?: string;
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
const usd = (n: number) => `$${n.toFixed(2)}`;
|
|
35
52
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
const materialSelect = $("material") as HTMLSelectElement;
|
|
54
|
+
const longestInput = $("longest") as HTMLInputElement;
|
|
55
|
+
const quantityInput = $("quantity") as HTMLInputElement;
|
|
56
|
+
const orderBtn = $("order") as HTMLButtonElement;
|
|
39
57
|
|
|
40
|
-
|
|
41
|
-
|
|
58
|
+
/** The mesh being quoted, and whether the card is a receipt or a configurator. */
|
|
59
|
+
let source: { generationId?: number; glbUrl?: string } | undefined;
|
|
60
|
+
let staticPrintUrl: string | undefined;
|
|
61
|
+
/** Set once the shown settings are in the cart; cleared by any knob turn. */
|
|
62
|
+
let cartCheckoutUrl = "";
|
|
63
|
+
/** Monotonic guard: a slow older quote must never overwrite a newer one. */
|
|
64
|
+
let quoteSeq = 0;
|
|
65
|
+
let debounceTimer: number | undefined;
|
|
42
66
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
67
|
+
function setStatus(text: string, isError = false) {
|
|
68
|
+
const el = $("status");
|
|
69
|
+
el.textContent = text;
|
|
70
|
+
el.classList.toggle("error", isError);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderQuote(data: PrintQuoteResult) {
|
|
74
|
+
$("card").classList.remove("quoting");
|
|
46
75
|
|
|
47
76
|
const d = data.dimensionsCm;
|
|
48
|
-
if (d) {
|
|
49
|
-
$("size").textContent = `${d.width.toFixed(1)} x ${d.height.toFixed(1)} x ${d.depth.toFixed(1)} cm`;
|
|
50
|
-
}
|
|
77
|
+
if (d) $("size").textContent = `${d.width.toFixed(1)} x ${d.height.toFixed(1)} x ${d.depth.toFixed(1)} cm`;
|
|
51
78
|
|
|
52
79
|
const solid = data.solid;
|
|
53
|
-
if (solid)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
if (!solid) return;
|
|
81
|
+
|
|
82
|
+
const price = $("price");
|
|
83
|
+
price.textContent = "";
|
|
84
|
+
const amount = document.createElement("span");
|
|
85
|
+
amount.className = "amount";
|
|
86
|
+
amount.textContent = usd(solid.unitPrice);
|
|
87
|
+
price.append(amount);
|
|
88
|
+
if (solid.discount > 0) {
|
|
89
|
+
const was = document.createElement("span");
|
|
90
|
+
was.className = "was";
|
|
91
|
+
was.textContent = usd(solid.salePrice);
|
|
92
|
+
price.append(was);
|
|
93
|
+
}
|
|
94
|
+
const per = document.createElement("span");
|
|
95
|
+
per.className = "per";
|
|
96
|
+
per.textContent = data.quantity && data.quantity > 1 ? `per unit x ${data.quantity}` : "per unit";
|
|
97
|
+
price.append(per);
|
|
98
|
+
|
|
99
|
+
const rows = $("rows");
|
|
100
|
+
rows.textContent = "";
|
|
101
|
+
if (data.hollow) {
|
|
102
|
+
const hollow = document.createElement("div");
|
|
103
|
+
hollow.textContent = `hollow (Pro): ${usd(data.hollow.unitPrice)} per unit${data.callerIsPro ? "" : " — requires a subscription"}`;
|
|
104
|
+
rows.append(hollow);
|
|
105
|
+
}
|
|
106
|
+
const days = document.createElement("div");
|
|
107
|
+
days.textContent = `~${solid.productionDays} production day${solid.productionDays === 1 ? "" : "s"}`;
|
|
108
|
+
rows.append(days);
|
|
109
|
+
|
|
110
|
+
$("note").hidden = false;
|
|
111
|
+
orderBtn.hidden = false;
|
|
112
|
+
orderBtn.disabled = false;
|
|
113
|
+
setStatus("");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Fill the controls from settings without firing their change handlers. */
|
|
117
|
+
function syncControls(data: PrintQuoteResult) {
|
|
118
|
+
if (data.material) {
|
|
119
|
+
// The picker may not exist yet on the very first input announcement; the
|
|
120
|
+
// result's materials list (below) is what populates it.
|
|
121
|
+
if (![...materialSelect.options].some((o) => o.value === data.material)) {
|
|
122
|
+
const opt = document.createElement("option");
|
|
123
|
+
opt.value = data.material;
|
|
124
|
+
opt.textContent = data.material;
|
|
125
|
+
materialSelect.append(opt);
|
|
77
126
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
127
|
+
materialSelect.value = data.material;
|
|
128
|
+
}
|
|
129
|
+
if (typeof data.longestSideCm === "number") longestInput.value = String(Math.round(data.longestSideCm * 10) / 10);
|
|
130
|
+
if (typeof data.quantity === "number") quantityInput.value = String(data.quantity);
|
|
131
|
+
}
|
|
81
132
|
|
|
82
|
-
|
|
83
|
-
|
|
133
|
+
function populateMaterials(materials: { id: string }[], selected?: string) {
|
|
134
|
+
materialSelect.textContent = "";
|
|
135
|
+
for (const m of materials) {
|
|
136
|
+
const opt = document.createElement("option");
|
|
137
|
+
opt.value = m.id;
|
|
138
|
+
opt.textContent = m.id;
|
|
139
|
+
materialSelect.append(opt);
|
|
84
140
|
}
|
|
141
|
+
if (selected) materialSelect.value = selected;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function currentSettings() {
|
|
145
|
+
return {
|
|
146
|
+
...(source?.generationId ? { generationId: source.generationId } : {}),
|
|
147
|
+
...(source?.glbUrl ? { glbUrl: source.glbUrl } : {}),
|
|
148
|
+
material: materialSelect.value,
|
|
149
|
+
quantity: Math.min(100, Math.max(1, Math.round(Number(quantityInput.value) || 1))),
|
|
150
|
+
longestSideCm: Math.min(60, Math.max(2, Number(longestInput.value) || 10)),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function requote() {
|
|
155
|
+
if (!source || staticPrintUrl) return;
|
|
156
|
+
const seq = ++quoteSeq;
|
|
157
|
+
$("card").classList.add("quoting");
|
|
158
|
+
orderBtn.disabled = true;
|
|
159
|
+
setStatus("Updating price…");
|
|
160
|
+
try {
|
|
161
|
+
const res = await callTool(card.app, "estimate_print", currentSettings());
|
|
162
|
+
if (seq !== quoteSeq) return; // superseded by a newer knob turn
|
|
163
|
+
if (res.isError) {
|
|
164
|
+
const text = (res.content as any[])?.find((c) => c.type === "text")?.text || "Could not update the quote.";
|
|
165
|
+
$("card").classList.remove("quoting");
|
|
166
|
+
orderBtn.disabled = false;
|
|
167
|
+
setStatus(String(text), true);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
renderQuote((res.structuredContent || {}) as PrintQuoteResult);
|
|
171
|
+
} catch {
|
|
172
|
+
if (seq !== quoteSeq) return;
|
|
173
|
+
$("card").classList.remove("quoting");
|
|
174
|
+
orderBtn.disabled = false;
|
|
175
|
+
setStatus("Could not update the quote — check the connection and try again.", true);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
85
178
|
|
|
86
|
-
|
|
87
|
-
|
|
179
|
+
function scheduleRequote() {
|
|
180
|
+
// Same idea as the web print panel's debounce: a slider being dragged must
|
|
181
|
+
// not fire an engine estimate per pixel.
|
|
182
|
+
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
|
|
183
|
+
// The settings changed: whatever was added to the cart no longer matches
|
|
184
|
+
// what the card shows, so the button goes back to adding.
|
|
185
|
+
cartCheckoutUrl = "";
|
|
186
|
+
$("order-label").textContent = "Add to cart";
|
|
187
|
+
debounceTimer = window.setTimeout(() => void requote(), 600);
|
|
88
188
|
}
|
|
89
189
|
|
|
190
|
+
materialSelect.addEventListener("change", scheduleRequote);
|
|
191
|
+
longestInput.addEventListener("change", scheduleRequote);
|
|
192
|
+
quantityInput.addEventListener("change", scheduleRequote);
|
|
193
|
+
|
|
90
194
|
const card = createCard("Womp print", {
|
|
91
195
|
onTimeout: () => setStatus("Still measuring — ask me to check on it."),
|
|
196
|
+
onInput: (args) => {
|
|
197
|
+
// Seed the controls from what the model asked for, before any result: the
|
|
198
|
+
// user sees "resin-clear, 15cm" selected while the first quote computes.
|
|
199
|
+
syncControls({
|
|
200
|
+
material: typeof args.material === "string" ? args.material : undefined,
|
|
201
|
+
longestSideCm: typeof args.longestSideCm === "number" ? args.longestSideCm : undefined,
|
|
202
|
+
quantity: typeof args.quantity === "number" ? args.quantity : undefined,
|
|
203
|
+
});
|
|
204
|
+
},
|
|
92
205
|
onResult: (raw: GenerationResult) => {
|
|
93
206
|
const data = raw as PrintQuoteResult & GenerationResult;
|
|
94
207
|
// ontoolinput sends {status: "running"} while the engine measures; a print
|
|
95
208
|
// result itself never carries `status`.
|
|
96
209
|
if (data.status === "running") {
|
|
97
|
-
$("card").classList.add("
|
|
210
|
+
$("card").classList.add("quoting");
|
|
98
211
|
setStatus("Measuring mesh…");
|
|
99
212
|
return;
|
|
100
213
|
}
|
|
101
|
-
|
|
214
|
+
if (data.source) source = data.source;
|
|
215
|
+
if (data.printUrl) {
|
|
216
|
+
// A model-initiated prepare_print: render a receipt, not a configurator.
|
|
217
|
+
staticPrintUrl = data.printUrl;
|
|
218
|
+
$("controls").hidden = true;
|
|
219
|
+
$("order-label").textContent = "Complete order on womp.com";
|
|
220
|
+
} else if (data.materials?.length) {
|
|
221
|
+
populateMaterials(data.materials, data.material);
|
|
222
|
+
$("controls").hidden = false;
|
|
223
|
+
}
|
|
224
|
+
syncControls(data);
|
|
225
|
+
renderQuote(data);
|
|
102
226
|
},
|
|
103
227
|
});
|
|
104
228
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
229
|
+
orderBtn.addEventListener("click", async () => {
|
|
230
|
+
if (staticPrintUrl) {
|
|
231
|
+
void openLink(card.app, staticPrintUrl);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (cartCheckoutUrl) {
|
|
235
|
+
void openLink(card.app, cartCheckoutUrl);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!source) return;
|
|
239
|
+
orderBtn.disabled = true;
|
|
240
|
+
$("order-label").textContent = "Adding to cart…";
|
|
241
|
+
setStatus("");
|
|
242
|
+
try {
|
|
243
|
+
const res = await callTool(card.app, "add_to_cart", currentSettings());
|
|
244
|
+
const data = (res.structuredContent || {}) as { payUrl?: string; items?: unknown[]; subtotal?: number };
|
|
245
|
+
if (res.isError || !data.payUrl) {
|
|
246
|
+
const text = (res.content as any[])?.find((c) => c.type === "text")?.text || "Could not add to the cart.";
|
|
247
|
+
setStatus(String(text), true);
|
|
248
|
+
$("order-label").textContent = "Add to cart";
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
cartCheckoutUrl = data.payUrl;
|
|
252
|
+
$("order-label").textContent = "Checkout on womp.com";
|
|
253
|
+
setStatus(
|
|
254
|
+
`Added — cart has ${data.items?.length ?? 1} item(s), subtotal $${(data.subtotal ?? 0).toFixed(2)}. ` +
|
|
255
|
+
`Shipping and promo codes apply at checkout.`,
|
|
256
|
+
);
|
|
257
|
+
} catch {
|
|
258
|
+
setStatus("Could not add to the cart — try again.", true);
|
|
259
|
+
$("order-label").textContent = "Add to cart";
|
|
260
|
+
} finally {
|
|
261
|
+
orderBtn.disabled = false;
|
|
262
|
+
}
|
|
108
263
|
});
|