@solumflow-app/crm-mcp-server 0.1.0 → 0.3.0
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/dist/index.js +1937 -19
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { createClient } from "@solumflow-app/crm-client";
|
|
7
7
|
|
|
8
|
-
// src/
|
|
8
|
+
// src/catalog-tools.ts
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
|
|
11
11
|
// src/tool-answers.ts
|
|
@@ -46,13 +46,1928 @@ ${snippet}
|
|
|
46
46
|
\`\`\``;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// src/catalog-tools.ts
|
|
50
|
+
var CONTENT_BLOCKS = 'Besides plain values (text, number, choice), a product can carry content blocks, three field types: "content_text" is { html, links? } with already-sanitised html; "cta" is { heading, body, primary: {label,url}, secondary? }, a call to action; "repeater" is { items: [{ id, ...one value per item field }] } and covers lists, accordions (FAQ), downloads and testimonials. A repeater\u2019s field says how to draw it: "display" is "list" or "accordion", "preset" names where it started (list, faq, downloads, testimonials), "itemFields" is the structure of one item (key, label, type: text, richtext, url {label,url}, icon {set,name}, image or file {url,name,size,contentType}) and "titleKey" is the item field that names an item, such as the question of a FAQ. A "configurator" is a 3D product configurator: a listing carries only { configurator: true }, a product\u2019s own page the whole document (see read_configurator). A block that is public but empty is null; a block that is not public is not there at all.';
|
|
51
|
+
var slugOrId = z.string().min(1).max(200).describe("The address (slug) or the id of the product.");
|
|
52
|
+
function registerCatalogTools(server, client) {
|
|
53
|
+
server.registerTool(
|
|
54
|
+
"list_catalog_fields",
|
|
55
|
+
{
|
|
56
|
+
title: "List the public fields of the catalogue",
|
|
57
|
+
description: "The custom fields the catalogue publishes on its products, with their type, choices and, for a content block, how it is built. Read this once before building a product page: it says which blocks exist and what each one looks like. " + CONTENT_BLOCKS,
|
|
58
|
+
inputSchema: {},
|
|
59
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
60
|
+
},
|
|
61
|
+
async () => {
|
|
62
|
+
try {
|
|
63
|
+
const fields = await client.getFields();
|
|
64
|
+
return answer(
|
|
65
|
+
fields.length === 0 ? "The catalogue publishes no custom fields." : fields.map(describeField).join("\n"),
|
|
66
|
+
fields
|
|
67
|
+
);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return refusal(error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
server.registerTool(
|
|
74
|
+
"get_product",
|
|
75
|
+
{
|
|
76
|
+
title: "Read one product",
|
|
77
|
+
description: "One product as its page needs it: name, prices, pictures, categories, variants and `fields`, keyed by field slug. May be a minute old. Answers that it is not found for an unknown product. " + CONTENT_BLOCKS,
|
|
78
|
+
inputSchema: { slugOrId },
|
|
79
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
80
|
+
},
|
|
81
|
+
async ({ slugOrId: wanted }) => {
|
|
82
|
+
try {
|
|
83
|
+
const product = await client.getProduct(wanted);
|
|
84
|
+
if (!product) {
|
|
85
|
+
return answer(`No product "${wanted}" on this account.`, null);
|
|
86
|
+
}
|
|
87
|
+
const slugs = Object.keys(product.fields);
|
|
88
|
+
return answer(
|
|
89
|
+
`${product.name} (${product.slug}), ${slugs.length} public fields: ${slugs.join(", ") || "none"}.`,
|
|
90
|
+
product
|
|
91
|
+
);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return refusal(error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
function describeField(field) {
|
|
99
|
+
if (field.type !== "repeater") {
|
|
100
|
+
return `- ${field.slug}: ${field.label}, ${field.type}`;
|
|
101
|
+
}
|
|
102
|
+
const items = (field.itemFields ?? []).map(({ key, type }) => `${key}:${type}`).join(", ");
|
|
103
|
+
const preset = field.preset ? `, ${field.preset}` : "";
|
|
104
|
+
const title = field.titleKey ? `; titled by ${field.titleKey}` : "";
|
|
105
|
+
return `- ${field.slug}: ${field.label}, repeater (${field.display ?? "list"}${preset}; items: ${items}${title})`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/configurator-tools.ts
|
|
109
|
+
import { z as z5 } from "zod";
|
|
110
|
+
|
|
111
|
+
// ../features/product-configurator/src/shared/configurator-limits.ts
|
|
112
|
+
var CONFIGURATOR_LIMITS = {
|
|
113
|
+
structureBytes: 64 * 1024,
|
|
114
|
+
valueBytes: 32 * 1024,
|
|
115
|
+
parameters: 60,
|
|
116
|
+
tabs: 12,
|
|
117
|
+
choiceLists: 40,
|
|
118
|
+
optionsPerList: 60,
|
|
119
|
+
materials: 120,
|
|
120
|
+
priceGroups: 20,
|
|
121
|
+
amounts: 20,
|
|
122
|
+
rules: 80,
|
|
123
|
+
priceRules: 20,
|
|
124
|
+
presets: 60,
|
|
125
|
+
formulaChars: 200,
|
|
126
|
+
formulaNodes: 64,
|
|
127
|
+
formulaDepth: 8,
|
|
128
|
+
geometryInputs: 60,
|
|
129
|
+
geometryConstants: 60,
|
|
130
|
+
geometrySlots: 20,
|
|
131
|
+
geometryNodes: 200,
|
|
132
|
+
expandedParts: 200,
|
|
133
|
+
repeatCount: 12,
|
|
134
|
+
repeatDepth: 2,
|
|
135
|
+
annotations: 30
|
|
136
|
+
};
|
|
137
|
+
function jsonByteLength(value) {
|
|
138
|
+
return new TextEncoder().encode(JSON.stringify(value)).length + jsonbSpacing(value);
|
|
139
|
+
}
|
|
140
|
+
function jsonbSpacing(value) {
|
|
141
|
+
if (Array.isArray(value)) {
|
|
142
|
+
return Math.max(0, value.length - 1) + value.reduce((sum, item) => sum + jsonbSpacing(item), 0);
|
|
143
|
+
}
|
|
144
|
+
if (value !== null && typeof value === "object") {
|
|
145
|
+
const entries = Object.values(value).filter(
|
|
146
|
+
(item) => item !== void 0
|
|
147
|
+
);
|
|
148
|
+
return entries.length + Math.max(0, entries.length - 1) + entries.reduce((sum, item) => sum + jsonbSpacing(item), 0);
|
|
149
|
+
}
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ../features/product-configurator/src/shared/configurator-issue.ts
|
|
154
|
+
function issue(path, code, params) {
|
|
155
|
+
return params ? { path, code, params } : { path, code };
|
|
156
|
+
}
|
|
157
|
+
function formatIssuePath(path) {
|
|
158
|
+
return path.reduce((text, part) => {
|
|
159
|
+
if (typeof part === "number") {
|
|
160
|
+
return `${text}[${part}]`;
|
|
161
|
+
}
|
|
162
|
+
return text ? `${text}.${part}` : part;
|
|
163
|
+
}, "");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ../features/product-configurator/src/shared/evaluate-formula.ts
|
|
167
|
+
var FUNCTIONS = ["min", "max", "floor", "ceil", "round", "abs"];
|
|
168
|
+
var NAME_START = /[a-z_]/;
|
|
169
|
+
var NAME_PART = /[a-z0-9_]/;
|
|
170
|
+
function tokenize(source) {
|
|
171
|
+
const tokens = [];
|
|
172
|
+
let index = 0;
|
|
173
|
+
while (index < source.length) {
|
|
174
|
+
const char = source.charAt(index);
|
|
175
|
+
if (char === " " || char === " " || char === "\n") {
|
|
176
|
+
index += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (/[0-9.]/.test(char)) {
|
|
180
|
+
let end = index;
|
|
181
|
+
while (end < source.length && /[0-9.]/.test(source.charAt(end))) {
|
|
182
|
+
end += 1;
|
|
183
|
+
}
|
|
184
|
+
const text = source.slice(index, end);
|
|
185
|
+
if (!/^(\d+(\.\d+)?|\.\d+)$/.test(text)) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
tokens.push({ type: "number", value: Number(text) });
|
|
189
|
+
index = end;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (NAME_START.test(char)) {
|
|
193
|
+
let end = index;
|
|
194
|
+
while (end < source.length && NAME_PART.test(source.charAt(end))) {
|
|
195
|
+
end += 1;
|
|
196
|
+
}
|
|
197
|
+
tokens.push({ type: "name", value: source.slice(index, end) });
|
|
198
|
+
index = end;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const operator = char === "\xD7" ? "*" : char === "\xF7" ? "/" : char === "\u2212" ? "-" : char;
|
|
202
|
+
if (operator === "+" || operator === "-" || operator === "*" || operator === "/" || operator === "(" || operator === ")" || operator === ",") {
|
|
203
|
+
tokens.push({ type: "op", value: operator });
|
|
204
|
+
index += 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
return tokens;
|
|
210
|
+
}
|
|
211
|
+
var FormulaParser = class {
|
|
212
|
+
constructor(tokens) {
|
|
213
|
+
this.tokens = tokens;
|
|
214
|
+
this.position = 0;
|
|
215
|
+
this.nodes = 0;
|
|
216
|
+
this.depth = 0;
|
|
217
|
+
this.names = [];
|
|
218
|
+
}
|
|
219
|
+
parse() {
|
|
220
|
+
const node = this.expression();
|
|
221
|
+
if (this.position !== this.tokens.length) {
|
|
222
|
+
throw new FormulaSyntaxError("formula_syntax");
|
|
223
|
+
}
|
|
224
|
+
return node;
|
|
225
|
+
}
|
|
226
|
+
count(node) {
|
|
227
|
+
this.nodes += 1;
|
|
228
|
+
if (this.nodes > CONFIGURATOR_LIMITS.formulaNodes) {
|
|
229
|
+
throw new FormulaSyntaxError("formula_too_many_nodes");
|
|
230
|
+
}
|
|
231
|
+
return node;
|
|
232
|
+
}
|
|
233
|
+
peek() {
|
|
234
|
+
return this.tokens[this.position];
|
|
235
|
+
}
|
|
236
|
+
takeOperator(value) {
|
|
237
|
+
const token = this.peek();
|
|
238
|
+
if (token?.type === "op" && token.value === value) {
|
|
239
|
+
this.position += 1;
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
expression() {
|
|
245
|
+
let left = this.term();
|
|
246
|
+
for (; ; ) {
|
|
247
|
+
if (this.takeOperator("+")) {
|
|
248
|
+
left = this.count({
|
|
249
|
+
kind: "binary",
|
|
250
|
+
operator: "+",
|
|
251
|
+
left,
|
|
252
|
+
right: this.term()
|
|
253
|
+
});
|
|
254
|
+
} else if (this.takeOperator("-")) {
|
|
255
|
+
left = this.count({
|
|
256
|
+
kind: "binary",
|
|
257
|
+
operator: "-",
|
|
258
|
+
left,
|
|
259
|
+
right: this.term()
|
|
260
|
+
});
|
|
261
|
+
} else {
|
|
262
|
+
return left;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
term() {
|
|
267
|
+
let left = this.unary();
|
|
268
|
+
for (; ; ) {
|
|
269
|
+
if (this.takeOperator("*")) {
|
|
270
|
+
left = this.count({
|
|
271
|
+
kind: "binary",
|
|
272
|
+
operator: "*",
|
|
273
|
+
left,
|
|
274
|
+
right: this.unary()
|
|
275
|
+
});
|
|
276
|
+
} else if (this.takeOperator("/")) {
|
|
277
|
+
left = this.count({
|
|
278
|
+
kind: "binary",
|
|
279
|
+
operator: "/",
|
|
280
|
+
left,
|
|
281
|
+
right: this.unary()
|
|
282
|
+
});
|
|
283
|
+
} else {
|
|
284
|
+
return left;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
unary() {
|
|
289
|
+
if (this.takeOperator("-")) {
|
|
290
|
+
return this.count({ kind: "negate", operand: this.unary() });
|
|
291
|
+
}
|
|
292
|
+
if (this.takeOperator("+")) {
|
|
293
|
+
return this.unary();
|
|
294
|
+
}
|
|
295
|
+
return this.primary();
|
|
296
|
+
}
|
|
297
|
+
nested(read) {
|
|
298
|
+
this.depth += 1;
|
|
299
|
+
if (this.depth > CONFIGURATOR_LIMITS.formulaDepth) {
|
|
300
|
+
throw new FormulaSyntaxError("formula_too_deep");
|
|
301
|
+
}
|
|
302
|
+
const result = read();
|
|
303
|
+
this.depth -= 1;
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
primary() {
|
|
307
|
+
const token = this.peek();
|
|
308
|
+
if (!token) {
|
|
309
|
+
throw new FormulaSyntaxError("formula_syntax");
|
|
310
|
+
}
|
|
311
|
+
if (token.type === "number") {
|
|
312
|
+
this.position += 1;
|
|
313
|
+
return this.count({ kind: "number", value: token.value });
|
|
314
|
+
}
|
|
315
|
+
if (token.type === "name") {
|
|
316
|
+
this.position += 1;
|
|
317
|
+
if (this.takeOperator("(")) {
|
|
318
|
+
if (!FUNCTIONS.includes(token.value)) {
|
|
319
|
+
throw new FormulaSyntaxError("formula_unknown_function", token.value);
|
|
320
|
+
}
|
|
321
|
+
const fn = token.value;
|
|
322
|
+
return this.nested(() => {
|
|
323
|
+
const args = [this.expression()];
|
|
324
|
+
while (this.takeOperator(",")) {
|
|
325
|
+
args.push(this.expression());
|
|
326
|
+
}
|
|
327
|
+
if (!this.takeOperator(")")) {
|
|
328
|
+
throw new FormulaSyntaxError("formula_syntax");
|
|
329
|
+
}
|
|
330
|
+
const variadic = fn === "min" || fn === "max";
|
|
331
|
+
if (!variadic && args.length !== 1) {
|
|
332
|
+
throw new FormulaSyntaxError("formula_syntax", fn);
|
|
333
|
+
}
|
|
334
|
+
return this.count({ kind: "call", fn, args });
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
if (!this.names.includes(token.value)) {
|
|
338
|
+
this.names.push(token.value);
|
|
339
|
+
}
|
|
340
|
+
return this.count({ kind: "name", name: token.value });
|
|
341
|
+
}
|
|
342
|
+
if (token.value === "(") {
|
|
343
|
+
this.position += 1;
|
|
344
|
+
return this.nested(() => {
|
|
345
|
+
const inner = this.expression();
|
|
346
|
+
if (!this.takeOperator(")")) {
|
|
347
|
+
throw new FormulaSyntaxError("formula_syntax");
|
|
348
|
+
}
|
|
349
|
+
return inner;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
throw new FormulaSyntaxError("formula_syntax");
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
var FormulaSyntaxError = class extends Error {
|
|
356
|
+
constructor(code, detail) {
|
|
357
|
+
super(code);
|
|
358
|
+
this.code = code;
|
|
359
|
+
this.detail = detail;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
var parsedCache = /* @__PURE__ */ new Map();
|
|
363
|
+
function parseFormula(source) {
|
|
364
|
+
if (typeof source === "number") {
|
|
365
|
+
if (!Number.isFinite(source)) {
|
|
366
|
+
return { ok: false, code: "formula_syntax" };
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
formula: { source, root: { kind: "number", value: source }, names: [] }
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const cached = parsedCache.get(source);
|
|
374
|
+
if (cached) {
|
|
375
|
+
return cached;
|
|
376
|
+
}
|
|
377
|
+
const result = parseUncached(source);
|
|
378
|
+
if (parsedCache.size > 2e3) {
|
|
379
|
+
parsedCache.clear();
|
|
380
|
+
}
|
|
381
|
+
parsedCache.set(source, result);
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
function parseUncached(source) {
|
|
385
|
+
if (source.length > CONFIGURATOR_LIMITS.formulaChars) {
|
|
386
|
+
return { ok: false, code: "formula_too_long" };
|
|
387
|
+
}
|
|
388
|
+
const tokens = tokenize(source);
|
|
389
|
+
if (!tokens || tokens.length === 0) {
|
|
390
|
+
return { ok: false, code: "formula_syntax" };
|
|
391
|
+
}
|
|
392
|
+
const parser = new FormulaParser(tokens);
|
|
393
|
+
try {
|
|
394
|
+
const root = parser.parse();
|
|
395
|
+
return { ok: true, formula: { source, root, names: parser.names } };
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (error instanceof FormulaSyntaxError) {
|
|
398
|
+
return { ok: false, code: error.code, detail: error.detail };
|
|
399
|
+
}
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function evaluateFormula(formula, scope) {
|
|
404
|
+
for (const name of formula.names) {
|
|
405
|
+
if (!Object.prototype.hasOwnProperty.call(scope, name)) {
|
|
406
|
+
return { ok: false, code: "formula_unknown_name", name };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const value = evaluateNode(formula.root, scope);
|
|
410
|
+
if (!Number.isFinite(value)) {
|
|
411
|
+
return { ok: false, code: "formula_not_finite" };
|
|
412
|
+
}
|
|
413
|
+
return { ok: true, value };
|
|
414
|
+
}
|
|
415
|
+
function evaluateNode(node, scope) {
|
|
416
|
+
switch (node.kind) {
|
|
417
|
+
case "number":
|
|
418
|
+
return node.value;
|
|
419
|
+
case "name":
|
|
420
|
+
return scope[node.name] ?? Number.NaN;
|
|
421
|
+
case "negate":
|
|
422
|
+
return -evaluateNode(node.operand, scope);
|
|
423
|
+
case "binary":
|
|
424
|
+
return applyOperator(
|
|
425
|
+
node.operator,
|
|
426
|
+
evaluateNode(node.left, scope),
|
|
427
|
+
evaluateNode(node.right, scope)
|
|
428
|
+
);
|
|
429
|
+
case "call":
|
|
430
|
+
return applyFunction(
|
|
431
|
+
node.fn,
|
|
432
|
+
node.args.map((arg) => evaluateNode(arg, scope))
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function applyOperator(operator, left, right) {
|
|
437
|
+
switch (operator) {
|
|
438
|
+
case "+":
|
|
439
|
+
return left + right;
|
|
440
|
+
case "-":
|
|
441
|
+
return left - right;
|
|
442
|
+
case "*":
|
|
443
|
+
return left * right;
|
|
444
|
+
case "/":
|
|
445
|
+
return left / right;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function applyFunction(fn, args) {
|
|
449
|
+
const first = args[0] ?? Number.NaN;
|
|
450
|
+
switch (fn) {
|
|
451
|
+
case "min":
|
|
452
|
+
return Math.min(...args);
|
|
453
|
+
case "max":
|
|
454
|
+
return Math.max(...args);
|
|
455
|
+
case "floor":
|
|
456
|
+
return Math.floor(first);
|
|
457
|
+
case "ceil":
|
|
458
|
+
return Math.ceil(first);
|
|
459
|
+
case "round":
|
|
460
|
+
return Math.round(first);
|
|
461
|
+
case "abs":
|
|
462
|
+
return Math.abs(first);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function formulaNames(source) {
|
|
466
|
+
const parsed = parseFormula(source);
|
|
467
|
+
return parsed.ok ? { ok: true, names: parsed.formula.names } : parsed;
|
|
468
|
+
}
|
|
469
|
+
function evaluateFormulaSource(source, scope) {
|
|
470
|
+
const parsed = parseFormula(source);
|
|
471
|
+
return parsed.ok ? evaluateFormula(parsed.formula, scope) : parsed;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ../features/product-configurator/src/shared/geometry-document.schema.ts
|
|
475
|
+
import { z as z2 } from "zod";
|
|
476
|
+
var CONFIGURATOR_NAME_PATTERN = /^[a-z][a-z0-9_]{0,39}$/;
|
|
477
|
+
var CONFIGURATOR_CODE_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
478
|
+
var ConfiguratorNameSchema = z2.string().regex(CONFIGURATOR_NAME_PATTERN, "configurator.invalidName");
|
|
479
|
+
var ConfiguratorCodeSchema = z2.string().regex(CONFIGURATOR_CODE_PATTERN, "configurator.invalidCode");
|
|
480
|
+
var FormulaSchema = z2.union([
|
|
481
|
+
z2.number().finite(),
|
|
482
|
+
z2.string().trim().min(1).max(CONFIGURATOR_LIMITS.formulaChars, "configurator.formulaTooLong")
|
|
483
|
+
]);
|
|
484
|
+
var Vector3Schema = z2.tuple([FormulaSchema, FormulaSchema, FormulaSchema]);
|
|
485
|
+
var PRIMITIVE_TYPES = [
|
|
486
|
+
"box",
|
|
487
|
+
"rounded_top_box",
|
|
488
|
+
"stadium_prism",
|
|
489
|
+
"arc_panel",
|
|
490
|
+
"led_strip",
|
|
491
|
+
"niche_panel"
|
|
492
|
+
];
|
|
493
|
+
var PRIMITIVE_PARAMS = {
|
|
494
|
+
box: { required: [], optional: [] },
|
|
495
|
+
// The front top edge, rounded over its whole width.
|
|
496
|
+
rounded_top_box: { required: ["radius"], optional: [] },
|
|
497
|
+
// A stadium (a rectangle with half-round ends) seen from the front,
|
|
498
|
+
// extruded over the depth.
|
|
499
|
+
stadium_prism: { required: [], optional: [] },
|
|
500
|
+
// A quarter of an elliptical shell in plan view, filling the block's width
|
|
501
|
+
// and depth; `mirror` 0 bends it towards the right, 1 towards the left.
|
|
502
|
+
arc_panel: { required: ["thickness"], optional: ["mirror"] },
|
|
503
|
+
// A thin strip that gives light.
|
|
504
|
+
led_strip: { required: [], optional: [] },
|
|
505
|
+
// A wall with a rectangular opening, measured from the block's own corner.
|
|
506
|
+
niche_panel: {
|
|
507
|
+
required: ["niche_x", "niche_y", "niche_width", "niche_height"],
|
|
508
|
+
optional: []
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
var PrimitiveNodeSchema = z2.strictObject({
|
|
512
|
+
id: ConfiguratorNameSchema,
|
|
513
|
+
type: z2.enum(PRIMITIVE_TYPES),
|
|
514
|
+
slot: ConfiguratorNameSchema,
|
|
515
|
+
size: Vector3Schema,
|
|
516
|
+
at: Vector3Schema,
|
|
517
|
+
visible: FormulaSchema.optional(),
|
|
518
|
+
params: z2.record(ConfiguratorNameSchema, FormulaSchema).optional()
|
|
519
|
+
});
|
|
520
|
+
var GeometryNodeSchema = z2.lazy(
|
|
521
|
+
() => z2.union([
|
|
522
|
+
PrimitiveNodeSchema,
|
|
523
|
+
z2.strictObject({
|
|
524
|
+
id: ConfiguratorNameSchema,
|
|
525
|
+
type: z2.literal("repeat"),
|
|
526
|
+
count: FormulaSchema,
|
|
527
|
+
index: z2.enum(["i", "j"]).optional(),
|
|
528
|
+
visible: FormulaSchema.optional(),
|
|
529
|
+
children: z2.array(GeometryNodeSchema).min(1)
|
|
530
|
+
})
|
|
531
|
+
])
|
|
532
|
+
);
|
|
533
|
+
var GeometryBindingSchema = z2.strictObject({
|
|
534
|
+
param: ConfiguratorNameSchema,
|
|
535
|
+
map: z2.record(ConfiguratorCodeSchema, z2.number().finite()).optional()
|
|
536
|
+
});
|
|
537
|
+
var GeometryInputSchema = z2.strictObject({
|
|
538
|
+
name: ConfiguratorNameSchema,
|
|
539
|
+
binding: GeometryBindingSchema
|
|
540
|
+
});
|
|
541
|
+
var GeometrySlotSchema = z2.strictObject({
|
|
542
|
+
name: ConfiguratorNameSchema,
|
|
543
|
+
param: ConfiguratorNameSchema.optional(),
|
|
544
|
+
material: ConfiguratorCodeSchema.optional(),
|
|
545
|
+
color: z2.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor").optional()
|
|
546
|
+
});
|
|
547
|
+
var GeometryAnnotationSchema = z2.strictObject({
|
|
548
|
+
id: ConfiguratorNameSchema,
|
|
549
|
+
axis: z2.enum(["x", "y"]),
|
|
550
|
+
from: FormulaSchema,
|
|
551
|
+
to: FormulaSchema,
|
|
552
|
+
at: FormulaSchema,
|
|
553
|
+
label: z2.string().trim().min(1).max(80).optional()
|
|
554
|
+
});
|
|
555
|
+
var GeometryDocumentSchema = z2.strictObject({
|
|
556
|
+
/** Rises with every new upload, so a product page can say which one it shows. */
|
|
557
|
+
version: z2.number().int().min(1),
|
|
558
|
+
/** The starter document this one was copied from, for information only. */
|
|
559
|
+
startedFrom: z2.string().trim().max(80).optional(),
|
|
560
|
+
inputs: z2.array(GeometryInputSchema).max(CONFIGURATOR_LIMITS.geometryInputs),
|
|
561
|
+
constants: z2.record(ConfiguratorNameSchema, z2.number().finite()),
|
|
562
|
+
slots: z2.array(GeometrySlotSchema).min(1).max(CONFIGURATOR_LIMITS.geometrySlots),
|
|
563
|
+
root: z2.array(GeometryNodeSchema).min(1),
|
|
564
|
+
camera: z2.strictObject({
|
|
565
|
+
/** Degrees around the vertical axis, 0 is straight on. */
|
|
566
|
+
azimuth: z2.number().min(-90).max(90),
|
|
567
|
+
/** Degrees above the horizon. */
|
|
568
|
+
elevation: z2.number().min(-10).max(80)
|
|
569
|
+
}).optional(),
|
|
570
|
+
annotations: z2.array(GeometryAnnotationSchema).max(CONFIGURATOR_LIMITS.annotations)
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// ../features/product-configurator/src/shared/geometry-document-problems.ts
|
|
574
|
+
function geometryDocumentProblems(document, context = {}) {
|
|
575
|
+
const base = context.path ?? [];
|
|
576
|
+
const problems = [];
|
|
577
|
+
const constantNames = Object.keys(document.constants);
|
|
578
|
+
if (constantNames.length > CONFIGURATOR_LIMITS.geometryConstants) {
|
|
579
|
+
problems.push(
|
|
580
|
+
issue([...base, "constants"], "configurator.tooMany", {
|
|
581
|
+
max: CONFIGURATOR_LIMITS.geometryConstants
|
|
582
|
+
})
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
const inputNames = /* @__PURE__ */ new Set();
|
|
586
|
+
document.inputs.forEach((input, index) => {
|
|
587
|
+
const path = [...base, "inputs", index];
|
|
588
|
+
if (inputNames.has(input.name) || constantNames.includes(input.name)) {
|
|
589
|
+
problems.push(
|
|
590
|
+
issue([...path, "name"], "configurator.duplicateName", {
|
|
591
|
+
name: input.name
|
|
592
|
+
})
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
if (input.name === "i" || input.name === "j") {
|
|
596
|
+
problems.push(
|
|
597
|
+
issue([...path, "name"], "configurator.reservedName", {
|
|
598
|
+
name: input.name
|
|
599
|
+
})
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
inputNames.add(input.name);
|
|
603
|
+
if (!context.parameters) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const parameter = context.parameters.get(input.binding.param);
|
|
607
|
+
if (!parameter) {
|
|
608
|
+
problems.push(
|
|
609
|
+
issue([...path, "binding", "param"], "configurator.inputUnbound", {
|
|
610
|
+
name: input.name,
|
|
611
|
+
param: input.binding.param
|
|
612
|
+
})
|
|
613
|
+
);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (parameter.kind === "material") {
|
|
617
|
+
problems.push(
|
|
618
|
+
issue(
|
|
619
|
+
[...path, "binding", "param"],
|
|
620
|
+
"configurator.inputCannotBindMaterial",
|
|
621
|
+
{
|
|
622
|
+
name: input.name
|
|
623
|
+
}
|
|
624
|
+
)
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
if (parameter.kind === "choice") {
|
|
628
|
+
const missing = (parameter.codes ?? []).filter(
|
|
629
|
+
(code) => input.binding.map?.[code] === void 0
|
|
630
|
+
);
|
|
631
|
+
if (missing.length > 0) {
|
|
632
|
+
problems.push(
|
|
633
|
+
issue(
|
|
634
|
+
[...path, "binding", "map"],
|
|
635
|
+
"configurator.choiceMapIncomplete",
|
|
636
|
+
{
|
|
637
|
+
name: input.name,
|
|
638
|
+
codes: missing.join(", ")
|
|
639
|
+
}
|
|
640
|
+
)
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
} else if (input.binding.map) {
|
|
644
|
+
problems.push(
|
|
645
|
+
issue([...path, "binding", "map"], "configurator.mapOnlyForChoice", {
|
|
646
|
+
name: input.name
|
|
647
|
+
})
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
const slotNames = /* @__PURE__ */ new Set();
|
|
652
|
+
document.slots.forEach((slot, index) => {
|
|
653
|
+
const path = [...base, "slots", index];
|
|
654
|
+
if (slotNames.has(slot.name)) {
|
|
655
|
+
problems.push(
|
|
656
|
+
issue([...path, "name"], "configurator.duplicateName", {
|
|
657
|
+
name: slot.name
|
|
658
|
+
})
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
slotNames.add(slot.name);
|
|
662
|
+
const sources = [slot.param, slot.material, slot.color].filter(
|
|
663
|
+
(source) => source !== void 0
|
|
664
|
+
);
|
|
665
|
+
if (sources.length !== 1) {
|
|
666
|
+
problems.push(
|
|
667
|
+
issue(path, "configurator.slotNeedsOneSource", { name: slot.name })
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
if (slot.param && context.parameters) {
|
|
671
|
+
const parameter = context.parameters.get(slot.param);
|
|
672
|
+
if (parameter?.kind !== "material") {
|
|
673
|
+
problems.push(
|
|
674
|
+
issue([...path, "param"], "configurator.slotParamNotMaterial", {
|
|
675
|
+
param: slot.param
|
|
676
|
+
})
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (slot.material && context.materialCodes && !context.materialCodes.has(slot.material)) {
|
|
681
|
+
problems.push(
|
|
682
|
+
issue([...path, "material"], "configurator.unknownMaterial", {
|
|
683
|
+
code: slot.material
|
|
684
|
+
})
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
const shapeNames = /* @__PURE__ */ new Set([...inputNames, ...constantNames]);
|
|
689
|
+
const ids = /* @__PURE__ */ new Set();
|
|
690
|
+
let declared = 0;
|
|
691
|
+
const checkFormula2 = (formula, path, scope) => {
|
|
692
|
+
const names = formulaNames(formula);
|
|
693
|
+
if (!names.ok) {
|
|
694
|
+
problems.push(issue(path, `configurator.${names.code}`));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
for (const name of names.names) {
|
|
698
|
+
if (!scope.has(name)) {
|
|
699
|
+
problems.push(issue(path, "configurator.unknownName", { name }));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
const walk = (nodes, path, scope, repeatDepth) => {
|
|
704
|
+
for (const node of nodes) {
|
|
705
|
+
const nodePath = [...path, node.id];
|
|
706
|
+
declared += 1;
|
|
707
|
+
if (ids.has(node.id)) {
|
|
708
|
+
problems.push(
|
|
709
|
+
issue([...nodePath, "id"], "configurator.duplicateId", {
|
|
710
|
+
id: node.id
|
|
711
|
+
})
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
ids.add(node.id);
|
|
715
|
+
if (node.visible !== void 0) {
|
|
716
|
+
checkFormula2(node.visible, [...nodePath, "visible"], scope);
|
|
717
|
+
}
|
|
718
|
+
if (node.type === "repeat") {
|
|
719
|
+
checkFormula2(node.count, [...nodePath, "count"], scope);
|
|
720
|
+
if (repeatDepth + 1 > CONFIGURATOR_LIMITS.repeatDepth) {
|
|
721
|
+
problems.push(
|
|
722
|
+
issue(nodePath, "configurator.repeatTooDeep", {
|
|
723
|
+
max: CONFIGURATOR_LIMITS.repeatDepth
|
|
724
|
+
})
|
|
725
|
+
);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
const index = node.index ?? "i";
|
|
729
|
+
if (scope.has(index)) {
|
|
730
|
+
problems.push(
|
|
731
|
+
issue([...nodePath, "index"], "configurator.indexInUse", { index })
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
walk(
|
|
735
|
+
node.children,
|
|
736
|
+
nodePath,
|
|
737
|
+
/* @__PURE__ */ new Set([...scope, index]),
|
|
738
|
+
repeatDepth + 1
|
|
739
|
+
);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (!slotNames.has(node.slot)) {
|
|
743
|
+
problems.push(
|
|
744
|
+
issue([...nodePath, "slot"], "configurator.unknownSlot", {
|
|
745
|
+
slot: node.slot
|
|
746
|
+
})
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
node.size.forEach(
|
|
750
|
+
(formula, axis) => checkFormula2(formula, [...nodePath, "size", axis], scope)
|
|
751
|
+
);
|
|
752
|
+
node.at.forEach(
|
|
753
|
+
(formula, axis) => checkFormula2(formula, [...nodePath, "at", axis], scope)
|
|
754
|
+
);
|
|
755
|
+
const allowed = PRIMITIVE_PARAMS[node.type];
|
|
756
|
+
const given = node.params ?? {};
|
|
757
|
+
for (const required of allowed.required) {
|
|
758
|
+
if (given[required] === void 0) {
|
|
759
|
+
problems.push(
|
|
760
|
+
issue(
|
|
761
|
+
[...nodePath, "params", required],
|
|
762
|
+
"configurator.missingBlockParam",
|
|
763
|
+
{
|
|
764
|
+
type: node.type,
|
|
765
|
+
param: required
|
|
766
|
+
}
|
|
767
|
+
)
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
for (const [key, formula] of Object.entries(given)) {
|
|
772
|
+
if (!allowed.required.includes(key) && !allowed.optional.includes(key)) {
|
|
773
|
+
problems.push(
|
|
774
|
+
issue(
|
|
775
|
+
[...nodePath, "params", key],
|
|
776
|
+
"configurator.unknownBlockParam",
|
|
777
|
+
{
|
|
778
|
+
type: node.type,
|
|
779
|
+
param: key
|
|
780
|
+
}
|
|
781
|
+
)
|
|
782
|
+
);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
checkFormula2(formula, [...nodePath, "params", key], scope);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
walk(document.root, [...base, "root"], shapeNames, 0);
|
|
790
|
+
if (declared > CONFIGURATOR_LIMITS.geometryNodes) {
|
|
791
|
+
problems.push(
|
|
792
|
+
issue([...base, "root"], "configurator.tooMany", {
|
|
793
|
+
max: CONFIGURATOR_LIMITS.geometryNodes
|
|
794
|
+
})
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
const annotationIds = /* @__PURE__ */ new Set();
|
|
798
|
+
document.annotations.forEach((annotation, index) => {
|
|
799
|
+
const path = [...base, "annotations", index];
|
|
800
|
+
if (annotationIds.has(annotation.id)) {
|
|
801
|
+
problems.push(
|
|
802
|
+
issue([...path, "id"], "configurator.duplicateId", {
|
|
803
|
+
id: annotation.id
|
|
804
|
+
})
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
annotationIds.add(annotation.id);
|
|
808
|
+
checkFormula2(annotation.from, [...path, "from"], shapeNames);
|
|
809
|
+
checkFormula2(annotation.to, [...path, "to"], shapeNames);
|
|
810
|
+
checkFormula2(annotation.at, [...path, "at"], shapeNames);
|
|
811
|
+
});
|
|
812
|
+
return problems;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ../features/product-configurator/src/shared/configurator-structure.schema.ts
|
|
816
|
+
import { z as z3 } from "zod";
|
|
817
|
+
var LabelSchema = z3.string().trim().min(1).max(80);
|
|
818
|
+
var CONFIGURATOR_TEXTURE_PATH_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(webp|jpg|jpeg)$/;
|
|
819
|
+
var TexturePathSchema = z3.string().regex(CONFIGURATOR_TEXTURE_PATH_PATTERN, "configurator.invalidTexturePath");
|
|
820
|
+
var ParameterBase = {
|
|
821
|
+
key: ConfiguratorNameSchema,
|
|
822
|
+
label: LabelSchema,
|
|
823
|
+
tab: ConfiguratorNameSchema
|
|
824
|
+
};
|
|
825
|
+
var ConfiguratorParameterSchema = z3.discriminatedUnion("kind", [
|
|
826
|
+
z3.strictObject({
|
|
827
|
+
...ParameterBase,
|
|
828
|
+
kind: z3.literal("number"),
|
|
829
|
+
/** `mm` is a measure, `count` a number of things (compartments). */
|
|
830
|
+
unit: z3.enum(["mm", "count"]),
|
|
831
|
+
step: z3.number().positive().finite(),
|
|
832
|
+
/** The widest range any model may offer; a model narrows it in its value. */
|
|
833
|
+
min: z3.number().finite(),
|
|
834
|
+
max: z3.number().finite()
|
|
835
|
+
}),
|
|
836
|
+
z3.strictObject({
|
|
837
|
+
...ParameterBase,
|
|
838
|
+
kind: z3.literal("choice"),
|
|
839
|
+
list: ConfiguratorNameSchema
|
|
840
|
+
}),
|
|
841
|
+
z3.strictObject({ ...ParameterBase, kind: z3.literal("material") }),
|
|
842
|
+
z3.strictObject({ ...ParameterBase, kind: z3.literal("toggle") })
|
|
843
|
+
]);
|
|
844
|
+
var ConfiguratorChoiceListSchema = z3.strictObject({
|
|
845
|
+
key: ConfiguratorNameSchema,
|
|
846
|
+
label: LabelSchema,
|
|
847
|
+
options: z3.array(
|
|
848
|
+
z3.strictObject({
|
|
849
|
+
code: ConfiguratorCodeSchema,
|
|
850
|
+
label: LabelSchema,
|
|
851
|
+
image: TexturePathSchema.optional()
|
|
852
|
+
})
|
|
853
|
+
).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
|
|
854
|
+
});
|
|
855
|
+
var ConfiguratorMaterialSchema = z3.strictObject({
|
|
856
|
+
code: ConfiguratorCodeSchema,
|
|
857
|
+
label: LabelSchema,
|
|
858
|
+
color: z3.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor"),
|
|
859
|
+
texture: TexturePathSchema.optional(),
|
|
860
|
+
/** Millimetres one repeat of the texture covers. */
|
|
861
|
+
textureSize: z3.number().positive().max(1e4).optional(),
|
|
862
|
+
roughness: z3.number().min(0).max(1).optional(),
|
|
863
|
+
metalness: z3.number().min(0).max(1).optional(),
|
|
864
|
+
/** How much of what is behind it shows through: glass in front of a flame. Absent is solid. */
|
|
865
|
+
opacity: z3.number().min(0.05).max(1).optional(),
|
|
866
|
+
/** Gives light rather than reflecting it: a flame, a LED. */
|
|
867
|
+
emissive: z3.boolean().optional(),
|
|
868
|
+
priceGroup: ConfiguratorCodeSchema.optional()
|
|
869
|
+
});
|
|
870
|
+
var ConditionValueSchema = z3.union([
|
|
871
|
+
z3.number().finite(),
|
|
872
|
+
z3.boolean(),
|
|
873
|
+
ConfiguratorCodeSchema,
|
|
874
|
+
z3.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
|
|
875
|
+
]);
|
|
876
|
+
var ConfiguratorConditionSchema = z3.strictObject({
|
|
877
|
+
param: ConfiguratorNameSchema,
|
|
878
|
+
op: z3.enum(["eq", "neq", "in", "gte", "lte"]),
|
|
879
|
+
value: ConditionValueSchema
|
|
880
|
+
});
|
|
881
|
+
var ConfiguratorRuleSchema = z3.discriminatedUnion("kind", [
|
|
882
|
+
/** Show `param` only while `when` holds. */
|
|
883
|
+
z3.strictObject({
|
|
884
|
+
kind: z3.literal("visible"),
|
|
885
|
+
param: ConfiguratorNameSchema,
|
|
886
|
+
when: ConfiguratorConditionSchema
|
|
887
|
+
}),
|
|
888
|
+
/** Narrow a number parameter: `max cabinet_height = total_height - 300`. */
|
|
889
|
+
z3.strictObject({
|
|
890
|
+
kind: z3.literal("limit"),
|
|
891
|
+
param: ConfiguratorNameSchema,
|
|
892
|
+
bound: z3.enum(["min", "max"]),
|
|
893
|
+
formula: FormulaSchema
|
|
894
|
+
}),
|
|
895
|
+
/** While `when` holds, these codes of a choice or material are not offered. */
|
|
896
|
+
z3.strictObject({
|
|
897
|
+
kind: z3.literal("exclude"),
|
|
898
|
+
param: ConfiguratorNameSchema,
|
|
899
|
+
codes: z3.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList),
|
|
900
|
+
when: ConfiguratorConditionSchema
|
|
901
|
+
})
|
|
902
|
+
]);
|
|
903
|
+
var PriceRuleBase = {
|
|
904
|
+
key: ConfiguratorNameSchema,
|
|
905
|
+
label: LabelSchema,
|
|
906
|
+
/** How many times the amount counts, e.g. `compartments`. Absent is once. */
|
|
907
|
+
multiplier: FormulaSchema.optional()
|
|
908
|
+
};
|
|
909
|
+
var ConfiguratorPriceRuleSchema = z3.discriminatedUnion("kind", [
|
|
910
|
+
/** The surcharge the model gives the chosen option. */
|
|
911
|
+
z3.strictObject({
|
|
912
|
+
...PriceRuleBase,
|
|
913
|
+
kind: z3.literal("choice"),
|
|
914
|
+
param: ConfiguratorNameSchema
|
|
915
|
+
}),
|
|
916
|
+
/** The surcharge of the chosen material's price group. */
|
|
917
|
+
z3.strictObject({
|
|
918
|
+
...PriceRuleBase,
|
|
919
|
+
kind: z3.literal("material"),
|
|
920
|
+
param: ConfiguratorNameSchema
|
|
921
|
+
}),
|
|
922
|
+
/** An amount, counted while a switch is on. */
|
|
923
|
+
z3.strictObject({
|
|
924
|
+
...PriceRuleBase,
|
|
925
|
+
kind: z3.literal("toggle"),
|
|
926
|
+
param: ConfiguratorNameSchema,
|
|
927
|
+
amount: ConfiguratorNameSchema
|
|
928
|
+
}),
|
|
929
|
+
/**
|
|
930
|
+
* A price on measure: `max(0, total_width - 1800) / 1000 * meter_price`.
|
|
931
|
+
* Reads number parameters (in their unit) and amounts (in cents).
|
|
932
|
+
*/
|
|
933
|
+
z3.strictObject({
|
|
934
|
+
key: ConfiguratorNameSchema,
|
|
935
|
+
label: LabelSchema,
|
|
936
|
+
kind: z3.literal("formula"),
|
|
937
|
+
formula: FormulaSchema
|
|
938
|
+
})
|
|
939
|
+
]);
|
|
940
|
+
var ConfiguratorStructureObjectSchema = z3.strictObject({
|
|
941
|
+
schemaVersion: z3.literal(1),
|
|
942
|
+
geometry: GeometryDocumentSchema,
|
|
943
|
+
tabs: z3.array(z3.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })).min(1).max(CONFIGURATOR_LIMITS.tabs),
|
|
944
|
+
parameters: z3.array(ConfiguratorParameterSchema).max(CONFIGURATOR_LIMITS.parameters),
|
|
945
|
+
choiceLists: z3.array(ConfiguratorChoiceListSchema).max(CONFIGURATOR_LIMITS.choiceLists),
|
|
946
|
+
materials: z3.array(ConfiguratorMaterialSchema).max(CONFIGURATOR_LIMITS.materials),
|
|
947
|
+
priceGroups: z3.array(z3.strictObject({ code: ConfiguratorCodeSchema, label: LabelSchema })).max(CONFIGURATOR_LIMITS.priceGroups),
|
|
948
|
+
/** Named amounts a model fills in and price rules read (`meter_price`). */
|
|
949
|
+
amounts: z3.array(z3.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })).max(CONFIGURATOR_LIMITS.amounts),
|
|
950
|
+
/**
|
|
951
|
+
* A list of fixed combinations a model offers, each setting some number
|
|
952
|
+
* parameters at once: a fireplace and the niche it needs.
|
|
953
|
+
*/
|
|
954
|
+
presets: z3.strictObject({
|
|
955
|
+
label: LabelSchema,
|
|
956
|
+
tab: ConfiguratorNameSchema,
|
|
957
|
+
sets: z3.array(ConfiguratorNameSchema).min(1).max(CONFIGURATOR_LIMITS.parameters),
|
|
958
|
+
required: z3.boolean()
|
|
959
|
+
}).optional(),
|
|
960
|
+
rules: z3.array(ConfiguratorRuleSchema).max(CONFIGURATOR_LIMITS.rules),
|
|
961
|
+
priceRules: z3.array(ConfiguratorPriceRuleSchema).max(CONFIGURATOR_LIMITS.priceRules)
|
|
962
|
+
});
|
|
963
|
+
var ConfiguratorStructureSchema = ConfiguratorStructureObjectSchema.superRefine((structure, ctx) => {
|
|
964
|
+
for (const problem of configuratorStructureProblems(structure)) {
|
|
965
|
+
ctx.addIssue({
|
|
966
|
+
code: "custom",
|
|
967
|
+
path: problem.path,
|
|
968
|
+
message: problem.code,
|
|
969
|
+
params: problem.params
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
function configuratorStructureProblems(structure) {
|
|
974
|
+
const problems = [];
|
|
975
|
+
if (jsonByteLength(structure) > CONFIGURATOR_LIMITS.structureBytes) {
|
|
976
|
+
problems.push(
|
|
977
|
+
issue([], "configurator.structureTooLarge", {
|
|
978
|
+
max: CONFIGURATOR_LIMITS.structureBytes
|
|
979
|
+
})
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
const tabs = uniqueKeys(
|
|
983
|
+
structure.tabs,
|
|
984
|
+
(tab) => tab.key,
|
|
985
|
+
["tabs"],
|
|
986
|
+
"key",
|
|
987
|
+
problems
|
|
988
|
+
);
|
|
989
|
+
const lists = new Map(
|
|
990
|
+
structure.choiceLists.map((list) => [
|
|
991
|
+
list.key,
|
|
992
|
+
list.options.map((option) => option.code)
|
|
993
|
+
])
|
|
994
|
+
);
|
|
995
|
+
uniqueKeys(
|
|
996
|
+
structure.choiceLists,
|
|
997
|
+
(list) => list.key,
|
|
998
|
+
["choiceLists"],
|
|
999
|
+
"key",
|
|
1000
|
+
problems
|
|
1001
|
+
);
|
|
1002
|
+
structure.choiceLists.forEach(
|
|
1003
|
+
(list, index) => uniqueKeys(
|
|
1004
|
+
list.options,
|
|
1005
|
+
(option) => option.code,
|
|
1006
|
+
["choiceLists", index, "options"],
|
|
1007
|
+
"code",
|
|
1008
|
+
problems
|
|
1009
|
+
)
|
|
1010
|
+
);
|
|
1011
|
+
const materials = uniqueKeys(
|
|
1012
|
+
structure.materials,
|
|
1013
|
+
(material) => material.code,
|
|
1014
|
+
["materials"],
|
|
1015
|
+
"code",
|
|
1016
|
+
problems
|
|
1017
|
+
);
|
|
1018
|
+
const groups = uniqueKeys(
|
|
1019
|
+
structure.priceGroups,
|
|
1020
|
+
(group) => group.code,
|
|
1021
|
+
["priceGroups"],
|
|
1022
|
+
"code",
|
|
1023
|
+
problems
|
|
1024
|
+
);
|
|
1025
|
+
const amounts = uniqueKeys(
|
|
1026
|
+
structure.amounts,
|
|
1027
|
+
(amount) => amount.key,
|
|
1028
|
+
["amounts"],
|
|
1029
|
+
"key",
|
|
1030
|
+
problems
|
|
1031
|
+
);
|
|
1032
|
+
structure.materials.forEach((material, index) => {
|
|
1033
|
+
if (material.priceGroup && !groups.has(material.priceGroup)) {
|
|
1034
|
+
problems.push(
|
|
1035
|
+
issue(
|
|
1036
|
+
["materials", index, "priceGroup"],
|
|
1037
|
+
"configurator.unknownPriceGroup",
|
|
1038
|
+
{
|
|
1039
|
+
code: material.priceGroup
|
|
1040
|
+
}
|
|
1041
|
+
)
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
const parameters = new Map(
|
|
1046
|
+
structure.parameters.map((parameter) => [parameter.key, parameter])
|
|
1047
|
+
);
|
|
1048
|
+
uniqueKeys(
|
|
1049
|
+
structure.parameters,
|
|
1050
|
+
(parameter) => parameter.key,
|
|
1051
|
+
["parameters"],
|
|
1052
|
+
"key",
|
|
1053
|
+
problems
|
|
1054
|
+
);
|
|
1055
|
+
structure.parameters.forEach((parameter, index) => {
|
|
1056
|
+
const path = ["parameters", index];
|
|
1057
|
+
if (!tabs.has(parameter.tab)) {
|
|
1058
|
+
problems.push(
|
|
1059
|
+
issue([...path, "tab"], "configurator.unknownTab", {
|
|
1060
|
+
tab: parameter.tab
|
|
1061
|
+
})
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
if (parameter.kind === "number" && parameter.min > parameter.max) {
|
|
1065
|
+
problems.push(
|
|
1066
|
+
issue([...path, "min"], "configurator.minAboveMax", {
|
|
1067
|
+
key: parameter.key
|
|
1068
|
+
})
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
if (parameter.kind === "choice" && !lists.has(parameter.list)) {
|
|
1072
|
+
problems.push(
|
|
1073
|
+
issue([...path, "list"], "configurator.unknownList", {
|
|
1074
|
+
list: parameter.list
|
|
1075
|
+
})
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
const numberKeys = new Set(
|
|
1080
|
+
structure.parameters.filter((parameter) => parameter.kind === "number").map((parameter) => parameter.key)
|
|
1081
|
+
);
|
|
1082
|
+
const toggleKeys = new Set(
|
|
1083
|
+
structure.parameters.filter((parameter) => parameter.kind === "toggle").map((parameter) => parameter.key)
|
|
1084
|
+
);
|
|
1085
|
+
const numericScope = /* @__PURE__ */ new Set([...numberKeys, ...toggleKeys]);
|
|
1086
|
+
if (structure.presets) {
|
|
1087
|
+
if (!tabs.has(structure.presets.tab)) {
|
|
1088
|
+
problems.push(
|
|
1089
|
+
issue(["presets", "tab"], "configurator.unknownTab", {
|
|
1090
|
+
tab: structure.presets.tab
|
|
1091
|
+
})
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
structure.presets.sets.forEach((key, index) => {
|
|
1095
|
+
if (!numberKeys.has(key)) {
|
|
1096
|
+
problems.push(
|
|
1097
|
+
issue(["presets", "sets", index], "configurator.notANumberParam", {
|
|
1098
|
+
key
|
|
1099
|
+
})
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
const dependsOn = /* @__PURE__ */ new Map();
|
|
1105
|
+
const addDependency = (from, on) => {
|
|
1106
|
+
const set = dependsOn.get(from) ?? /* @__PURE__ */ new Set();
|
|
1107
|
+
set.add(on);
|
|
1108
|
+
dependsOn.set(from, set);
|
|
1109
|
+
};
|
|
1110
|
+
structure.rules.forEach((rule, index) => {
|
|
1111
|
+
const path = ["rules", index];
|
|
1112
|
+
const target = parameters.get(rule.param);
|
|
1113
|
+
if (!target) {
|
|
1114
|
+
problems.push(
|
|
1115
|
+
issue([...path, "param"], "configurator.unknownParam", {
|
|
1116
|
+
key: rule.param
|
|
1117
|
+
})
|
|
1118
|
+
);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (rule.kind === "limit") {
|
|
1122
|
+
if (target.kind !== "number") {
|
|
1123
|
+
problems.push(
|
|
1124
|
+
issue([...path, "param"], "configurator.notANumberParam", {
|
|
1125
|
+
key: rule.param
|
|
1126
|
+
})
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
for (const name of checkFormula(
|
|
1130
|
+
rule.formula,
|
|
1131
|
+
[...path, "formula"],
|
|
1132
|
+
numericScope,
|
|
1133
|
+
problems
|
|
1134
|
+
)) {
|
|
1135
|
+
addDependency(rule.param, name);
|
|
1136
|
+
}
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (rule.kind === "exclude") {
|
|
1140
|
+
const codes = codesOf(target, lists, materials);
|
|
1141
|
+
if (!codes) {
|
|
1142
|
+
problems.push(
|
|
1143
|
+
issue([...path, "param"], "configurator.notAChoiceParam", {
|
|
1144
|
+
key: rule.param
|
|
1145
|
+
})
|
|
1146
|
+
);
|
|
1147
|
+
} else {
|
|
1148
|
+
rule.codes.forEach((code, codeIndex) => {
|
|
1149
|
+
if (!codes.has(code)) {
|
|
1150
|
+
problems.push(
|
|
1151
|
+
issue([...path, "codes", codeIndex], "configurator.unknownCode", {
|
|
1152
|
+
code
|
|
1153
|
+
})
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const condition = rule.when;
|
|
1160
|
+
const conditionParam = parameters.get(condition.param);
|
|
1161
|
+
if (!conditionParam) {
|
|
1162
|
+
problems.push(
|
|
1163
|
+
issue([...path, "when", "param"], "configurator.unknownParam", {
|
|
1164
|
+
key: condition.param
|
|
1165
|
+
})
|
|
1166
|
+
);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
addDependency(rule.param, condition.param);
|
|
1170
|
+
const valueProblem = conditionValueProblem(
|
|
1171
|
+
condition.op,
|
|
1172
|
+
condition.value,
|
|
1173
|
+
conditionParam,
|
|
1174
|
+
lists,
|
|
1175
|
+
materials
|
|
1176
|
+
);
|
|
1177
|
+
if (valueProblem) {
|
|
1178
|
+
problems.push(
|
|
1179
|
+
issue([...path, "when", "value"], valueProblem, {
|
|
1180
|
+
key: condition.param
|
|
1181
|
+
})
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
const cycle = findCycle(dependsOn);
|
|
1186
|
+
if (cycle) {
|
|
1187
|
+
problems.push(
|
|
1188
|
+
issue(["rules"], "configurator.ruleCycle", { cycle: cycle.join(" \u2192 ") })
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
const priceRuleKeys = /* @__PURE__ */ new Set();
|
|
1192
|
+
structure.priceRules.forEach((rule, index) => {
|
|
1193
|
+
const path = ["priceRules", index];
|
|
1194
|
+
if (priceRuleKeys.has(rule.key)) {
|
|
1195
|
+
problems.push(
|
|
1196
|
+
issue([...path, "key"], "configurator.duplicateName", {
|
|
1197
|
+
name: rule.key
|
|
1198
|
+
})
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
priceRuleKeys.add(rule.key);
|
|
1202
|
+
if (rule.kind === "formula") {
|
|
1203
|
+
checkFormula(
|
|
1204
|
+
rule.formula,
|
|
1205
|
+
[...path, "formula"],
|
|
1206
|
+
/* @__PURE__ */ new Set([...numericScope, ...amounts]),
|
|
1207
|
+
problems
|
|
1208
|
+
);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (rule.multiplier !== void 0) {
|
|
1212
|
+
checkFormula(
|
|
1213
|
+
rule.multiplier,
|
|
1214
|
+
[...path, "multiplier"],
|
|
1215
|
+
numericScope,
|
|
1216
|
+
problems
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
const target = parameters.get(rule.param);
|
|
1220
|
+
if (!target || target.kind !== rule.kind) {
|
|
1221
|
+
problems.push(
|
|
1222
|
+
issue([...path, "param"], "configurator.priceRuleParamKind", {
|
|
1223
|
+
key: rule.param,
|
|
1224
|
+
kind: rule.kind
|
|
1225
|
+
})
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
if (rule.kind === "toggle" && !amounts.has(rule.amount)) {
|
|
1229
|
+
problems.push(
|
|
1230
|
+
issue([...path, "amount"], "configurator.unknownAmount", {
|
|
1231
|
+
key: rule.amount
|
|
1232
|
+
})
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
const geometryParameters = new Map(
|
|
1237
|
+
structure.parameters.map((parameter) => [
|
|
1238
|
+
parameter.key,
|
|
1239
|
+
parameter.kind === "choice" ? { kind: "choice", codes: lists.get(parameter.list) ?? [] } : { kind: parameter.kind }
|
|
1240
|
+
])
|
|
1241
|
+
);
|
|
1242
|
+
problems.push(
|
|
1243
|
+
...geometryDocumentProblems(structure.geometry, {
|
|
1244
|
+
parameters: geometryParameters,
|
|
1245
|
+
materialCodes: materials,
|
|
1246
|
+
path: ["geometry"]
|
|
1247
|
+
})
|
|
1248
|
+
);
|
|
1249
|
+
return problems;
|
|
1250
|
+
}
|
|
1251
|
+
function uniqueKeys(items, keyOf, path, field, problems) {
|
|
1252
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1253
|
+
items.forEach((item, index) => {
|
|
1254
|
+
const key = keyOf(item);
|
|
1255
|
+
if (seen.has(key)) {
|
|
1256
|
+
problems.push(
|
|
1257
|
+
issue([...path, index, field], "configurator.duplicateName", {
|
|
1258
|
+
name: key
|
|
1259
|
+
})
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
seen.add(key);
|
|
1263
|
+
});
|
|
1264
|
+
return seen;
|
|
1265
|
+
}
|
|
1266
|
+
function checkFormula(formula, path, scope, problems) {
|
|
1267
|
+
const names = formulaNames(formula);
|
|
1268
|
+
if (!names.ok) {
|
|
1269
|
+
problems.push(issue(path, `configurator.${names.code}`));
|
|
1270
|
+
return [];
|
|
1271
|
+
}
|
|
1272
|
+
for (const name of names.names) {
|
|
1273
|
+
if (!scope.has(name)) {
|
|
1274
|
+
problems.push(issue(path, "configurator.unknownName", { name }));
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
return names.names;
|
|
1278
|
+
}
|
|
1279
|
+
function codesOf(parameter, lists, materials) {
|
|
1280
|
+
if (parameter.kind === "choice") {
|
|
1281
|
+
return new Set(lists.get(parameter.list) ?? []);
|
|
1282
|
+
}
|
|
1283
|
+
if (parameter.kind === "material") {
|
|
1284
|
+
return materials;
|
|
1285
|
+
}
|
|
1286
|
+
return null;
|
|
1287
|
+
}
|
|
1288
|
+
function conditionValueProblem(op, value, parameter, lists, materials) {
|
|
1289
|
+
if (parameter.kind === "number") {
|
|
1290
|
+
return typeof value === "number" && op !== "in" ? null : "configurator.conditionNeedsNumber";
|
|
1291
|
+
}
|
|
1292
|
+
if (parameter.kind === "toggle") {
|
|
1293
|
+
return typeof value === "boolean" && (op === "eq" || op === "neq") ? null : "configurator.conditionNeedsBoolean";
|
|
1294
|
+
}
|
|
1295
|
+
const codes = codesOf(parameter, lists, materials) ?? /* @__PURE__ */ new Set();
|
|
1296
|
+
if (op === "in") {
|
|
1297
|
+
return Array.isArray(value) && value.every((code) => codes.has(code)) ? null : "configurator.conditionUnknownCode";
|
|
1298
|
+
}
|
|
1299
|
+
return (op === "eq" || op === "neq") && typeof value === "string" && codes.has(value) ? null : "configurator.conditionUnknownCode";
|
|
1300
|
+
}
|
|
1301
|
+
function findCycle(graph) {
|
|
1302
|
+
const state = /* @__PURE__ */ new Map();
|
|
1303
|
+
const stack = [];
|
|
1304
|
+
const visit = (node) => {
|
|
1305
|
+
const current = state.get(node);
|
|
1306
|
+
if (current === "done") {
|
|
1307
|
+
return null;
|
|
1308
|
+
}
|
|
1309
|
+
if (current === "visiting") {
|
|
1310
|
+
return [...stack.slice(stack.indexOf(node)), node];
|
|
1311
|
+
}
|
|
1312
|
+
state.set(node, "visiting");
|
|
1313
|
+
stack.push(node);
|
|
1314
|
+
for (const next of graph.get(node) ?? []) {
|
|
1315
|
+
const cycle = visit(next);
|
|
1316
|
+
if (cycle) {
|
|
1317
|
+
return cycle;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
stack.pop();
|
|
1321
|
+
state.set(node, "done");
|
|
1322
|
+
return null;
|
|
1323
|
+
};
|
|
1324
|
+
for (const node of graph.keys()) {
|
|
1325
|
+
const cycle = visit(node);
|
|
1326
|
+
if (cycle) {
|
|
1327
|
+
return cycle;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
function parameterResolutionOrder(structure) {
|
|
1333
|
+
const dependsOn = /* @__PURE__ */ new Map();
|
|
1334
|
+
for (const rule of structure.rules) {
|
|
1335
|
+
const set = dependsOn.get(rule.param) ?? /* @__PURE__ */ new Set();
|
|
1336
|
+
if (rule.kind === "limit") {
|
|
1337
|
+
const names = formulaNames(rule.formula);
|
|
1338
|
+
for (const name of names.ok ? names.names : []) {
|
|
1339
|
+
set.add(name);
|
|
1340
|
+
}
|
|
1341
|
+
} else {
|
|
1342
|
+
set.add(rule.when.param);
|
|
1343
|
+
}
|
|
1344
|
+
dependsOn.set(rule.param, set);
|
|
1345
|
+
}
|
|
1346
|
+
const order = [];
|
|
1347
|
+
const placed = /* @__PURE__ */ new Set();
|
|
1348
|
+
const place = (key, trail) => {
|
|
1349
|
+
if (placed.has(key) || trail.has(key)) {
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
trail.add(key);
|
|
1353
|
+
for (const dependency of dependsOn.get(key) ?? []) {
|
|
1354
|
+
place(dependency, trail);
|
|
1355
|
+
}
|
|
1356
|
+
placed.add(key);
|
|
1357
|
+
order.push(key);
|
|
1358
|
+
};
|
|
1359
|
+
for (const parameter of structure.parameters) {
|
|
1360
|
+
place(parameter.key, /* @__PURE__ */ new Set());
|
|
1361
|
+
}
|
|
1362
|
+
return order.filter(
|
|
1363
|
+
(key) => structure.parameters.some((parameter) => parameter.key === key)
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// ../features/product-configurator/src/shared/configurator-value.schema.ts
|
|
1368
|
+
import { z as z4 } from "zod";
|
|
1369
|
+
var CentsSchema = z4.number().int().min(0).max(1e8);
|
|
1370
|
+
var PriceSchema = CentsSchema.nullable();
|
|
1371
|
+
var ConfiguratorValueObjectSchema = z4.strictObject({
|
|
1372
|
+
/** Null: the model has no price of its own, every quote is "open". */
|
|
1373
|
+
basePriceCents: PriceSchema,
|
|
1374
|
+
numbers: z4.record(
|
|
1375
|
+
ConfiguratorNameSchema,
|
|
1376
|
+
z4.strictObject({
|
|
1377
|
+
min: z4.number().finite(),
|
|
1378
|
+
max: z4.number().finite(),
|
|
1379
|
+
default: z4.number().finite()
|
|
1380
|
+
})
|
|
1381
|
+
).default({}),
|
|
1382
|
+
/** Per choice list: the options this model offers, with their surcharge. */
|
|
1383
|
+
choices: z4.record(
|
|
1384
|
+
ConfiguratorNameSchema,
|
|
1385
|
+
z4.strictObject({
|
|
1386
|
+
options: z4.record(
|
|
1387
|
+
ConfiguratorCodeSchema,
|
|
1388
|
+
z4.strictObject({ priceCents: PriceSchema })
|
|
1389
|
+
),
|
|
1390
|
+
default: ConfiguratorCodeSchema.optional()
|
|
1391
|
+
})
|
|
1392
|
+
).default({}),
|
|
1393
|
+
/** Per material parameter: the materials this model offers. */
|
|
1394
|
+
materials: z4.record(
|
|
1395
|
+
ConfiguratorNameSchema,
|
|
1396
|
+
z4.strictObject({
|
|
1397
|
+
offered: z4.array(ConfiguratorCodeSchema).max(CONFIGURATOR_LIMITS.materials),
|
|
1398
|
+
default: ConfiguratorCodeSchema.optional()
|
|
1399
|
+
})
|
|
1400
|
+
).default({}),
|
|
1401
|
+
/** Per switch: whether this model offers it, and how it starts. Absent is offered, off. */
|
|
1402
|
+
toggles: z4.record(
|
|
1403
|
+
ConfiguratorNameSchema,
|
|
1404
|
+
z4.strictObject({ offered: z4.boolean(), default: z4.boolean() })
|
|
1405
|
+
).default({}),
|
|
1406
|
+
/** The surcharge of each material price group. */
|
|
1407
|
+
priceGroups: z4.record(ConfiguratorCodeSchema, PriceSchema).default({}),
|
|
1408
|
+
/** The amounts the structure names, such as a price per metre. */
|
|
1409
|
+
amounts: z4.record(ConfiguratorNameSchema, PriceSchema).default({}),
|
|
1410
|
+
presets: z4.array(
|
|
1411
|
+
z4.strictObject({
|
|
1412
|
+
code: ConfiguratorCodeSchema,
|
|
1413
|
+
label: z4.string().trim().min(1).max(80),
|
|
1414
|
+
/** A product of the same account this preset stands for, e.g. the fireplace. */
|
|
1415
|
+
productId: z4.string().uuid().nullable(),
|
|
1416
|
+
values: z4.record(ConfiguratorNameSchema, z4.number().finite())
|
|
1417
|
+
})
|
|
1418
|
+
).max(CONFIGURATOR_LIMITS.presets).default([])
|
|
1419
|
+
});
|
|
1420
|
+
function emptyConfiguratorValue() {
|
|
1421
|
+
return ConfiguratorValueObjectSchema.parse({ basePriceCents: null });
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// ../features/product-configurator/src/shared/resolve-configuration.ts
|
|
1425
|
+
var NEUTRAL_SLOT_COLOR = "#b8b4ad";
|
|
1426
|
+
var STEP_TOLERANCE = 1e-6;
|
|
1427
|
+
function resolveConfiguration(params) {
|
|
1428
|
+
const { structure, value, selection } = params;
|
|
1429
|
+
const issues = [];
|
|
1430
|
+
const parametersByKey = new Map(
|
|
1431
|
+
structure.parameters.map((parameter) => [parameter.key, parameter])
|
|
1432
|
+
);
|
|
1433
|
+
const materialsByCode = new Map(
|
|
1434
|
+
structure.materials.map((material) => [material.code, material])
|
|
1435
|
+
);
|
|
1436
|
+
for (const key of Object.keys(selection.values)) {
|
|
1437
|
+
if (!parametersByKey.has(key)) {
|
|
1438
|
+
issues.push(issue(["values", key], "configurator.unknownParam", { key }));
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
const presets = structure.presets ? value.presets.map((preset2) => ({
|
|
1442
|
+
code: preset2.code,
|
|
1443
|
+
label: preset2.label,
|
|
1444
|
+
productId: preset2.productId
|
|
1445
|
+
})) : [];
|
|
1446
|
+
let preset = null;
|
|
1447
|
+
if (selection.preset !== void 0) {
|
|
1448
|
+
preset = presets.find((candidate) => candidate.code === selection.preset) ?? null;
|
|
1449
|
+
if (!preset) {
|
|
1450
|
+
issues.push(
|
|
1451
|
+
issue(["preset"], "configurator.presetNotOffered", {
|
|
1452
|
+
code: selection.preset
|
|
1453
|
+
})
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
if (!preset && structure.presets?.required) {
|
|
1458
|
+
preset = presets[0] ?? null;
|
|
1459
|
+
}
|
|
1460
|
+
const presetValues = preset ? value.presets.find((candidate) => candidate.code === preset?.code)?.values ?? {} : {};
|
|
1461
|
+
const presetSets = new Set(structure.presets?.sets ?? []);
|
|
1462
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1463
|
+
const numericScope = {};
|
|
1464
|
+
const conditionHolds = (condition) => {
|
|
1465
|
+
const current = resolved.get(condition.param);
|
|
1466
|
+
const actual = current?.visible ? current.value : null;
|
|
1467
|
+
switch (condition.op) {
|
|
1468
|
+
case "eq":
|
|
1469
|
+
return actual === condition.value;
|
|
1470
|
+
case "neq":
|
|
1471
|
+
return actual !== condition.value;
|
|
1472
|
+
case "in":
|
|
1473
|
+
return Array.isArray(condition.value) && condition.value.includes(actual);
|
|
1474
|
+
case "gte":
|
|
1475
|
+
return typeof actual === "number" && actual >= condition.value;
|
|
1476
|
+
case "lte":
|
|
1477
|
+
return typeof actual === "number" && actual <= condition.value;
|
|
1478
|
+
}
|
|
1479
|
+
};
|
|
1480
|
+
for (const key of parameterResolutionOrder(structure)) {
|
|
1481
|
+
const parameter = parametersByKey.get(key);
|
|
1482
|
+
if (!parameter) {
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
const rules = structure.rules.filter((rule) => rule.param === key);
|
|
1486
|
+
let visible = rules.filter((rule) => rule.kind === "visible").every((rule) => conditionHolds(rule.when));
|
|
1487
|
+
const chosen = selection.values[key];
|
|
1488
|
+
const path = ["values", key];
|
|
1489
|
+
const base = {
|
|
1490
|
+
key,
|
|
1491
|
+
kind: parameter.kind,
|
|
1492
|
+
label: parameter.label,
|
|
1493
|
+
tab: parameter.tab
|
|
1494
|
+
};
|
|
1495
|
+
if (parameter.kind === "number") {
|
|
1496
|
+
const range = value.numbers[key] ?? {
|
|
1497
|
+
min: parameter.min,
|
|
1498
|
+
max: parameter.max,
|
|
1499
|
+
default: parameter.min
|
|
1500
|
+
};
|
|
1501
|
+
let min = range.min;
|
|
1502
|
+
let max = range.max;
|
|
1503
|
+
for (const rule of rules) {
|
|
1504
|
+
if (rule.kind !== "limit") {
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
const bound = evaluateFormulaSource(rule.formula, numericScope);
|
|
1508
|
+
if (!bound.ok) {
|
|
1509
|
+
issues.push(
|
|
1510
|
+
issue(
|
|
1511
|
+
["rules", structure.rules.indexOf(rule)],
|
|
1512
|
+
`configurator.${bound.code}`
|
|
1513
|
+
)
|
|
1514
|
+
);
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
if (rule.bound === "min") {
|
|
1518
|
+
min = Math.max(min, bound.value);
|
|
1519
|
+
} else {
|
|
1520
|
+
max = Math.min(max, bound.value);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
if (min > max) {
|
|
1524
|
+
issues.push(issue(path, "configurator.emptyRange", { key, min, max }));
|
|
1525
|
+
max = min;
|
|
1526
|
+
}
|
|
1527
|
+
const fixedValue = presetSets.has(key) ? presetValues[key] : void 0;
|
|
1528
|
+
let current2 = clamp(range.default, min, max);
|
|
1529
|
+
if (fixedValue !== void 0) {
|
|
1530
|
+
current2 = fixedValue;
|
|
1531
|
+
} else if (visible && chosen !== void 0) {
|
|
1532
|
+
if (typeof chosen !== "number") {
|
|
1533
|
+
issues.push(issue(path, "configurator.expectedNumber", { key }));
|
|
1534
|
+
} else if (chosen < min || chosen > max) {
|
|
1535
|
+
issues.push(
|
|
1536
|
+
issue(path, "configurator.outOfRange", { key, min, max })
|
|
1537
|
+
);
|
|
1538
|
+
current2 = clamp(chosen, min, max);
|
|
1539
|
+
} else if (!onStep(chosen, parameter.step)) {
|
|
1540
|
+
issues.push(
|
|
1541
|
+
issue(path, "configurator.offStep", { key, step: parameter.step })
|
|
1542
|
+
);
|
|
1543
|
+
} else {
|
|
1544
|
+
current2 = chosen;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
numericScope[key] = current2;
|
|
1548
|
+
resolved.set(key, {
|
|
1549
|
+
...base,
|
|
1550
|
+
visible,
|
|
1551
|
+
value: current2,
|
|
1552
|
+
min,
|
|
1553
|
+
max,
|
|
1554
|
+
step: parameter.step,
|
|
1555
|
+
unit: parameter.unit,
|
|
1556
|
+
fixed: fixedValue !== void 0
|
|
1557
|
+
});
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
if (parameter.kind === "toggle") {
|
|
1561
|
+
const setting = value.toggles[key] ?? { offered: true, default: false };
|
|
1562
|
+
visible = visible && setting.offered;
|
|
1563
|
+
let current2 = setting.default;
|
|
1564
|
+
if (visible && chosen !== void 0) {
|
|
1565
|
+
if (typeof chosen !== "boolean") {
|
|
1566
|
+
issues.push(issue(path, "configurator.expectedBoolean", { key }));
|
|
1567
|
+
} else {
|
|
1568
|
+
current2 = chosen;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
const on = visible && current2;
|
|
1572
|
+
numericScope[key] = on ? 1 : 0;
|
|
1573
|
+
resolved.set(key, { ...base, visible, value: on });
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
const excluded = new Set(
|
|
1577
|
+
rules.filter((rule) => rule.kind === "exclude" && conditionHolds(rule.when)).flatMap((rule) => rule.kind === "exclude" ? rule.codes : [])
|
|
1578
|
+
);
|
|
1579
|
+
let options;
|
|
1580
|
+
let preferred;
|
|
1581
|
+
if (parameter.kind === "choice") {
|
|
1582
|
+
const list = structure.choiceLists.find(
|
|
1583
|
+
(candidate) => candidate.key === parameter.list
|
|
1584
|
+
);
|
|
1585
|
+
const offer = value.choices[parameter.list];
|
|
1586
|
+
options = (list?.options ?? []).filter(
|
|
1587
|
+
(option) => offer?.options[option.code] !== void 0 && !excluded.has(option.code)
|
|
1588
|
+
).map((option) => ({
|
|
1589
|
+
code: option.code,
|
|
1590
|
+
label: option.label,
|
|
1591
|
+
priceCents: offer?.options[option.code]?.priceCents ?? null
|
|
1592
|
+
}));
|
|
1593
|
+
preferred = offer?.default;
|
|
1594
|
+
} else {
|
|
1595
|
+
const offer = value.materials[key];
|
|
1596
|
+
options = (offer?.offered ?? []).map((code) => materialsByCode.get(code)).filter(
|
|
1597
|
+
(material) => material !== void 0
|
|
1598
|
+
).filter((material) => !excluded.has(material.code)).map((material) => ({
|
|
1599
|
+
code: material.code,
|
|
1600
|
+
label: material.label,
|
|
1601
|
+
priceCents: material.priceGroup ? value.priceGroups[material.priceGroup] ?? null : 0,
|
|
1602
|
+
material
|
|
1603
|
+
}));
|
|
1604
|
+
preferred = offer?.default;
|
|
1605
|
+
}
|
|
1606
|
+
visible = visible && options.length > 0;
|
|
1607
|
+
let current = options.find((option) => option.code === preferred)?.code ?? options[0]?.code ?? null;
|
|
1608
|
+
if (visible && chosen !== void 0) {
|
|
1609
|
+
if (typeof chosen !== "string" || !options.some((option) => option.code === chosen)) {
|
|
1610
|
+
issues.push(
|
|
1611
|
+
issue(path, "configurator.optionNotOffered", {
|
|
1612
|
+
key,
|
|
1613
|
+
code: String(chosen)
|
|
1614
|
+
})
|
|
1615
|
+
);
|
|
1616
|
+
} else {
|
|
1617
|
+
current = chosen;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
resolved.set(key, {
|
|
1621
|
+
...base,
|
|
1622
|
+
visible,
|
|
1623
|
+
value: visible ? current : null,
|
|
1624
|
+
options
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
const parameters = structure.parameters.map((parameter) => resolved.get(parameter.key)).filter(
|
|
1628
|
+
(parameter) => parameter !== void 0
|
|
1629
|
+
);
|
|
1630
|
+
const inputs = {};
|
|
1631
|
+
for (const input of structure.geometry.inputs) {
|
|
1632
|
+
const parameter = resolved.get(input.binding.param);
|
|
1633
|
+
if (!parameter) {
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
if (parameter.kind === "number") {
|
|
1637
|
+
inputs[input.name] = parameter.value;
|
|
1638
|
+
} else if (parameter.kind === "toggle") {
|
|
1639
|
+
inputs[input.name] = parameter.value === true ? 1 : 0;
|
|
1640
|
+
} else if (parameter.kind === "choice") {
|
|
1641
|
+
inputs[input.name] = typeof parameter.value === "string" ? input.binding.map?.[parameter.value] ?? 0 : 0;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
const slots = {};
|
|
1645
|
+
for (const slot of structure.geometry.slots) {
|
|
1646
|
+
let material = null;
|
|
1647
|
+
if (slot.param) {
|
|
1648
|
+
const parameter = resolved.get(slot.param);
|
|
1649
|
+
material = parameter?.options?.find((option) => option.code === parameter.value)?.material ?? null;
|
|
1650
|
+
} else if (slot.material) {
|
|
1651
|
+
material = materialsByCode.get(slot.material) ?? null;
|
|
1652
|
+
}
|
|
1653
|
+
slots[slot.name] = {
|
|
1654
|
+
color: material?.color ?? slot.color ?? NEUTRAL_SLOT_COLOR,
|
|
1655
|
+
material
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
const normalised = { values: {} };
|
|
1659
|
+
for (const parameter of parameters) {
|
|
1660
|
+
if (parameter.visible && !parameter.fixed && parameter.value !== null) {
|
|
1661
|
+
normalised.values[parameter.key] = parameter.value;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (preset) {
|
|
1665
|
+
normalised.preset = preset.code;
|
|
1666
|
+
}
|
|
1667
|
+
return {
|
|
1668
|
+
configuration: {
|
|
1669
|
+
parameters,
|
|
1670
|
+
byKey: Object.fromEntries(resolved),
|
|
1671
|
+
presets,
|
|
1672
|
+
preset,
|
|
1673
|
+
inputs,
|
|
1674
|
+
slots,
|
|
1675
|
+
selection: normalised
|
|
1676
|
+
},
|
|
1677
|
+
issues
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
function clamp(value, min, max) {
|
|
1681
|
+
return Math.min(max, Math.max(min, value));
|
|
1682
|
+
}
|
|
1683
|
+
function onStep(value, step) {
|
|
1684
|
+
const ratio = value / step;
|
|
1685
|
+
return Math.abs(ratio - Math.round(ratio)) < STEP_TOLERANCE;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// ../features/product-configurator/src/shared/expand-geometry.ts
|
|
1689
|
+
function expandGeometry(document, inputs) {
|
|
1690
|
+
const issues = [];
|
|
1691
|
+
const parts = [];
|
|
1692
|
+
const baseScope = { ...document.constants };
|
|
1693
|
+
for (const input of document.inputs) {
|
|
1694
|
+
const number = inputs[input.name];
|
|
1695
|
+
if (number === void 0 || !Number.isFinite(number)) {
|
|
1696
|
+
issues.push(
|
|
1697
|
+
issue(["inputs", input.name], "configurator.inputMissing", {
|
|
1698
|
+
name: input.name
|
|
1699
|
+
})
|
|
1700
|
+
);
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
baseScope[input.name] = number;
|
|
1704
|
+
}
|
|
1705
|
+
if (issues.length > 0) {
|
|
1706
|
+
return { ok: false, issues };
|
|
1707
|
+
}
|
|
1708
|
+
const evaluate = (formula, scope, path) => {
|
|
1709
|
+
const result = evaluateFormulaSource(formula, scope);
|
|
1710
|
+
if (!result.ok) {
|
|
1711
|
+
issues.push(
|
|
1712
|
+
result.code === "formula_unknown_name" ? issue(path, "configurator.unknownName", { name: result.name }) : issue(path, `configurator.${result.code}`)
|
|
1713
|
+
);
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
return result.value;
|
|
1717
|
+
};
|
|
1718
|
+
const walk = (nodes, scope, path, keySuffix, depth) => {
|
|
1719
|
+
for (const node of nodes) {
|
|
1720
|
+
if (issues.length > 0) {
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
const nodePath = [...path, node.id];
|
|
1724
|
+
if (node.visible !== void 0) {
|
|
1725
|
+
const visible = evaluate(node.visible, scope, [...nodePath, "visible"]);
|
|
1726
|
+
if (visible === null || visible <= 0) {
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
if (node.type === "repeat") {
|
|
1731
|
+
if (depth + 1 > CONFIGURATOR_LIMITS.repeatDepth) {
|
|
1732
|
+
issues.push(
|
|
1733
|
+
issue(nodePath, "configurator.repeatTooDeep", {
|
|
1734
|
+
max: CONFIGURATOR_LIMITS.repeatDepth
|
|
1735
|
+
})
|
|
1736
|
+
);
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
const count = evaluate(node.count, scope, [...nodePath, "count"]);
|
|
1740
|
+
if (count === null) {
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
if (!Number.isInteger(count) || count < 0 || count > CONFIGURATOR_LIMITS.repeatCount) {
|
|
1744
|
+
issues.push(
|
|
1745
|
+
issue([...nodePath, "count"], "configurator.repeatCountInvalid", {
|
|
1746
|
+
count,
|
|
1747
|
+
max: CONFIGURATOR_LIMITS.repeatCount
|
|
1748
|
+
})
|
|
1749
|
+
);
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
const index = node.index ?? "i";
|
|
1753
|
+
for (let step = 0; step < count; step += 1) {
|
|
1754
|
+
walk(
|
|
1755
|
+
node.children,
|
|
1756
|
+
{ ...scope, [index]: step },
|
|
1757
|
+
[...nodePath, step],
|
|
1758
|
+
`${keySuffix}[${step}]`,
|
|
1759
|
+
depth + 1
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
const size = node.size.map(
|
|
1765
|
+
(formula, axis) => evaluate(formula, scope, [...nodePath, "size", axis])
|
|
1766
|
+
);
|
|
1767
|
+
const at = node.at.map(
|
|
1768
|
+
(formula, axis) => evaluate(formula, scope, [...nodePath, "at", axis])
|
|
1769
|
+
);
|
|
1770
|
+
if (size.includes(null) || at.includes(null)) {
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const negative = size.findIndex((measure) => measure < 0);
|
|
1774
|
+
if (negative >= 0) {
|
|
1775
|
+
issues.push(
|
|
1776
|
+
issue(
|
|
1777
|
+
[...nodePath, "size", negative],
|
|
1778
|
+
"configurator.negativeMeasure",
|
|
1779
|
+
{
|
|
1780
|
+
value: size[negative]
|
|
1781
|
+
}
|
|
1782
|
+
)
|
|
1783
|
+
);
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
const params = {};
|
|
1787
|
+
for (const [key, formula] of Object.entries(node.params ?? {})) {
|
|
1788
|
+
const number = evaluate(formula, scope, [...nodePath, "params", key]);
|
|
1789
|
+
if (number === null) {
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
params[key] = number;
|
|
1793
|
+
}
|
|
1794
|
+
parts.push({
|
|
1795
|
+
key: `${node.id}${keySuffix}`,
|
|
1796
|
+
id: node.id,
|
|
1797
|
+
type: node.type,
|
|
1798
|
+
slot: node.slot,
|
|
1799
|
+
size,
|
|
1800
|
+
at,
|
|
1801
|
+
params
|
|
1802
|
+
});
|
|
1803
|
+
if (parts.length > CONFIGURATOR_LIMITS.expandedParts) {
|
|
1804
|
+
issues.push(
|
|
1805
|
+
issue(["root"], "configurator.tooManyParts", {
|
|
1806
|
+
max: CONFIGURATOR_LIMITS.expandedParts
|
|
1807
|
+
})
|
|
1808
|
+
);
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
};
|
|
1813
|
+
walk(document.root, baseScope, ["root"], "", 0);
|
|
1814
|
+
const dimensions = [];
|
|
1815
|
+
for (const annotation of document.annotations) {
|
|
1816
|
+
const path = ["annotations", annotation.id];
|
|
1817
|
+
const from = evaluate(annotation.from, baseScope, [...path, "from"]);
|
|
1818
|
+
const to = evaluate(annotation.to, baseScope, [...path, "to"]);
|
|
1819
|
+
const at = evaluate(annotation.at, baseScope, [...path, "at"]);
|
|
1820
|
+
if (from === null || to === null || at === null) {
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
dimensions.push({
|
|
1824
|
+
id: annotation.id,
|
|
1825
|
+
axis: annotation.axis,
|
|
1826
|
+
from,
|
|
1827
|
+
to,
|
|
1828
|
+
at,
|
|
1829
|
+
label: annotation.label ?? null,
|
|
1830
|
+
length: Math.abs(to - from)
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
if (issues.length > 0) {
|
|
1834
|
+
return { ok: false, issues };
|
|
1835
|
+
}
|
|
1836
|
+
return { ok: true, geometry: { parts, dimensions, bounds: boundsOf(parts) } };
|
|
1837
|
+
}
|
|
1838
|
+
function boundsOf(parts) {
|
|
1839
|
+
if (parts.length === 0) {
|
|
1840
|
+
return { min: [0, 0, 0], max: [0, 0, 0] };
|
|
1841
|
+
}
|
|
1842
|
+
const min = [Infinity, Infinity, Infinity];
|
|
1843
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
1844
|
+
for (const part of parts) {
|
|
1845
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
1846
|
+
min[axis] = Math.min(min[axis], part.at[axis]);
|
|
1847
|
+
max[axis] = Math.max(
|
|
1848
|
+
max[axis],
|
|
1849
|
+
part.at[axis] + part.size[axis]
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
return { min, max };
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// src/configurator-tools.ts
|
|
1857
|
+
var CONFIGURATOR = 'A "configurator" field holds a 3D product configurator. Its structure has "geometry" (the shape document: "inputs" bound to parameters, "constants", material "slots", a "root" tree of building blocks and "annotations" for dimension lines), "parameters", "choiceLists", "materials", "priceGroups", "amounts", optional "presets", "rules" and "priceRules". Building blocks: box, rounded_top_box (params.radius), stadium_prism, arc_panel (params.thickness, params.mirror), led_strip, niche_panel (params.niche_x, niche_y, niche_width, niche_height) and repeat (count, children, index i or j). Every measure is millimetres; "at" is the minimum corner, x left to right, y bottom to top, z from the wall into the room; "size" is width, height, depth. A measure is a number or a formula with + - * / ( ) and min, max, floor, ceil, round, abs over the shape\u2019s inputs, constants and the repeat index. This server can check such a document but never save it: a reseller uploads it in the object builder.';
|
|
1858
|
+
var documentInput = z5.string().min(2).max(2e5).describe(
|
|
1859
|
+
'The JSON text of a whole configurator structure (it has "schemaVersion"), or of a shape document alone (it has "root").'
|
|
1860
|
+
);
|
|
1861
|
+
function registerConfiguratorTools(server, client) {
|
|
1862
|
+
server.registerTool(
|
|
1863
|
+
"check_configurator_shape",
|
|
1864
|
+
{
|
|
1865
|
+
title: "Check a configurator structure or shape document",
|
|
1866
|
+
description: "Checks a configurator structure or a shape document exactly as the object builder will when a reseller uploads it: the outline, every name a formula reads, bindings, cycles in the rules and the limits. A whole structure is also expanded at its smallest measures, so a part that would come out negative is named. Answers with every problem and where it is, or that the document is sound. " + CONFIGURATOR,
|
|
1867
|
+
inputSchema: { document: documentInput },
|
|
1868
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
1869
|
+
},
|
|
1870
|
+
({ document }) => {
|
|
1871
|
+
const problems = checkDocument(document);
|
|
1872
|
+
return answer(
|
|
1873
|
+
problems.length === 0 ? "The document is sound. A reseller can upload it in the object builder." : [
|
|
1874
|
+
`${problems.length} problem(s):`,
|
|
1875
|
+
...problems.map(describeIssue)
|
|
1876
|
+
].join("\n"),
|
|
1877
|
+
{ problems }
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
);
|
|
1881
|
+
server.registerTool(
|
|
1882
|
+
"read_configurator",
|
|
1883
|
+
{
|
|
1884
|
+
title: "Read the configurator of a product",
|
|
1885
|
+
description: "The configurator field of one product as its page receives it: the structure (texture paths already URLs), the model\u2019s value (ranges, offered options, amounts, presets), the tax a quote uses and the two hashes a request must send back. Useful to draft a new shape from an existing one. " + CONFIGURATOR,
|
|
1886
|
+
inputSchema: {
|
|
1887
|
+
slugOrId: z5.string().min(1).max(200).describe("The address (slug) or the id of the product."),
|
|
1888
|
+
field: z5.string().min(1).max(64).describe("The slug of the configurator field.")
|
|
1889
|
+
},
|
|
1890
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
1891
|
+
},
|
|
1892
|
+
async ({ slugOrId: slugOrId2, field }) => {
|
|
1893
|
+
try {
|
|
1894
|
+
const product = await client.getProduct(slugOrId2);
|
|
1895
|
+
const value = product?.fields[field];
|
|
1896
|
+
if (!product) {
|
|
1897
|
+
return answer(`No product "${slugOrId2}" on this account.`, null);
|
|
1898
|
+
}
|
|
1899
|
+
if (!value || typeof value !== "object" || !("structure" in value)) {
|
|
1900
|
+
return answer(
|
|
1901
|
+
`The product has no public configurator under "${field}".`,
|
|
1902
|
+
null
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
return answer(`The configurator "${field}" of ${product.name}.`, value);
|
|
1906
|
+
} catch (error) {
|
|
1907
|
+
return refusal(error);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
function checkDocument(text) {
|
|
1913
|
+
let parsed;
|
|
1914
|
+
try {
|
|
1915
|
+
parsed = JSON.parse(text);
|
|
1916
|
+
} catch {
|
|
1917
|
+
return [{ path: [], code: "configurator.notJson" }];
|
|
1918
|
+
}
|
|
1919
|
+
if (isRecord(parsed) && "schemaVersion" in parsed) {
|
|
1920
|
+
const structure = ConfiguratorStructureSchema.safeParse(parsed);
|
|
1921
|
+
if (!structure.success) {
|
|
1922
|
+
return structure.error.issues.map(toIssue);
|
|
1923
|
+
}
|
|
1924
|
+
const { configuration } = resolveConfiguration({
|
|
1925
|
+
structure: structure.data,
|
|
1926
|
+
value: emptyConfiguratorValue(),
|
|
1927
|
+
selection: { values: {} }
|
|
1928
|
+
});
|
|
1929
|
+
const expanded = expandGeometry(
|
|
1930
|
+
structure.data.geometry,
|
|
1931
|
+
configuration.inputs
|
|
1932
|
+
);
|
|
1933
|
+
return expanded.ok ? [] : expanded.issues.map((problem) => ({
|
|
1934
|
+
...problem,
|
|
1935
|
+
path: ["geometry", ...problem.path]
|
|
1936
|
+
}));
|
|
1937
|
+
}
|
|
1938
|
+
const geometry = GeometryDocumentSchema.safeParse(parsed);
|
|
1939
|
+
if (!geometry.success) {
|
|
1940
|
+
return geometry.error.issues.map(toIssue);
|
|
1941
|
+
}
|
|
1942
|
+
return geometryDocumentProblems(geometry.data);
|
|
1943
|
+
}
|
|
1944
|
+
function toIssue(problem) {
|
|
1945
|
+
const params = problem.params;
|
|
1946
|
+
return {
|
|
1947
|
+
path: problem.path.map(
|
|
1948
|
+
(part) => typeof part === "number" ? part : String(part)
|
|
1949
|
+
),
|
|
1950
|
+
code: problem.message,
|
|
1951
|
+
...params ? { params } : {}
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
function describeIssue(problem) {
|
|
1955
|
+
const where = formatIssuePath(problem.path) || "(the document)";
|
|
1956
|
+
const params = problem.params ? ` (${Object.entries(problem.params).map(([key, value]) => `${key}: ${value}`).join(", ")})` : "";
|
|
1957
|
+
return `- ${where}: ${problem.code}${params}`;
|
|
1958
|
+
}
|
|
1959
|
+
function isRecord(value) {
|
|
1960
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
49
1963
|
// src/placement-tools.ts
|
|
1964
|
+
import { z as z6 } from "zod";
|
|
50
1965
|
var REACT_HINT = "On a React or Next.js site, prefer the components in `@solumflow-app/crm-client/react` (CrmForm, CrmBooking, CrmChatWidget, CrmSiteTracking) over pasting HTML.";
|
|
51
1966
|
var page = {
|
|
52
|
-
cursor:
|
|
53
|
-
limit:
|
|
1967
|
+
cursor: z6.string().optional().describe("The nextCursor of the previous page; leave out for the first."),
|
|
1968
|
+
limit: z6.number().int().min(1).max(100).optional()
|
|
54
1969
|
};
|
|
55
|
-
var origin =
|
|
1970
|
+
var origin = z6.string().regex(/^https?:\/\/[a-zA-Z0-9.-]+(:[0-9]{1,5})?$/).describe("Scheme, host and optional port; no path: https://www.example.com");
|
|
56
1971
|
function registerPlacementTools(server, client) {
|
|
57
1972
|
server.registerTool(
|
|
58
1973
|
"list_forms",
|
|
@@ -80,7 +1995,7 @@ function registerPlacementTools(server, client) {
|
|
|
80
1995
|
"list_booking_pages",
|
|
81
1996
|
{
|
|
82
1997
|
title: "List booking pages",
|
|
83
|
-
description: "Every page a visitor can book on: the services overview, each category and service, and each appointment calendar. Each has a link
|
|
1998
|
+
description: "Every page a visitor can book on: the services overview, each category and service, and each appointment calendar. Each has a link and an embed snippet (an iframe plus a sizing script); a paid calendar embedded this way opens its payment in a tab of its own. " + REACT_HINT,
|
|
84
1999
|
inputSchema: {},
|
|
85
2000
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
86
2001
|
},
|
|
@@ -125,13 +2040,13 @@ function registerPlacementTools(server, client) {
|
|
|
125
2040
|
title: "Make or change a chat widget",
|
|
126
2041
|
description: "Makes the chat widget at this address when there is none, or changes it. Sets its name, whether it is live, and which sites it may run on. How it looks and what it says is done in the app\u2019s builder. Sites are added and removed, never replaced. Careful: an empty list means ANY site, so adding the first origin narrows the widget to that site only, and removing an origin takes the chat off a site that may be live.",
|
|
127
2042
|
inputSchema: {
|
|
128
|
-
slug:
|
|
2043
|
+
slug: z6.string().regex(/^[a-z][a-z0-9-]*$/).max(100).describe(
|
|
129
2044
|
'The widget\u2019s address on this account, such as "website-chat".'
|
|
130
2045
|
),
|
|
131
|
-
name:
|
|
132
|
-
status:
|
|
133
|
-
addAllowedOrigins:
|
|
134
|
-
removeAllowedOrigins:
|
|
2046
|
+
name: z6.string().min(1).max(200).optional().describe("Required when the widget does not exist yet."),
|
|
2047
|
+
status: z6.enum(["draft", "published"]).optional().describe("Only a published widget answers on a site."),
|
|
2048
|
+
addAllowedOrigins: z6.array(origin).max(20).optional(),
|
|
2049
|
+
removeAllowedOrigins: z6.array(origin).max(20).optional()
|
|
135
2050
|
},
|
|
136
2051
|
annotations: {
|
|
137
2052
|
readOnlyHint: false,
|
|
@@ -179,9 +2094,9 @@ ${describeChatWidget(widget)}`,
|
|
|
179
2094
|
title: "Set up or change the tracking script",
|
|
180
2095
|
description: "Sets website tracking up when it is not there, adds or removes the hosts it counts visits from, and changes the consent it waits for. An empty list of hosts counts nothing, so add the host of every site the script is placed on. Hosts are added and removed, never replaced.",
|
|
181
2096
|
inputSchema: {
|
|
182
|
-
addAllowedHosts:
|
|
183
|
-
removeAllowedHosts:
|
|
184
|
-
consentSignal:
|
|
2097
|
+
addAllowedHosts: z6.array(z6.string()).max(20).optional().describe('Hosts such as "www.example.com" or "*.example.com".'),
|
|
2098
|
+
removeAllowedHosts: z6.array(z6.string()).max(20).optional(),
|
|
2099
|
+
consentSignal: z6.enum([
|
|
185
2100
|
"ad_storage",
|
|
186
2101
|
"analytics_storage",
|
|
187
2102
|
"personalization_storage",
|
|
@@ -222,7 +2137,7 @@ function describeBookingPage(page2) {
|
|
|
222
2137
|
return [
|
|
223
2138
|
`**${page2.name}** (${page2.kind}${page2.id ? `, id ${page2.id}` : ""})`,
|
|
224
2139
|
`Link: ${page2.url}`,
|
|
225
|
-
|
|
2140
|
+
codeBlock(page2.embed)
|
|
226
2141
|
].join("\n");
|
|
227
2142
|
}
|
|
228
2143
|
function describeChatWidget(widget) {
|
|
@@ -278,12 +2193,15 @@ async function main() {
|
|
|
278
2193
|
}
|
|
279
2194
|
const server = new McpServer({
|
|
280
2195
|
name: "crm-mcp-server",
|
|
281
|
-
version: "0.
|
|
2196
|
+
version: "0.3.0"
|
|
282
2197
|
});
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
);
|
|
2198
|
+
const client = createClient({
|
|
2199
|
+
apiKey: config.apiKey,
|
|
2200
|
+
baseUrl: config.baseUrl
|
|
2201
|
+
});
|
|
2202
|
+
registerPlacementTools(server, client);
|
|
2203
|
+
registerCatalogTools(server, client);
|
|
2204
|
+
registerConfiguratorTools(server, client);
|
|
287
2205
|
await server.connect(new StdioServerTransport());
|
|
288
2206
|
}
|
|
289
2207
|
main().catch((error) => {
|