@merchi/product-form-sdk 1.0.9

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.
Files changed (50) hide show
  1. package/README.md +177 -0
  2. package/dist/components/Field.d.ts +7 -0
  3. package/dist/components/Field.js +353 -0
  4. package/dist/components/ProductFormActions.d.ts +11 -0
  5. package/dist/components/ProductFormActions.js +20 -0
  6. package/dist/components/context.d.ts +85 -0
  7. package/dist/components/context.js +198 -0
  8. package/dist/components/footerActions.d.ts +18 -0
  9. package/dist/components/footerActions.js +67 -0
  10. package/dist/components/groups.d.ts +25 -0
  11. package/dist/components/groups.js +204 -0
  12. package/dist/components/index.d.ts +8 -0
  13. package/dist/components/index.js +7 -0
  14. package/dist/components/layout.d.ts +43 -0
  15. package/dist/components/layout.js +69 -0
  16. package/dist/components/shell.d.ts +15 -0
  17. package/dist/components/shell.js +100 -0
  18. package/dist/helpers/area.d.ts +90 -0
  19. package/dist/helpers/area.js +287 -0
  20. package/dist/helpers/buildJob.d.ts +29 -0
  21. package/dist/helpers/buildJob.js +190 -0
  22. package/dist/helpers/constants.d.ts +8 -0
  23. package/dist/helpers/constants.js +8 -0
  24. package/dist/helpers/format.d.ts +4 -0
  25. package/dist/helpers/format.js +18 -0
  26. package/dist/helpers/gridQuote.d.ts +10 -0
  27. package/dist/helpers/gridQuote.js +32 -0
  28. package/dist/helpers/product.d.ts +7 -0
  29. package/dist/helpers/product.js +27 -0
  30. package/dist/helpers/serialize.d.ts +9 -0
  31. package/dist/helpers/serialize.js +35 -0
  32. package/dist/index.d.ts +17 -0
  33. package/dist/index.js +11 -0
  34. package/dist/runtime/clientPricing.d.ts +31 -0
  35. package/dist/runtime/clientPricing.js +70 -0
  36. package/dist/runtime/pricing.d.ts +29 -0
  37. package/dist/runtime/pricing.js +70 -0
  38. package/dist/runtime/runtime.d.ts +36 -0
  39. package/dist/runtime/runtime.js +32 -0
  40. package/dist/types/form.d.ts +26 -0
  41. package/dist/types/form.js +1 -0
  42. package/dist/types/job.d.ts +26 -0
  43. package/dist/types/job.js +1 -0
  44. package/dist/types/product.d.ts +30 -0
  45. package/dist/types/product.js +1 -0
  46. package/dist/types/variation.d.ts +46 -0
  47. package/dist/types/variation.js +1 -0
  48. package/dist/version.d.ts +4 -0
  49. package/dist/version.js +6 -0
  50. package/package.json +49 -0
