@womp/mcp-ui 0.1.6 → 0.1.8
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 +86 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +15 -6
- package/dist/print/index.html +700 -134
- package/dist/upload/index.html +351 -0
- package/package.json +4 -3
- package/src/print/index.html +683 -117
- package/src/print/main.ts +747 -210
- package/src/print/pantone-coated.ts +5 -0
- package/src/print/pantone.ts +224 -0
- package/src/upload/index.html +277 -0
- package/src/upload/main.ts +351 -0
package/src/print/main.ts
CHANGED
|
@@ -1,309 +1,846 @@
|
|
|
1
1
|
import { $, callTool, createCard, openLink, type GenerationResult } from "../shared/bridge";
|
|
2
|
+
import { expandHexInput, nearestPantone, normalizeHex, pantoneChips } from "./pantone";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* The print card —
|
|
5
|
+
* The print card — the web print panel's configurator, compressed to a card.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
11
|
-
*
|
|
7
|
+
* Same flow as the panel (NewPrintingPanel.tsx): Size → Material → Print style
|
|
8
|
+
* → review with quantity and price → the one primary action. Same controls
|
|
9
|
+
* where the panel has them: W/H/D in cm or inches, swatch tiles grouped
|
|
10
|
+
* Standard / Enterprise, a Matte/Glossy finish on clear resin, a colour picker
|
|
11
|
+
* with the nearest-Pantone readout on dyed materials, Solid vs Hollow with both
|
|
12
|
+
* prices, a quantity stepper, currency-formatted totals with the discount
|
|
13
|
+
* struck through. What the panel does to the 3D viewport (drain holes, wall
|
|
14
|
+
* thickness, X-ray) stays on womp.com, which is where Hollow hands off.
|
|
12
15
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
16
|
+
* The card re-quotes THROUGH THE HOST by calling `estimate_print` itself, so
|
|
17
|
+
* tuning a print never round-trips through the conversation. The model seeds
|
|
18
|
+
* the starting settings simply by calling the tool with them: the host
|
|
19
|
+
* announces the call's arguments (`ontoolinput`) before any result exists.
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* Deliberately NO payment surface. "Add to cart" calls the app-callable
|
|
22
|
+
* `add_to_cart` (a free project + a real cart item) and then becomes "Checkout
|
|
23
|
+
* on womp.com" — a `ui/open-link` to /pay/<ids>, where address, shipping, promo
|
|
24
|
+
* codes and payment run in the one existing cart UI. Hollow never orders from
|
|
25
|
+
* here at all: escape holes are placed in the editor, so its button is
|
|
26
|
+
* "Continue on womp.com" via `prepare_print`.
|
|
27
|
+
*
|
|
28
|
+
* Colour: the panel snaps a pick to the nearest Pantone coated shade and sends
|
|
29
|
+
* the code; chat orders send a hex. The card snaps the same way and sends the
|
|
30
|
+
* snapped shade's own hex, so both paths print the same colour and show the
|
|
31
|
+
* same "Pantone 2386 C". Colour never changes the price, so it never re-quotes.
|
|
23
32
|
*/
|
|
24
33
|
|
|
25
|
-
/** One priced variant, as swan-backend's QUOTE_ROW_SCHEMA emits it. */
|
|
26
34
|
interface QuoteRow {
|
|
27
35
|
salePrice: number;
|
|
28
36
|
discount: number;
|
|
29
37
|
unitPrice: number;
|
|
30
38
|
productionDays: number;
|
|
31
39
|
}
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
interface Dims {
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
depth: number;
|
|
44
|
+
}
|
|
45
|
+
interface PrintMaterialOption {
|
|
46
|
+
id: string;
|
|
47
|
+
label?: string;
|
|
48
|
+
group?: "standard" | "enterprise";
|
|
49
|
+
description?: string;
|
|
50
|
+
swatchUrl?: string;
|
|
51
|
+
available?: boolean;
|
|
52
|
+
unavailableReason?: string;
|
|
53
|
+
contactUrl?: string;
|
|
54
|
+
colorRequired?: boolean;
|
|
55
|
+
colorOptional?: boolean;
|
|
56
|
+
finishes?: string[];
|
|
57
|
+
pricedAs?: string;
|
|
58
|
+
}
|
|
59
|
+
/** structuredContent of estimate_print / prepare_print (swan-backend api/mcp.ts §13). */
|
|
34
60
|
interface PrintQuoteResult {
|
|
61
|
+
/** The catalog key the card is keyed on — echoed unchanged by the backend. */
|
|
35
62
|
material?: string;
|
|
63
|
+
pricedAs?: string;
|
|
64
|
+
materialLabel?: string;
|
|
65
|
+
finish?: string;
|
|
66
|
+
color?: string;
|
|
36
67
|
quantity?: number;
|
|
37
68
|
longestSideCm?: number;
|
|
38
|
-
dimensionsCm?:
|
|
69
|
+
dimensionsCm?: Dims;
|
|
39
70
|
solid?: QuoteRow;
|
|
40
71
|
hollow?: QuoteRow;
|
|
41
72
|
callerIsPro?: boolean;
|
|
42
|
-
|
|
73
|
+
repaired?: boolean;
|
|
74
|
+
nonManifold?: boolean;
|
|
43
75
|
source?: { generationId?: number; glbUrl?: string };
|
|
44
|
-
/**
|
|
45
|
-
* Present on estimate results only — populates the material picker.
|
|
46
|
-
*
|
|
47
|
-
* `label` and `swatchUrl` come from the backend material catalog
|
|
48
|
-
* (swan-backend docs/PRINT_MATERIALS.md). Before it existed this was a list
|
|
49
|
-
* of pricing ids, so the picker read "resin-clear-glossy" where the print
|
|
50
|
-
* flow says "Clear". Both stay optional: an id with no catalog entry yet
|
|
51
|
-
* still has to appear.
|
|
52
|
-
*/
|
|
76
|
+
/** Present on estimate results only; role-aware for this account. */
|
|
53
77
|
materials?: PrintMaterialOption[];
|
|
54
78
|
projectId?: number;
|
|
55
79
|
/** Present on prepare_print results only. */
|
|
56
80
|
printUrl?: string;
|
|
57
81
|
}
|
|
58
82
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
group?: "standard" | "enterprise";
|
|
63
|
-
swatchUrl?: string;
|
|
64
|
-
available?: boolean;
|
|
65
|
-
colorRequired?: boolean;
|
|
66
|
-
}
|
|
83
|
+
type Unit = "cm" | "in";
|
|
84
|
+
type Style = "solid" | "hollow";
|
|
85
|
+
type Axis = keyof Dims;
|
|
67
86
|
|
|
68
|
-
const usd = (
|
|
87
|
+
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
|
88
|
+
const money = (n: number) => usd.format(n);
|
|
69
89
|
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
90
|
+
/** The bounds the panel enforces (usePrintingParams): a 60 × 60 × 40 cm ceiling. */
|
|
91
|
+
const HARD_MAX_CM: Dims = { width: 60, height: 60, depth: 40 };
|
|
92
|
+
/** The MCP contract's longest-side range. */
|
|
93
|
+
const MIN_LONGEST_CM = 2;
|
|
94
|
+
const MAX_LONGEST_CM = 60;
|
|
95
|
+
/** The blue the panel seeds when switching into Tinted clear (NPP:7133). */
|
|
96
|
+
const PANEL_DEFAULT_TINT = "#276ee2";
|
|
97
|
+
/** The panel's own re-price debounce. */
|
|
98
|
+
const REQUOTE_DEBOUNCE_MS = 500;
|
|
99
|
+
const AXES: Axis[] = ["width", "height", "depth"];
|
|
100
|
+
|
|
101
|
+
const state = {
|
|
102
|
+
materials: [] as PrintMaterialOption[],
|
|
103
|
+
material: "",
|
|
104
|
+
finish: undefined as string | undefined,
|
|
105
|
+
/** The user's exact pick, lowercase #rrggbb. What is SENT is its nearest Pantone's hex. */
|
|
106
|
+
color: undefined as string | undefined,
|
|
107
|
+
/** For dye-optional materials (Painted): "original" = full colour, "custom" = dyed. */
|
|
108
|
+
colorMode: "original" as "original" | "custom",
|
|
109
|
+
quantity: 1,
|
|
110
|
+
longestSideCm: undefined as number | undefined,
|
|
111
|
+
dims: undefined as Dims | undefined,
|
|
112
|
+
unit: "cm" as Unit,
|
|
113
|
+
style: "solid" as Style,
|
|
114
|
+
quote: undefined as PrintQuoteResult | undefined,
|
|
115
|
+
source: undefined as PrintQuoteResult["source"],
|
|
116
|
+
/** A prepare_print result rendered by a model call: a receipt, not a configurator. */
|
|
117
|
+
staticPrintUrl: "",
|
|
118
|
+
/** Set once the shown settings are in the cart; cleared by any knob turn. */
|
|
119
|
+
cartCheckoutUrl: "",
|
|
120
|
+
/** Set once a hollow hand-off project exists for the shown settings. */
|
|
121
|
+
preparedUrl: "",
|
|
122
|
+
quoting: false,
|
|
123
|
+
enterpriseOpen: false,
|
|
124
|
+
/** The gated Enterprise material the user last tapped, for the contact note. */
|
|
125
|
+
gatedTap: undefined as PrintMaterialOption | undefined,
|
|
126
|
+
sizeNotice: "",
|
|
127
|
+
};
|
|
75
128
|
|
|
76
|
-
/** The mesh being quoted, and whether the card is a receipt or a configurator. */
|
|
77
|
-
let source: { generationId?: number; glbUrl?: string } | undefined;
|
|
78
|
-
let staticPrintUrl: string | undefined;
|
|
79
|
-
/** Set once the shown settings are in the cart; cleared by any knob turn. */
|
|
80
|
-
let cartCheckoutUrl = "";
|
|
81
129
|
/** Monotonic guard: a slow older quote must never overwrite a newer one. */
|
|
82
130
|
let quoteSeq = 0;
|
|
83
131
|
let debounceTimer: number | undefined;
|
|
84
132
|
|
|
133
|
+
// --- helpers ---------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
const selectedMaterial = () => state.materials.find((m) => m.id === state.material);
|
|
136
|
+
const materialLabel = (m?: PrintMaterialOption) => m?.label || m?.id || state.quote?.materialLabel || state.material;
|
|
137
|
+
const isClear = (m?: PrintMaterialOption) => (m?.pricedAs || m?.id || "") === "resin-clear";
|
|
138
|
+
/** Whether the current selection carries a dye. */
|
|
139
|
+
function needsColor(m = selectedMaterial()): boolean {
|
|
140
|
+
if (!m) return false;
|
|
141
|
+
if (m.colorRequired) return true;
|
|
142
|
+
return !!m.colorOptional && state.colorMode === "custom";
|
|
143
|
+
}
|
|
144
|
+
/** The colour an order would carry: the nearest Pantone's hex of the user's pick. */
|
|
145
|
+
function snappedColor(): { code: string; hex: string } | undefined {
|
|
146
|
+
if (!needsColor()) return undefined;
|
|
147
|
+
return nearestPantone(state.color || PANEL_DEFAULT_TINT);
|
|
148
|
+
}
|
|
149
|
+
const toUnit = (cm: number, unit: Unit) => (unit === "in" ? cm / 2.54 : cm);
|
|
150
|
+
const fromUnit = (v: number, unit: Unit) => (unit === "in" ? v * 2.54 : v);
|
|
151
|
+
const fmtDim = (cm: number, unit: Unit) => toUnit(cm, unit).toFixed(unit === "in" ? 2 : 1);
|
|
152
|
+
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
153
|
+
const textOf = (res: { content?: unknown }) =>
|
|
154
|
+
(res.content as { type: string; text?: string }[] | undefined)?.find((c) => c.type === "text")?.text || "";
|
|
155
|
+
|
|
85
156
|
function setStatus(text: string, isError = false) {
|
|
86
157
|
const el = $("status");
|
|
87
158
|
el.textContent = text;
|
|
88
159
|
el.classList.toggle("error", isError);
|
|
89
160
|
}
|
|
90
161
|
|
|
91
|
-
function
|
|
92
|
-
|
|
162
|
+
function setQuoting(on: boolean) {
|
|
163
|
+
state.quoting = on;
|
|
164
|
+
$("card").classList.toggle("quoting", on);
|
|
165
|
+
renderCta();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Any knob turn: whatever was added or prepared no longer matches the card. */
|
|
169
|
+
function markDirty() {
|
|
170
|
+
state.cartCheckoutUrl = "";
|
|
171
|
+
state.preparedUrl = "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The tool's error text is written for the model; the person at the card needs
|
|
176
|
+
* the action, not the argument name. Anything unrecognised is shown as is —
|
|
177
|
+
* a real message beats a generic one.
|
|
178
|
+
*/
|
|
179
|
+
function friendlyError(message: string): string {
|
|
180
|
+
if (/dyed material/i.test(message)) return "Pick a colour for this material first.";
|
|
181
|
+
if (/#rrggbb/i.test(message)) return "That colour couldn't be used. Pick another shade.";
|
|
182
|
+
if (/Enterprise material/i.test(message)) return "This material is available to Enterprise customers. Use Contact sales above.";
|
|
183
|
+
if (/escape holes/i.test(message)) return "Hollow prints are finished on womp.com, where escape holes are placed.";
|
|
184
|
+
if (/could not measure|degenerate/i.test(message)) return "The print engine couldn't measure this mesh.";
|
|
185
|
+
return message;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function svg(path: string, extra = ""): SVGElement {
|
|
189
|
+
const el = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
190
|
+
el.setAttribute("viewBox", "0 0 24 24");
|
|
191
|
+
el.setAttribute("fill", "none");
|
|
192
|
+
el.setAttribute("stroke", "currentColor");
|
|
193
|
+
el.setAttribute("stroke-width", "2");
|
|
194
|
+
el.setAttribute("stroke-linecap", "round");
|
|
195
|
+
el.setAttribute("stroke-linejoin", "round");
|
|
196
|
+
el.innerHTML = path + extra;
|
|
197
|
+
return el;
|
|
198
|
+
}
|
|
199
|
+
const ICON_INFO = '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>';
|
|
200
|
+
const ICON_WARN = '<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/>';
|
|
201
|
+
const ICON_CROWN = '<path d="M3 18h18l-1.5-9-4.5 4-3-6-3 6-4.5-4L3 18Z" fill="currentColor" stroke="none"/>';
|
|
202
|
+
|
|
203
|
+
function callout(kind: "info" | "warn" | "danger", body: (HTMLElement | string)[]): HTMLElement {
|
|
204
|
+
const el = document.createElement("div");
|
|
205
|
+
el.className = `callout ${kind}`;
|
|
206
|
+
el.append(svg(kind === "info" ? ICON_INFO : ICON_WARN));
|
|
207
|
+
const text = document.createElement("div");
|
|
208
|
+
text.append(...body);
|
|
209
|
+
el.append(text);
|
|
210
|
+
return el;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function linkButton(label: string, url: string): HTMLElement {
|
|
214
|
+
const b = document.createElement("button");
|
|
215
|
+
b.type = "button";
|
|
216
|
+
b.className = "link";
|
|
217
|
+
b.textContent = label;
|
|
218
|
+
b.addEventListener("click", () => void openLink(card.app, url));
|
|
219
|
+
return b;
|
|
220
|
+
}
|
|
93
221
|
|
|
94
|
-
|
|
95
|
-
if (d) $("size").textContent = `${d.width.toFixed(1)} x ${d.height.toFixed(1)} x ${d.depth.toFixed(1)} cm`;
|
|
222
|
+
// --- rendering -------------------------------------------------------------
|
|
96
223
|
|
|
97
|
-
|
|
98
|
-
|
|
224
|
+
function render() {
|
|
225
|
+
renderHead();
|
|
226
|
+
renderMeshCallouts();
|
|
227
|
+
renderSize();
|
|
228
|
+
renderMaterials();
|
|
229
|
+
renderSubControls();
|
|
230
|
+
renderStyle();
|
|
231
|
+
renderReview();
|
|
232
|
+
renderCta();
|
|
233
|
+
}
|
|
99
234
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
235
|
+
function renderHead() {
|
|
236
|
+
const d = state.dims;
|
|
237
|
+
$("dims").textContent = d
|
|
238
|
+
? `${fmtDim(d.width, state.unit)} × ${fmtDim(d.height, state.unit)} × ${fmtDim(d.depth, state.unit)} ${state.unit}`
|
|
239
|
+
: "";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderMeshCallouts() {
|
|
243
|
+
const holder = $("mesh-callouts");
|
|
244
|
+
holder.textContent = "";
|
|
245
|
+
const q = state.quote;
|
|
246
|
+
if (!q) return;
|
|
247
|
+
if (q.nonManifold) {
|
|
248
|
+
holder.append(
|
|
249
|
+
callout("warn", [
|
|
250
|
+
"Parts of this mesh aren't watertight" +
|
|
251
|
+
(q.repaired ? ", even after repair" : "") +
|
|
252
|
+
". It can be ordered, but the print may have defects. Every order is reviewed before printing.",
|
|
253
|
+
]),
|
|
254
|
+
);
|
|
255
|
+
} else if (q.repaired) {
|
|
256
|
+
holder.append(
|
|
257
|
+
callout("info", [
|
|
258
|
+
"This mesh wasn't watertight, so it was repaired automatically for printing. Repair can subtly change the shape — check the preview.",
|
|
259
|
+
]),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Any axis past the panel's ceiling blocks the order, as the panel does. */
|
|
265
|
+
function overHardLimit(): boolean {
|
|
266
|
+
const d = state.dims;
|
|
267
|
+
return !!d && AXES.some((a) => d[a] > HARD_MAX_CM[a] + 1e-6);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function renderSize() {
|
|
271
|
+
const d = state.dims;
|
|
272
|
+
for (const axis of AXES) {
|
|
273
|
+
const input = $(`dim-${axis}`) as HTMLInputElement;
|
|
274
|
+
if (document.activeElement === input) continue; // never fight the typist
|
|
275
|
+
input.value = d ? fmtDim(d[axis], state.unit) : "";
|
|
111
276
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
277
|
+
document.querySelectorAll<HTMLButtonElement>("#unit [data-unit]").forEach((b) => {
|
|
278
|
+
b.setAttribute("aria-checked", String(b.dataset.unit === state.unit));
|
|
279
|
+
});
|
|
280
|
+
const holder = $("size-callouts");
|
|
281
|
+
holder.textContent = "";
|
|
282
|
+
if (overHardLimit()) {
|
|
283
|
+
const lim = HARD_MAX_CM;
|
|
284
|
+
holder.append(
|
|
285
|
+
callout("danger", [
|
|
286
|
+
`This exceeds the maximum printable size, ${fmtDim(lim.width, state.unit)} × ${fmtDim(lim.height, state.unit)} × ${fmtDim(lim.depth, state.unit)} ${state.unit}. Reduce the size to continue.`,
|
|
287
|
+
]),
|
|
288
|
+
);
|
|
289
|
+
} else if (state.sizeNotice) {
|
|
290
|
+
holder.append(callout("info", [state.sizeNotice]));
|
|
123
291
|
}
|
|
124
|
-
|
|
125
|
-
days.textContent = `~${solid.productionDays} production day${solid.productionDays === 1 ? "" : "s"}`;
|
|
126
|
-
rows.append(days);
|
|
292
|
+
}
|
|
127
293
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
294
|
+
function tile(m: PrintMaterialOption): HTMLElement {
|
|
295
|
+
const b = document.createElement("button");
|
|
296
|
+
b.type = "button";
|
|
297
|
+
b.className = "tile";
|
|
298
|
+
b.setAttribute("role", "radio");
|
|
299
|
+
b.setAttribute("aria-checked", String(m.id === state.material));
|
|
300
|
+
if (m.available === false) b.setAttribute("aria-disabled", "true");
|
|
301
|
+
b.title = m.description || m.label || m.id;
|
|
302
|
+
// The catalog's swatch when it has one and it loads; the label's initial in
|
|
303
|
+
// the same box otherwise — a broken-image glyph next to a picker reads as a
|
|
304
|
+
// broken card.
|
|
305
|
+
const placeholder = () => {
|
|
306
|
+
const ph = document.createElement("div");
|
|
307
|
+
ph.className = "tile-img";
|
|
308
|
+
ph.textContent = (m.label || m.id).charAt(0).toUpperCase();
|
|
309
|
+
return ph;
|
|
310
|
+
};
|
|
311
|
+
if (m.swatchUrl) {
|
|
312
|
+
const img = document.createElement("img");
|
|
313
|
+
img.className = "tile-img";
|
|
314
|
+
img.src = m.swatchUrl;
|
|
315
|
+
img.alt = "";
|
|
316
|
+
img.loading = "lazy";
|
|
317
|
+
img.addEventListener("error", () => img.replaceWith(placeholder()), { once: true });
|
|
318
|
+
b.append(img);
|
|
319
|
+
} else {
|
|
320
|
+
b.append(placeholder());
|
|
321
|
+
}
|
|
322
|
+
if (m.group === "enterprise") {
|
|
323
|
+
const crown = document.createElement("span");
|
|
324
|
+
crown.className = "crown";
|
|
325
|
+
crown.append(svg(ICON_CROWN));
|
|
326
|
+
b.append(crown);
|
|
327
|
+
}
|
|
328
|
+
const label = document.createElement("span");
|
|
329
|
+
label.className = "tile-label";
|
|
330
|
+
label.textContent = m.label || m.id;
|
|
331
|
+
b.append(label);
|
|
332
|
+
b.addEventListener("click", () => selectMaterial(m.id));
|
|
333
|
+
return b;
|
|
132
334
|
}
|
|
133
335
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
336
|
+
function renderMaterials() {
|
|
337
|
+
const standard = state.materials.filter((m) => (m.group || "standard") !== "enterprise");
|
|
338
|
+
const enterprise = state.materials.filter((m) => m.group === "enterprise");
|
|
339
|
+
|
|
340
|
+
const std = $("tiles-standard");
|
|
341
|
+
std.textContent = "";
|
|
342
|
+
standard.forEach((m) => std.append(tile(m)));
|
|
343
|
+
|
|
344
|
+
const toggle = $("enterprise-toggle");
|
|
345
|
+
const ent = $("tiles-enterprise");
|
|
346
|
+
ent.textContent = "";
|
|
347
|
+
toggle.hidden = !enterprise.length;
|
|
348
|
+
if (enterprise.length) {
|
|
349
|
+
// Opens itself when the selection is an Enterprise material, so the ring is
|
|
350
|
+
// never hidden behind a closed disclosure.
|
|
351
|
+
if (selectedMaterial()?.group === "enterprise") state.enterpriseOpen = true;
|
|
352
|
+
toggle.setAttribute("aria-expanded", String(state.enterpriseOpen));
|
|
353
|
+
$("enterprise-label").textContent = `Enterprise materials (${enterprise.length})`;
|
|
354
|
+
ent.hidden = !state.enterpriseOpen;
|
|
355
|
+
enterprise.forEach((m) => ent.append(tile(m)));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const notes = $("material-callouts");
|
|
359
|
+
notes.textContent = "";
|
|
360
|
+
const gated = state.gatedTap;
|
|
361
|
+
if (gated) {
|
|
362
|
+
const body: (HTMLElement | string)[] = [`${gated.label || gated.id} prints are available to Enterprise customers. `];
|
|
363
|
+
if (gated.contactUrl) body.push(linkButton("Contact sales", gated.contactUrl));
|
|
364
|
+
notes.append(callout("info", body));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function renderSubControls() {
|
|
369
|
+
const m = selectedMaterial();
|
|
370
|
+
const finishes = m?.finishes || [];
|
|
371
|
+
const showFinish = finishes.length > 0;
|
|
372
|
+
const showMode = !!m?.colorOptional;
|
|
373
|
+
const showColor = needsColor(m);
|
|
374
|
+
$("sub").hidden = !(showFinish || showMode || showColor);
|
|
375
|
+
|
|
376
|
+
$("finish-row").hidden = !showFinish;
|
|
377
|
+
if (showFinish) {
|
|
378
|
+
const seg = $("finish");
|
|
379
|
+
seg.textContent = "";
|
|
380
|
+
if (!state.finish || !finishes.includes(state.finish)) state.finish = finishes[0];
|
|
381
|
+
for (const f of finishes) {
|
|
382
|
+
const b = document.createElement("button");
|
|
383
|
+
b.type = "button";
|
|
384
|
+
b.setAttribute("role", "radio");
|
|
385
|
+
b.setAttribute("aria-checked", String(f === state.finish));
|
|
386
|
+
b.textContent = capitalize(f);
|
|
387
|
+
b.addEventListener("click", () => {
|
|
388
|
+
if (state.finish === f) return;
|
|
389
|
+
state.finish = f;
|
|
390
|
+
markDirty();
|
|
391
|
+
renderSubControls();
|
|
392
|
+
renderCta();
|
|
393
|
+
});
|
|
394
|
+
seg.append(b);
|
|
144
395
|
}
|
|
145
|
-
materialSelect.value = data.material;
|
|
146
|
-
showSwatch();
|
|
147
396
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
397
|
+
|
|
398
|
+
$("color-mode-row").hidden = !showMode;
|
|
399
|
+
if (showMode) {
|
|
400
|
+
document.querySelectorAll<HTMLButtonElement>("#color-mode [data-mode]").forEach((b) => {
|
|
401
|
+
b.setAttribute("aria-checked", String(b.dataset.mode === state.colorMode));
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
$("color-block").hidden = !showColor;
|
|
406
|
+
if (showColor) renderColor();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function renderColor() {
|
|
410
|
+
const pick = state.color || PANEL_DEFAULT_TINT;
|
|
411
|
+
const snapped = nearestPantone(pick);
|
|
412
|
+
|
|
413
|
+
($("color-dot") as HTMLElement).style.background = pick;
|
|
414
|
+
const native = $("color-native") as HTMLInputElement;
|
|
415
|
+
if (native.value !== pick) native.value = pick;
|
|
416
|
+
const hex = $("color-hex") as HTMLInputElement;
|
|
417
|
+
if (document.activeElement !== hex) hex.value = pick.slice(1).toUpperCase();
|
|
418
|
+
|
|
419
|
+
const readout = $("pantone");
|
|
420
|
+
readout.textContent = "";
|
|
421
|
+
const dot = document.createElement("i");
|
|
422
|
+
dot.style.background = snapped.hex;
|
|
423
|
+
readout.append(dot, `Pantone ${snapped.code}`);
|
|
424
|
+
readout.title = `Prints as Pantone ${snapped.code} (${snapped.hex.toUpperCase()})`;
|
|
425
|
+
|
|
426
|
+
const chips = $("chips");
|
|
427
|
+
if (!chips.childElementCount) {
|
|
428
|
+
for (const chip of pantoneChips()) {
|
|
429
|
+
const b = document.createElement("button");
|
|
430
|
+
b.type = "button";
|
|
431
|
+
b.className = "chip";
|
|
432
|
+
b.setAttribute("role", "option");
|
|
433
|
+
b.dataset.hex = chip.hex;
|
|
434
|
+
b.style.background = chip.hex;
|
|
435
|
+
b.title = `Pantone ${chip.code}`;
|
|
436
|
+
b.setAttribute("aria-label", `Pantone ${chip.code}`);
|
|
437
|
+
b.addEventListener("click", () => setColor(chip.hex));
|
|
438
|
+
chips.append(b);
|
|
173
439
|
}
|
|
174
|
-
if (holder) materialSelect.append(holder);
|
|
175
440
|
}
|
|
176
|
-
|
|
177
|
-
|
|
441
|
+
chips.querySelectorAll<HTMLButtonElement>(".chip").forEach((c) => {
|
|
442
|
+
c.setAttribute("aria-selected", String(c.dataset.hex === snapped.hex));
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function renderStyle() {
|
|
447
|
+
const q = state.quote;
|
|
448
|
+
const section = $("style-section");
|
|
449
|
+
section.hidden = !q?.hollow || !!state.staticPrintUrl;
|
|
450
|
+
if (section.hidden) return;
|
|
451
|
+
if (!q?.hollow) return;
|
|
452
|
+
document.querySelectorAll<HTMLButtonElement>(".style[data-style]").forEach((b) => {
|
|
453
|
+
b.setAttribute("aria-checked", String(b.dataset.style === state.style));
|
|
454
|
+
});
|
|
455
|
+
$("price-solid").textContent = q.solid ? `${money(q.solid.unitPrice)} each` : "";
|
|
456
|
+
$("price-hollow").textContent = `${money(q.hollow.unitPrice)} each`;
|
|
457
|
+
$("hollow-pro").hidden = !!q.callerIsPro;
|
|
458
|
+
|
|
459
|
+
const notes = $("style-callouts");
|
|
460
|
+
notes.textContent = "";
|
|
461
|
+
if (state.style === "hollow") {
|
|
462
|
+
const bits: string[] = ["Hollow prints need escape holes placed in the Womp editor, so they're finished on womp.com."];
|
|
463
|
+
if (!q.callerIsPro) bits.push("Hollow pricing needs Womp Pro.");
|
|
464
|
+
if (isClear(selectedMaterial())) bits.push("Solid is best for clear prints: nothing inside shows through.");
|
|
465
|
+
notes.append(callout("info", [bits.join(" ")]));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function renderReview() {
|
|
470
|
+
const q = state.quote;
|
|
471
|
+
const review = $("review");
|
|
472
|
+
review.hidden = !q?.solid;
|
|
473
|
+
if (!q?.solid) return;
|
|
474
|
+
|
|
475
|
+
const qty = $("qty") as HTMLInputElement;
|
|
476
|
+
if (document.activeElement !== qty) qty.value = String(state.quantity);
|
|
477
|
+
($("qty-dec") as HTMLButtonElement).disabled = state.quantity <= 1 || !!state.staticPrintUrl;
|
|
478
|
+
($("qty-inc") as HTMLButtonElement).disabled = state.quantity >= 100 || !!state.staticPrintUrl;
|
|
479
|
+
qty.disabled = !!state.staticPrintUrl;
|
|
480
|
+
|
|
481
|
+
const row = state.style === "hollow" && q.hollow ? q.hollow : q.solid;
|
|
482
|
+
const n = state.quantity;
|
|
483
|
+
$("amount").textContent = money(row.unitPrice * n);
|
|
484
|
+
const was = $("was");
|
|
485
|
+
was.hidden = !(row.discount > 0);
|
|
486
|
+
if (row.discount > 0) was.textContent = money(row.salePrice * n);
|
|
487
|
+
// The saving as a number, not a label: the row's discount folds Pro,
|
|
488
|
+
// Enterprise and bulk savings together, and the card cannot tell which.
|
|
489
|
+
const pill = $("pill");
|
|
490
|
+
pill.hidden = !(row.discount > 0);
|
|
491
|
+
if (row.discount > 0) pill.textContent = `Save ${money(row.discount * n)}`;
|
|
492
|
+
|
|
493
|
+
const sub: string[] = [];
|
|
494
|
+
if (n > 1) sub.push(`${money(row.unitPrice)} each`);
|
|
495
|
+
sub.push(`~${row.productionDays} production day${row.productionDays === 1 ? "" : "s"}`);
|
|
496
|
+
$("price-sub").textContent = sub.join(" · ");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function renderCta() {
|
|
500
|
+
const cta = $("cta") as HTMLButtonElement;
|
|
501
|
+
const label = $("cta-label");
|
|
502
|
+
const q = state.quote;
|
|
503
|
+
cta.hidden = !q?.solid && !state.staticPrintUrl;
|
|
504
|
+
$("note").hidden = cta.hidden;
|
|
505
|
+
if (cta.hidden) return;
|
|
506
|
+
|
|
507
|
+
if (state.staticPrintUrl) {
|
|
508
|
+
label.textContent = "Complete order on womp.com";
|
|
509
|
+
cta.disabled = false;
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const gated = selectedMaterial()?.available === false;
|
|
513
|
+
cta.disabled = state.quoting || overHardLimit() || gated || !q?.solid;
|
|
514
|
+
if (state.style === "hollow") {
|
|
515
|
+
label.textContent = state.preparedUrl ? "Open on womp.com" : "Continue on womp.com";
|
|
516
|
+
} else {
|
|
517
|
+
label.textContent = state.cartCheckoutUrl ? "Checkout on womp.com" : "Add to cart";
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// --- state changes ---------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
function selectMaterial(id: string) {
|
|
524
|
+
const m = state.materials.find((x) => x.id === id);
|
|
525
|
+
if (!m) return;
|
|
526
|
+
if (m.available === false) {
|
|
527
|
+
// The panel keeps the selection and opens the contact dialog; the card keeps
|
|
528
|
+
// the selection and shows the contact note.
|
|
529
|
+
state.gatedTap = m;
|
|
530
|
+
renderMaterials();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
state.gatedTap = undefined;
|
|
534
|
+
if (m.id === state.material) return;
|
|
535
|
+
state.material = m.id;
|
|
536
|
+
state.finish = m.finishes?.length ? m.finishes[0] : undefined;
|
|
537
|
+
state.colorMode = "original";
|
|
538
|
+
// The panel seeds its blue when you switch into Tinted clear; a tint the user
|
|
539
|
+
// already chose on this card is kept, because they chose it.
|
|
540
|
+
if (m.colorRequired && !state.color) state.color = PANEL_DEFAULT_TINT;
|
|
541
|
+
markDirty();
|
|
542
|
+
render();
|
|
543
|
+
scheduleRequote();
|
|
178
544
|
}
|
|
179
545
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
546
|
+
function setColor(hex: string) {
|
|
547
|
+
const norm = normalizeHex(hex);
|
|
548
|
+
if (!norm || norm === state.color) return;
|
|
549
|
+
state.color = norm;
|
|
550
|
+
markDirty();
|
|
551
|
+
renderColor();
|
|
552
|
+
renderCta();
|
|
185
553
|
}
|
|
186
554
|
|
|
187
|
-
function
|
|
555
|
+
function setQuantity(n: number) {
|
|
556
|
+
const next = Math.min(100, Math.max(1, Math.round(n) || 1));
|
|
557
|
+
if (next === state.quantity) {
|
|
558
|
+
renderReview();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
state.quantity = next;
|
|
562
|
+
markDirty();
|
|
563
|
+
renderReview();
|
|
564
|
+
renderCta();
|
|
565
|
+
scheduleRequote(); // discounts can depend on quantity
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* One axis was edited: MCP prints scale uniformly, so the other two follow and
|
|
570
|
+
* the longest side is what gets sent. Clamped to the contract's range, with a
|
|
571
|
+
* notice rather than a silent snap.
|
|
572
|
+
*/
|
|
573
|
+
function commitDim(axis: Axis, raw: string) {
|
|
574
|
+
const d = state.dims;
|
|
575
|
+
const entered = fromUnit(Number(raw), state.unit);
|
|
576
|
+
if (!d || !(entered > 0) || !(d[axis] > 0)) {
|
|
577
|
+
renderSize();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const longest = Math.max(d.width, d.height, d.depth);
|
|
581
|
+
let nextLongest = (longest * entered) / d[axis];
|
|
582
|
+
state.sizeNotice = "";
|
|
583
|
+
if (nextLongest > MAX_LONGEST_CM) {
|
|
584
|
+
nextLongest = MAX_LONGEST_CM;
|
|
585
|
+
state.sizeNotice = `The longest side is capped at ${fmtDim(MAX_LONGEST_CM, state.unit)} ${state.unit}.`;
|
|
586
|
+
} else if (nextLongest < MIN_LONGEST_CM) {
|
|
587
|
+
nextLongest = MIN_LONGEST_CM;
|
|
588
|
+
state.sizeNotice = `The longest side can't go below ${fmtDim(MIN_LONGEST_CM, state.unit)} ${state.unit}.`;
|
|
589
|
+
}
|
|
590
|
+
const k = nextLongest / longest;
|
|
591
|
+
if (Math.abs(k - 1) < 1e-6) {
|
|
592
|
+
renderSize();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
state.dims = { width: d.width * k, height: d.height * k, depth: d.depth * k };
|
|
596
|
+
state.longestSideCm = nextLongest;
|
|
597
|
+
markDirty();
|
|
598
|
+
renderHead();
|
|
599
|
+
renderSize();
|
|
600
|
+
renderCta();
|
|
601
|
+
scheduleRequote();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// --- talking to the server -------------------------------------------------
|
|
605
|
+
|
|
606
|
+
function baseArgs() {
|
|
607
|
+
const m = selectedMaterial();
|
|
608
|
+
const snapped = snappedColor();
|
|
188
609
|
return {
|
|
189
|
-
...(source?.generationId ? { generationId: source.generationId } : {}),
|
|
190
|
-
...(source?.glbUrl ? { glbUrl: source.glbUrl } : {}),
|
|
191
|
-
material:
|
|
192
|
-
|
|
193
|
-
|
|
610
|
+
...(state.source?.generationId ? { generationId: state.source.generationId } : {}),
|
|
611
|
+
...(state.source?.glbUrl ? { glbUrl: state.source.glbUrl } : {}),
|
|
612
|
+
material: state.material,
|
|
613
|
+
...(m?.finishes?.length && state.finish ? { finish: state.finish } : {}),
|
|
614
|
+
...(snapped ? { color: snapped.hex } : {}),
|
|
615
|
+
quantity: state.quantity,
|
|
616
|
+
...(state.longestSideCm ? { longestSideCm: Math.round(state.longestSideCm * 100) / 100 } : {}),
|
|
194
617
|
};
|
|
195
618
|
}
|
|
196
619
|
|
|
620
|
+
/** Fold a quote into the state without fighting what the user is doing. */
|
|
621
|
+
function applyQuote(data: PrintQuoteResult) {
|
|
622
|
+
state.quote = data;
|
|
623
|
+
if (data.source) state.source = data.source;
|
|
624
|
+
if (data.materials?.length) state.materials = data.materials;
|
|
625
|
+
if (data.material) {
|
|
626
|
+
state.material = data.material;
|
|
627
|
+
if (!state.materials.some((m) => m.id === data.material)) {
|
|
628
|
+
// An id the catalog has not caught up with yet still has to be selectable.
|
|
629
|
+
state.materials.push({ id: data.material, label: data.materialLabel, available: true });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (data.finish) state.finish = data.finish;
|
|
633
|
+
// The echo is what the card SENT (a snapped hex); the user's exact pick wins
|
|
634
|
+
// when there is one, so the dot never jumps under their finger.
|
|
635
|
+
if (data.color && !state.color) state.color = normalizeHex(data.color);
|
|
636
|
+
if (data.color && selectedMaterial()?.colorOptional) state.colorMode = "custom";
|
|
637
|
+
if (typeof data.quantity === "number") state.quantity = data.quantity;
|
|
638
|
+
if (typeof data.longestSideCm === "number") state.longestSideCm = data.longestSideCm;
|
|
639
|
+
if (data.dimensionsCm) state.dims = data.dimensionsCm;
|
|
640
|
+
state.sizeNotice = "";
|
|
641
|
+
render();
|
|
642
|
+
}
|
|
643
|
+
|
|
197
644
|
async function requote() {
|
|
198
|
-
if (!source || staticPrintUrl) return;
|
|
645
|
+
if (!state.source || state.staticPrintUrl || !state.material) return;
|
|
199
646
|
const seq = ++quoteSeq;
|
|
200
|
-
|
|
201
|
-
orderBtn.disabled = true;
|
|
647
|
+
setQuoting(true);
|
|
202
648
|
setStatus("Updating price…");
|
|
203
649
|
try {
|
|
204
|
-
const res = await callTool(card.app, "estimate_print",
|
|
650
|
+
const res = await callTool(card.app, "estimate_print", baseArgs());
|
|
205
651
|
if (seq !== quoteSeq) return; // superseded by a newer knob turn
|
|
652
|
+
setQuoting(false);
|
|
206
653
|
if (res.isError) {
|
|
207
|
-
|
|
208
|
-
$("card").classList.remove("quoting");
|
|
209
|
-
orderBtn.disabled = false;
|
|
210
|
-
setStatus(String(text), true);
|
|
654
|
+
setStatus(friendlyError(textOf(res) || "Could not update the price."), true);
|
|
211
655
|
return;
|
|
212
656
|
}
|
|
213
|
-
|
|
657
|
+
applyQuote((res.structuredContent || {}) as PrintQuoteResult);
|
|
658
|
+
setStatus("");
|
|
214
659
|
} catch {
|
|
215
660
|
if (seq !== quoteSeq) return;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
setStatus("Could not update the quote — check the connection and try again.", true);
|
|
661
|
+
setQuoting(false);
|
|
662
|
+
setStatus("Could not update the price. Check the connection and try again.", true);
|
|
219
663
|
}
|
|
220
664
|
}
|
|
221
665
|
|
|
222
666
|
function scheduleRequote() {
|
|
223
|
-
// Same idea as the web print panel's debounce: a slider being dragged must
|
|
224
|
-
// not fire an engine estimate per pixel.
|
|
225
667
|
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
|
|
226
|
-
|
|
227
|
-
// what the card shows, so the button goes back to adding.
|
|
228
|
-
cartCheckoutUrl = "";
|
|
229
|
-
$("order-label").textContent = "Add to cart";
|
|
230
|
-
debounceTimer = window.setTimeout(() => void requote(), 600);
|
|
668
|
+
debounceTimer = window.setTimeout(() => void requote(), REQUOTE_DEBOUNCE_MS);
|
|
231
669
|
}
|
|
232
670
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
671
|
+
async function addToCart() {
|
|
672
|
+
const cta = $("cta") as HTMLButtonElement;
|
|
673
|
+
cta.disabled = true;
|
|
674
|
+
$("cta-label").textContent = "Adding to cart…";
|
|
675
|
+
setStatus("");
|
|
676
|
+
try {
|
|
677
|
+
const res = await callTool(card.app, "add_to_cart", baseArgs());
|
|
678
|
+
const data = (res.structuredContent || {}) as { payUrl?: string; items?: unknown[]; subtotal?: number };
|
|
679
|
+
if (res.isError || !data.payUrl) {
|
|
680
|
+
setStatus(friendlyError(textOf(res) || "Could not add to the cart."), true);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
state.cartCheckoutUrl = data.payUrl;
|
|
684
|
+
const count = data.items?.length ?? 1;
|
|
685
|
+
setStatus(
|
|
686
|
+
`Added. Your cart has ${count} item${count === 1 ? "" : "s"}, ${money(data.subtotal ?? 0)} before shipping. ` +
|
|
687
|
+
"Shipping and promo codes apply at checkout.",
|
|
688
|
+
);
|
|
689
|
+
} catch {
|
|
690
|
+
setStatus("Could not add to the cart. Try again.", true);
|
|
691
|
+
} finally {
|
|
692
|
+
renderCta();
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Hollow: create the print project and open it — the editor is where escape holes go. */
|
|
697
|
+
async function continueOnWomp() {
|
|
698
|
+
const cta = $("cta") as HTMLButtonElement;
|
|
699
|
+
cta.disabled = true;
|
|
700
|
+
$("cta-label").textContent = "Setting up…";
|
|
701
|
+
setStatus("");
|
|
702
|
+
try {
|
|
703
|
+
const res = await callTool(card.app, "prepare_print", baseArgs());
|
|
704
|
+
const data = (res.structuredContent || {}) as { printUrl?: string };
|
|
705
|
+
if (res.isError || !data.printUrl) {
|
|
706
|
+
setStatus(friendlyError(textOf(res) || "Could not set the print up."), true);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
state.preparedUrl = data.printUrl;
|
|
710
|
+
void openLink(card.app, data.printUrl);
|
|
711
|
+
setStatus("Opened on womp.com. Choose Hollow there, place the escape holes, and check out.");
|
|
712
|
+
} catch {
|
|
713
|
+
setStatus("Could not set the print up. Try again.", true);
|
|
714
|
+
} finally {
|
|
715
|
+
renderCta();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// --- wiring ----------------------------------------------------------------
|
|
239
720
|
|
|
240
721
|
const card = createCard("Womp print", {
|
|
241
|
-
onTimeout: () => setStatus("Still measuring
|
|
722
|
+
onTimeout: () => setStatus("Still measuring. Ask me to check on it."),
|
|
242
723
|
onInput: (args) => {
|
|
243
724
|
// Seed the controls from what the model asked for, before any result: the
|
|
244
|
-
// user sees "
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
725
|
+
// user sees "Tinted clear, glossy, 15 cm, two copies" selected while the
|
|
726
|
+
// first quote computes.
|
|
727
|
+
if (typeof args.material === "string") state.material = args.material;
|
|
728
|
+
if (typeof args.finish === "string") state.finish = args.finish.toLowerCase();
|
|
729
|
+
if (typeof args.color === "string") state.color = normalizeHex(args.color) || state.color;
|
|
730
|
+
if (typeof args.quantity === "number") state.quantity = args.quantity;
|
|
731
|
+
if (typeof args.longestSideCm === "number") state.longestSideCm = args.longestSideCm;
|
|
732
|
+
if (args.hollow === true) state.style = "hollow";
|
|
250
733
|
},
|
|
251
734
|
onResult: (raw: GenerationResult) => {
|
|
252
735
|
const data = raw as PrintQuoteResult & GenerationResult;
|
|
253
736
|
// ontoolinput sends {status: "running"} while the engine measures; a print
|
|
254
737
|
// result itself never carries `status`.
|
|
255
738
|
if (data.status === "running") {
|
|
256
|
-
|
|
739
|
+
setQuoting(true);
|
|
257
740
|
setStatus("Measuring mesh…");
|
|
258
741
|
return;
|
|
259
742
|
}
|
|
260
|
-
|
|
743
|
+
setQuoting(false);
|
|
261
744
|
if (data.printUrl) {
|
|
262
|
-
// A model-initiated prepare_print:
|
|
263
|
-
staticPrintUrl = data.printUrl;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
745
|
+
// A model-initiated prepare_print: a receipt, not a configurator.
|
|
746
|
+
state.staticPrintUrl = data.printUrl;
|
|
747
|
+
applyQuote(data);
|
|
748
|
+
for (const id of ["size-section", "material-section", "style-section"]) $(id).hidden = true;
|
|
749
|
+
const m = selectedMaterial();
|
|
750
|
+
const bits = [materialLabel(m)];
|
|
751
|
+
if (state.finish && m?.finishes?.length) bits.push(capitalize(state.finish));
|
|
752
|
+
if (data.color) bits.push(`Pantone ${nearestPantone(data.color).code}`);
|
|
753
|
+
if (state.longestSideCm) bits.push(`${fmtDim(state.longestSideCm, state.unit)} ${state.unit} longest side`);
|
|
754
|
+
$("summary").textContent = bits.join(" · ");
|
|
755
|
+
$("summary").hidden = false;
|
|
756
|
+
setStatus("");
|
|
757
|
+
return;
|
|
269
758
|
}
|
|
270
|
-
|
|
271
|
-
|
|
759
|
+
applyQuote(data);
|
|
760
|
+
for (const id of ["size-section", "material-section"]) $(id).hidden = !state.materials.length;
|
|
761
|
+
setStatus("");
|
|
272
762
|
},
|
|
273
763
|
});
|
|
274
764
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
765
|
+
document.querySelectorAll<HTMLButtonElement>("#unit [data-unit]").forEach((b) =>
|
|
766
|
+
b.addEventListener("click", () => {
|
|
767
|
+
state.unit = b.dataset.unit as Unit;
|
|
768
|
+
renderHead();
|
|
769
|
+
renderSize();
|
|
770
|
+
}),
|
|
771
|
+
);
|
|
772
|
+
for (const axis of AXES) {
|
|
773
|
+
const input = $(`dim-${axis}`) as HTMLInputElement;
|
|
774
|
+
input.addEventListener("change", () => commitDim(axis, input.value));
|
|
775
|
+
input.addEventListener("keydown", (e) => {
|
|
776
|
+
if (e.key === "Enter") input.blur();
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
$("enterprise-toggle").addEventListener("click", () => {
|
|
781
|
+
state.enterpriseOpen = !state.enterpriseOpen;
|
|
782
|
+
// Closing the group is how the note about a gated material is dismissed.
|
|
783
|
+
if (!state.enterpriseOpen) state.gatedTap = undefined;
|
|
784
|
+
renderMaterials();
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
document.querySelectorAll<HTMLButtonElement>("#color-mode [data-mode]").forEach((b) =>
|
|
788
|
+
b.addEventListener("click", () => {
|
|
789
|
+
const mode = b.dataset.mode as "original" | "custom";
|
|
790
|
+
if (state.colorMode === mode) return;
|
|
791
|
+
state.colorMode = mode;
|
|
792
|
+
if (mode === "custom" && !state.color) state.color = PANEL_DEFAULT_TINT;
|
|
793
|
+
markDirty();
|
|
794
|
+
renderSubControls();
|
|
795
|
+
renderCta();
|
|
796
|
+
}),
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
const native = $("color-native") as HTMLInputElement;
|
|
800
|
+
native.addEventListener("input", () => setColor(native.value));
|
|
801
|
+
|
|
802
|
+
const hexInput = $("color-hex") as HTMLInputElement;
|
|
803
|
+
hexInput.addEventListener("input", () => {
|
|
804
|
+
hexInput.value = hexInput.value.replace(/[^0-9a-fA-F]/g, "").toUpperCase().slice(0, 6);
|
|
805
|
+
if (hexInput.value.length === 6) setColor(`#${hexInput.value}`);
|
|
806
|
+
});
|
|
807
|
+
const commitHex = () => {
|
|
808
|
+
const expanded = expandHexInput(hexInput.value);
|
|
809
|
+
if (expanded) setColor(expanded);
|
|
810
|
+
renderColor();
|
|
811
|
+
};
|
|
812
|
+
hexInput.addEventListener("blur", commitHex);
|
|
813
|
+
hexInput.addEventListener("keydown", (e) => {
|
|
814
|
+
if (e.key === "Enter") {
|
|
815
|
+
commitHex();
|
|
816
|
+
hexInput.blur();
|
|
283
817
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
document.querySelectorAll<HTMLButtonElement>(".style[data-style]").forEach((b) =>
|
|
821
|
+
b.addEventListener("click", () => {
|
|
822
|
+
const style = b.dataset.style as Style;
|
|
823
|
+
if (state.style === style) return;
|
|
824
|
+
state.style = style;
|
|
825
|
+
markDirty();
|
|
826
|
+
renderStyle();
|
|
827
|
+
renderReview();
|
|
828
|
+
renderCta();
|
|
829
|
+
}),
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
const qtyInput = $("qty") as HTMLInputElement;
|
|
833
|
+
qtyInput.addEventListener("change", () => setQuantity(Number(qtyInput.value)));
|
|
834
|
+
$("qty-dec").addEventListener("click", () => setQuantity(state.quantity - 1));
|
|
835
|
+
$("qty-inc").addEventListener("click", () => setQuantity(state.quantity + 1));
|
|
836
|
+
|
|
837
|
+
$("cta").addEventListener("click", () => {
|
|
838
|
+
if (state.staticPrintUrl) return void openLink(card.app, state.staticPrintUrl);
|
|
839
|
+
if (state.style === "hollow") {
|
|
840
|
+
if (state.preparedUrl) return void openLink(card.app, state.preparedUrl);
|
|
841
|
+
return void continueOnWomp();
|
|
308
842
|
}
|
|
843
|
+
if (state.cartCheckoutUrl) return void openLink(card.app, state.cartCheckoutUrl);
|
|
844
|
+
if (!state.source) return;
|
|
845
|
+
void addToCart();
|
|
309
846
|
});
|