@@ -0,0 +1,100 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ import { ProductFormProvider, useProductForm, } from './context';
4
+ import { theme } from './layout';
5
+ import { productHasGroups } from '../helpers/product';
6
+ import { footerActionsFromComponent, resolveFooterActions, } from './footerActions';
7
+ function QuantityControl() {
8
+ const { product, quantity, setQuantity } = useProductForm();
9
+ if (productHasGroups(product))
10
+ return null;
11
+ const min = Number(product.minOrderQuantity ?? product.minimum) || 1;
12
+ return (_jsxs("div", { children: [_jsx("label", { style: {
13
+ display: 'block',
14
+ fontFamily: theme.font,
15
+ fontSize: 14,
16
+ fontWeight: 700,
17
+ color: theme.text,
18
+ marginBottom: 8,
19
+ }, children: "Quantity" }), _jsx("input", { type: "number", min: min, value: quantity, onChange: (e) => setQuantity(Math.max(min, parseInt(e.target.value, 10) || min)), style: {
20
+ width: 160,
21
+ padding: '10px 12px',
22
+ fontFamily: theme.font,
23
+ fontSize: 14,
24
+ color: theme.text,
25
+ background: theme.card,
26
+ border: `1px solid ${theme.border}`,
27
+ borderRadius: 8,
28
+ outline: 'none',
29
+ boxSizing: 'border-box',
30
+ } })] }));
31
+ }
32
+ function btn(primary) {
33
+ return {
34
+ padding: '10px 18px',
35
+ fontFamily: theme.font,
36
+ fontSize: 14,
37
+ fontWeight: 700,
38
+ borderRadius: 8,
39
+ cursor: 'pointer',
40
+ border: `1px solid ${primary ? theme.primary : theme.border}`,
41
+ background: primary ? theme.primary : theme.card,
42
+ color: primary ? theme.primaryText : theme.text,
43
+ };
44
+ }
45
+ function Footer() {
46
+ const { quote, loading, currency, helpers, available, actionLabels, footerActions, submit, } = useProductForm();
47
+ const total = quote?.totalCost;
48
+ const unit = quote?.costPerUnit;
49
+ const fmt = (n) => typeof n === 'number' ? helpers.formatCurrency(n, currency) : '—';
50
+ const buttons = resolveFooterActions({
51
+ available,
52
+ actionLabels,
53
+ footerActions,
54
+ });
55
+ return (_jsxs("div", { style: {
56
+ borderTop: `1px solid ${theme.border}`,
57
+ paddingTop: 20,
58
+ display: 'flex',
59
+ flexWrap: 'wrap',
60
+ alignItems: 'center',
61
+ justifyContent: 'space-between',
62
+ gap: 16,
63
+ }, children: [_jsxs("div", { children: [_jsxs("div", { style: {
64
+ fontFamily: theme.font,
65
+ fontSize: 12,
66
+ textTransform: 'uppercase',
67
+ letterSpacing: '0.06em',
68
+ color: theme.muted,
69
+ }, children: ["Total ", loading ? '· updating…' : ''] }), _jsx("div", { style: {
70
+ fontFamily: theme.font,
71
+ fontSize: 26,
72
+ fontWeight: 800,
73
+ color: theme.text,
74
+ }, children: fmt(total) }), typeof unit === 'number' ? (_jsxs("div", { style: { fontFamily: theme.font, fontSize: 13, color: theme.muted }, children: [fmt(unit), " per unit"] })) : null] }), _jsx("div", { style: { display: 'flex', gap: 10, flexWrap: 'wrap' }, children: buttons.map((item) => (_jsx("button", { type: "button", style: btn(!!item.primary), onClick: () => submit(item.action), children: item.label }, item.action))) })] }));
75
+ }
76
+ /**
77
+ * Wraps a custom form: provides the form context (state + live pricing) and
78
+ * renders a quantity control above and a total + submit footer below the form's
79
+ * own content. Hosts render this around the form's default export.
80
+ *
81
+ * If `product` is omitted, render children only. AI-built forms sometimes nest
82
+ * `<ProductFormShell>` without props; the host already provided the real shell,
83
+ * so a second Provider with undefined product would crash and fall back to the
84
+ * default GroupRows form.
85
+ */
86
+ export function ProductFormShell({ product, pricing, actions, helpers, children, initialJob, actionLabels, footerActions: footerActionsProp, }) {
87
+ if (product == null || pricing == null || actions == null || helpers == null) {
88
+ return _jsx(_Fragment, { children: children });
89
+ }
90
+ const childFooterActions = React.Children.count(children) === 1 && React.isValidElement(children)
91
+ ? footerActionsFromComponent(children.type)
92
+ : null;
93
+ const footerActions = footerActionsProp ?? childFooterActions;
94
+ return (_jsx(ProductFormProvider, { product: product, pricing: pricing, actions: actions, helpers: helpers, initialJob: initialJob, actionLabels: actionLabels, footerActions: footerActions, children: _jsxs("div", { style: {
95
+ display: 'flex',
96
+ flexDirection: 'column',
97
+ gap: 24,
98
+ fontFamily: theme.font,
99
+ }, children: [_jsx(QuantityControl, {}), children, _jsx(Footer, {})] }) }));
100
+ }
@@ -0,0 +1,90 @@
1
+ /** Shared helpers for Area (height × width) variation fields. Stored value is always millimetres. */
2
+ export type AreaUnit = 'mm' | 'cm' | 'm' | 'in' | 'ft';
3
+ export type AreaInputType = 'input' | 'slider';
4
+ export type DisplayModality = 'metric' | 'imperial';
5
+ export declare function parseAreaValue(value: string | null | undefined): {
6
+ heightMm: number;
7
+ widthMm: number;
8
+ } | null;
9
+ export declare function formatAreaValue(heightMm: number, widthMm: number): string;
10
+ export declare function isImperialUnit(unit: AreaUnit | string | null | undefined): boolean;
11
+ export declare function normaliseAreaUnit(unit?: string | null): AreaUnit;
12
+ /** Imperial for en-US / en-LR / en-MM; otherwise metric (incl. en-GB). */
13
+ export declare function localePrefersImperial(locale?: string): boolean;
14
+ /** Millimetres per one unit of the given area unit. */
15
+ export declare function unitFactorMm(unit?: AreaUnit | string): number;
16
+ /** @deprecated use unitFactorMm */
17
+ export declare function metricUnitFactor(unit?: AreaUnit): number;
18
+ export declare function mmToUnit(mm: number, unit?: AreaUnit | string): number;
19
+ export declare function unitToMm(value: number, unit?: AreaUnit | string): number;
20
+ /** @deprecated use mmToUnit */
21
+ export declare function mmToMetricDisplay(mm: number, unit?: AreaUnit): number;
22
+ /** @deprecated use unitToMm */
23
+ export declare function metricDisplayToMm(value: number, unit?: AreaUnit): number;
24
+ export declare function mmToInches(mm: number): number;
25
+ export declare function inchesToMm(inches: number): number;
26
+ /**
27
+ * Unit shown for the current buyer modality.
28
+ * Metric → field unit if metric, else mm.
29
+ * Imperial → field unit if imperial, else inches.
30
+ */
31
+ export declare function activeDisplayUnit(modality: DisplayModality, areaUnit?: AreaUnit | string): AreaUnit;
32
+ export declare function defaultModalityForAreaUnit(areaUnit?: AreaUnit | string): DisplayModality;
33
+ export declare function mmToDisplay(mm: number, modality: DisplayModality, areaUnit?: AreaUnit | string): number;
34
+ export declare function displayToMm(value: number, modality: DisplayModality, areaUnit?: AreaUnit | string): number;
35
+ export declare function unitLabel(modality: DisplayModality, areaUnit?: AreaUnit | string): string;
36
+ export declare function defaultAreaStep(unit?: AreaUnit | string): number;
37
+ /**
38
+ * Step for inputs/sliders in the current display unit.
39
+ * `stepMm` is the admin-configured step stored in millimetres.
40
+ */
41
+ export declare function stepInDisplayUnit(stepMm: number | null | undefined, modality: DisplayModality, areaUnit?: AreaUnit | string): number;
42
+ /** Format a length in mm for buyer-facing summary. */
43
+ export declare function formatLengthFromMm(mm: number, modality: DisplayModality, areaUnit?: AreaUnit | string): string;
44
+ export declare function formatAreaParts(value: string | null | undefined, modality?: DisplayModality, areaUnit?: AreaUnit | string): {
45
+ width: string;
46
+ height: string;
47
+ areaLabel: string;
48
+ } | null;
49
+ export declare function formatAreaSummary(value: string | null | undefined, modality?: DisplayModality, areaUnit?: AreaUnit | string): string | null;
50
+ export declare function clamp(n: number, min?: number | null, max?: number | null): number;
51
+ interface AreaCostVariationField {
52
+ areaUnit?: AreaUnit | string | null;
53
+ area_unit?: AreaUnit | string | null;
54
+ heightVariationCost?: number | string | null;
55
+ height_variation_cost?: number | string | null;
56
+ widthVariationCost?: number | string | null;
57
+ width_variation_cost?: number | string | null;
58
+ heightVariationUnitCost?: number | string | null;
59
+ height_variation_unit_cost?: number | string | null;
60
+ widthVariationUnitCost?: number | string | null;
61
+ width_variation_unit_cost?: number | string | null;
62
+ }
63
+ /**
64
+ * Client-side Area cost estimate (pre-discount), matching API formula:
65
+ * onceOff = heightCost × widthCost × height_u × width_u
66
+ * unitCost = heightUnit × widthUnit × height_u × width_u
67
+ * (dimensions expressed in the field's areaUnit)
68
+ */
69
+ export declare function estimateAreaCosts(variationField: AreaCostVariationField, value: string | null | undefined): {
70
+ onceOffCost: number;
71
+ unitCost: number;
72
+ } | null;
73
+ /**
74
+ * Keep width/height linked by aspectRatio (width/height).
75
+ * `changed` indicates which dimension the user edited.
76
+ */
77
+ export declare function clampWithAspectRatio(args: {
78
+ heightMm: number;
79
+ widthMm: number;
80
+ changed: 'height' | 'width';
81
+ aspectRatio: number;
82
+ heightMin?: number | null;
83
+ heightMax?: number | null;
84
+ widthMin?: number | null;
85
+ widthMax?: number | null;
86
+ }): {
87
+ heightMm: number;
88
+ widthMm: number;
89
+ };
90
+ export {};
@@ -0,0 +1,287 @@
1
+ /** Shared helpers for Area (height × width) variation fields. Stored value is always millimetres. */
2
+ const MM_PER_INCH = 25.4;
3
+ const MM_PER_FOOT = 304.8;
4
+ const MM_PER_CM = 10;
5
+ const MM_PER_M = 1000;
6
+ export function parseAreaValue(value) {
7
+ if (value == null)
8
+ return null;
9
+ const text = String(value).trim();
10
+ if (!text)
11
+ return null;
12
+ const parts = text.split(',').map((p) => p.trim());
13
+ if (parts.length !== 2)
14
+ return null;
15
+ const heightMm = Number(parts[0]);
16
+ const widthMm = Number(parts[1]);
17
+ if (!Number.isFinite(heightMm) || !Number.isFinite(widthMm))
18
+ return null;
19
+ if (heightMm <= 0 || widthMm <= 0)
20
+ return null;
21
+ return { heightMm, widthMm };
22
+ }
23
+ export function formatAreaValue(heightMm, widthMm) {
24
+ const h = Number(heightMm);
25
+ const w = Number(widthMm);
26
+ if (!Number.isFinite(h) || !Number.isFinite(w) || h <= 0 || w <= 0)
27
+ return '';
28
+ return `${trimNum(h)},${trimNum(w)}`;
29
+ }
30
+ function trimNum(n) {
31
+ return String(Number(n.toFixed(6)));
32
+ }
33
+ export function isImperialUnit(unit) {
34
+ const u = String(unit || 'mm').toLowerCase();
35
+ return u === 'in' || u === 'ft';
36
+ }
37
+ export function normaliseAreaUnit(unit) {
38
+ const u = String(unit || 'mm').toLowerCase();
39
+ if (u === 'cm' || u === 'm' || u === 'in' || u === 'ft' || u === 'mm') {
40
+ return u;
41
+ }
42
+ return 'mm';
43
+ }
44
+ /** Imperial for en-US / en-LR / en-MM; otherwise metric (incl. en-GB). */
45
+ export function localePrefersImperial(locale) {
46
+ const lang = locale ||
47
+ (typeof navigator !== 'undefined' ? navigator.language : undefined) ||
48
+ '';
49
+ const normalized = lang.toLowerCase().replace('_', '-');
50
+ return (normalized === 'en-us' ||
51
+ normalized === 'en-lr' ||
52
+ normalized === 'en-mm' ||
53
+ normalized.endsWith('-us') ||
54
+ normalized.endsWith('-lr') ||
55
+ normalized.endsWith('-mm'));
56
+ }
57
+ /** Millimetres per one unit of the given area unit. */
58
+ export function unitFactorMm(unit = 'mm') {
59
+ switch (normaliseAreaUnit(unit)) {
60
+ case 'cm':
61
+ return MM_PER_CM;
62
+ case 'm':
63
+ return MM_PER_M;
64
+ case 'in':
65
+ return MM_PER_INCH;
66
+ case 'ft':
67
+ return MM_PER_FOOT;
68
+ default:
69
+ return 1;
70
+ }
71
+ }
72
+ /** @deprecated use unitFactorMm */
73
+ export function metricUnitFactor(unit = 'mm') {
74
+ return unitFactorMm(unit);
75
+ }
76
+ export function mmToUnit(mm, unit = 'mm') {
77
+ return mm / unitFactorMm(unit);
78
+ }
79
+ export function unitToMm(value, unit = 'mm') {
80
+ return value * unitFactorMm(unit);
81
+ }
82
+ /** @deprecated use mmToUnit */
83
+ export function mmToMetricDisplay(mm, unit = 'mm') {
84
+ return mmToUnit(mm, unit);
85
+ }
86
+ /** @deprecated use unitToMm */
87
+ export function metricDisplayToMm(value, unit = 'mm') {
88
+ return unitToMm(value, unit);
89
+ }
90
+ export function mmToInches(mm) {
91
+ return mm / MM_PER_INCH;
92
+ }
93
+ export function inchesToMm(inches) {
94
+ return inches * MM_PER_INCH;
95
+ }
96
+ /**
97
+ * Unit shown for the current buyer modality.
98
+ * Metric → field unit if metric, else mm.
99
+ * Imperial → field unit if imperial, else inches.
100
+ */
101
+ export function activeDisplayUnit(modality, areaUnit = 'mm') {
102
+ const unit = normaliseAreaUnit(areaUnit);
103
+ if (modality === 'imperial') {
104
+ return isImperialUnit(unit) ? unit : 'in';
105
+ }
106
+ return isImperialUnit(unit) ? 'mm' : unit;
107
+ }
108
+ export function defaultModalityForAreaUnit(areaUnit = 'mm') {
109
+ if (isImperialUnit(areaUnit))
110
+ return 'imperial';
111
+ return localePrefersImperial() ? 'imperial' : 'metric';
112
+ }
113
+ export function mmToDisplay(mm, modality, areaUnit = 'mm') {
114
+ return mmToUnit(mm, activeDisplayUnit(modality, areaUnit));
115
+ }
116
+ export function displayToMm(value, modality, areaUnit = 'mm') {
117
+ return unitToMm(value, activeDisplayUnit(modality, areaUnit));
118
+ }
119
+ export function unitLabel(modality, areaUnit = 'mm') {
120
+ return activeDisplayUnit(modality, areaUnit);
121
+ }
122
+ export function defaultAreaStep(unit = 'mm') {
123
+ switch (normaliseAreaUnit(unit)) {
124
+ case 'm':
125
+ return 0.001;
126
+ case 'cm':
127
+ return 0.1;
128
+ case 'in':
129
+ return 0.125;
130
+ case 'ft':
131
+ return 0.01;
132
+ default:
133
+ return 1;
134
+ }
135
+ }
136
+ /**
137
+ * Step for inputs/sliders in the current display unit.
138
+ * `stepMm` is the admin-configured step stored in millimetres.
139
+ */
140
+ export function stepInDisplayUnit(stepMm, modality, areaUnit = 'mm') {
141
+ const unit = activeDisplayUnit(modality, areaUnit);
142
+ const raw = Number(stepMm);
143
+ if (Number.isFinite(raw) && raw > 0) {
144
+ return raw / unitFactorMm(unit);
145
+ }
146
+ return defaultAreaStep(unit);
147
+ }
148
+ function decimalsForUnit(unit) {
149
+ if (unit === 'm')
150
+ return 4;
151
+ if (unit === 'ft')
152
+ return 3;
153
+ if (unit === 'in' || unit === 'cm')
154
+ return 2;
155
+ return 2;
156
+ }
157
+ /** Format a length in mm for buyer-facing summary. */
158
+ export function formatLengthFromMm(mm, modality, areaUnit = 'mm') {
159
+ const unit = activeDisplayUnit(modality, areaUnit);
160
+ if (unit === 'ft') {
161
+ const feet = mmToUnit(mm, 'ft');
162
+ return `${trimNum(Number(feet.toFixed(3)))} ft`;
163
+ }
164
+ if (unit === 'in') {
165
+ const inches = mmToInches(mm);
166
+ if (inches >= 12) {
167
+ const feet = Math.floor(inches / 12);
168
+ const rem = inches - feet * 12;
169
+ if (rem < 0.05)
170
+ return `${feet} ft`;
171
+ return `${feet} ft ${trimNum(Number(rem.toFixed(2)))} in`;
172
+ }
173
+ return `${trimNum(Number(inches.toFixed(2)))} in`;
174
+ }
175
+ const display = mmToUnit(mm, unit);
176
+ return `${trimNum(Number(display.toFixed(decimalsForUnit(unit))))} ${unit}`;
177
+ }
178
+ export function formatAreaParts(value, modality = 'metric', areaUnit = 'mm') {
179
+ const parsed = parseAreaValue(value);
180
+ if (!parsed)
181
+ return null;
182
+ const { heightMm, widthMm } = parsed;
183
+ const height = formatLengthFromMm(heightMm, modality, areaUnit);
184
+ const width = formatLengthFromMm(widthMm, modality, areaUnit);
185
+ const areaMm2 = heightMm * widthMm;
186
+ const unit = activeDisplayUnit(modality, areaUnit);
187
+ let areaLabel;
188
+ if (unit === 'ft') {
189
+ const sqFt = areaMm2 / (MM_PER_FOOT * MM_PER_FOOT);
190
+ areaLabel = `${trimNum(Number(sqFt.toFixed(3)))} sq ft`;
191
+ }
192
+ else if (unit === 'in') {
193
+ const sqIn = areaMm2 / (MM_PER_INCH * MM_PER_INCH);
194
+ if (sqIn >= 144) {
195
+ areaLabel = `${trimNum(Number((sqIn / 144).toFixed(3)))} sq ft`;
196
+ }
197
+ else {
198
+ areaLabel = `${trimNum(Number(sqIn.toFixed(2)))} sq in`;
199
+ }
200
+ }
201
+ else if (unit === 'm') {
202
+ areaLabel = `${trimNum(Number((areaMm2 / 1000000).toFixed(4)))} m²`;
203
+ }
204
+ else if (unit === 'cm') {
205
+ areaLabel = `${trimNum(Number((areaMm2 / 100).toFixed(2)))} cm²`;
206
+ }
207
+ else {
208
+ areaLabel = `${trimNum(Number(areaMm2.toFixed(2)))} mm²`;
209
+ }
210
+ return { width, height, areaLabel };
211
+ }
212
+ export function formatAreaSummary(value, modality = 'metric', areaUnit = 'mm') {
213
+ const parts = formatAreaParts(value, modality, areaUnit);
214
+ if (!parts)
215
+ return null;
216
+ // Width × height (landscape-first) to match the buyer control order.
217
+ return `Width ${parts.width} × Height ${parts.height} (${parts.areaLabel})`;
218
+ }
219
+ export function clamp(n, min, max) {
220
+ let v = n;
221
+ if (min != null && Number.isFinite(min))
222
+ v = Math.max(v, min);
223
+ if (max != null && Number.isFinite(max))
224
+ v = Math.min(v, max);
225
+ return v;
226
+ }
227
+ /**
228
+ * Client-side Area cost estimate (pre-discount), matching API formula:
229
+ * onceOff = heightCost × widthCost × height_u × width_u
230
+ * unitCost = heightUnit × widthUnit × height_u × width_u
231
+ * (dimensions expressed in the field's areaUnit)
232
+ */
233
+ export function estimateAreaCosts(variationField, value) {
234
+ const parsed = parseAreaValue(value);
235
+ if (!parsed)
236
+ return null;
237
+ const areaUnit = normaliseAreaUnit(variationField?.areaUnit ?? variationField?.area_unit ?? 'mm');
238
+ const heightU = mmToUnit(parsed.heightMm, areaUnit);
239
+ const widthU = mmToUnit(parsed.widthMm, areaUnit);
240
+ const area = heightU * widthU;
241
+ const heightCost = Number(variationField?.heightVariationCost ??
242
+ variationField?.height_variation_cost) || 0;
243
+ const widthCost = Number(variationField?.widthVariationCost ?? variationField?.width_variation_cost) || 0;
244
+ const heightUnit = Number(variationField?.heightVariationUnitCost ??
245
+ variationField?.height_variation_unit_cost) || 0;
246
+ const widthUnit = Number(variationField?.widthVariationUnitCost ??
247
+ variationField?.width_variation_unit_cost) || 0;
248
+ const onceOffCost = heightCost * widthCost * area;
249
+ const unitCost = heightUnit * widthUnit * area;
250
+ if (!(onceOffCost > 0) && !(unitCost > 0))
251
+ return null;
252
+ return {
253
+ onceOffCost: Number(onceOffCost.toFixed(3)),
254
+ unitCost: Number(unitCost.toFixed(3)),
255
+ };
256
+ }
257
+ /**
258
+ * Keep width/height linked by aspectRatio (width/height).
259
+ * `changed` indicates which dimension the user edited.
260
+ */
261
+ export function clampWithAspectRatio(args) {
262
+ const ratio = args.aspectRatio;
263
+ if (!Number.isFinite(ratio) || ratio <= 0) {
264
+ return {
265
+ heightMm: clamp(args.heightMm, args.heightMin, args.heightMax),
266
+ widthMm: clamp(args.widthMm, args.widthMin, args.widthMax),
267
+ };
268
+ }
269
+ let heightMm = args.heightMm;
270
+ let widthMm = args.widthMm;
271
+ if (args.changed === 'height') {
272
+ heightMm = clamp(heightMm, args.heightMin, args.heightMax);
273
+ widthMm = heightMm * ratio;
274
+ widthMm = clamp(widthMm, args.widthMin, args.widthMax);
275
+ heightMm = widthMm / ratio;
276
+ heightMm = clamp(heightMm, args.heightMin, args.heightMax);
277
+ widthMm = heightMm * ratio;
278
+ }
279
+ else {
280
+ widthMm = clamp(widthMm, args.widthMin, args.widthMax);
281
+ heightMm = widthMm / ratio;
282
+ heightMm = clamp(heightMm, args.heightMin, args.heightMax);
283
+ widthMm = heightMm * ratio;
284
+ widthMm = clamp(widthMm, args.widthMin, args.widthMax);
285
+ }
286
+ return { heightMm, widthMm };
287
+ }
@@ -0,0 +1,29 @@
1
+ import { JobJson } from '../types/job';
2
+ import { ProductJson } from '../types/product';
3
+ import { VariationJson } from '../types/variation';
4
+ import { FieldSelection } from '../components/context';
5
+ export interface GroupRow {
6
+ quantity: number;
7
+ selections: Record<number, FieldSelection>;
8
+ }
9
+ export interface ProductFormJobState {
10
+ product: ProductJson;
11
+ quantity: number;
12
+ /** Independent (whole-order) field selections. */
13
+ selections: Record<number, FieldSelection>;
14
+ /** Multi-batch group rows when the product has 2+ group fields or non-grid group UX. */
15
+ groups?: GroupRow[];
16
+ /** Per-option quantities for single group-field grid products. */
17
+ optionQuantities?: Record<number, number>;
18
+ }
19
+ export declare function selectionsToVariations(selections: Record<number, FieldSelection>): VariationJson[];
20
+ /** Build the job JSON the API expects from provider/form state. */
21
+ export declare function buildProductFormJob(state: ProductFormJobState): JobJson;
22
+ export interface HydratedProductFormState {
23
+ quantity: number;
24
+ selections: Record<number, FieldSelection>;
25
+ groups: GroupRow[];
26
+ optionQuantities: Record<number, number>;
27
+ }
28
+ /** Reverse of buildProductFormJob — hydrate provider state from a cart item / job. */
29
+ export declare function jobToFormState(product: ProductJson, job?: JobJson | null): HydratedProductFormState;