@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
package/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # Merchi Product Form SDK
2
+
3
+ Contract SDK for AI-built and hand-written **custom Merchi product order forms**.
4
+
5
+ [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
6
+
7
+ Custom forms are React components that run inside the Merchi dashboard preview and the product embed. This package defines the **only** imports, components, and helpers those forms may use. Source is validated by a static gate and compiled to a browser bundle that externalises `react` and this SDK to `window` globals.
8
+
9
+ ## Table of Contents
10
+
11
+ - [Installation](#installation)
12
+ - [Quick start](#quick-start)
13
+ - [How forms run](#how-forms-run)
14
+ - [Form component contract](#form-component-contract)
15
+ - [Layout and fields](#layout-and-fields)
16
+ - [Group variations](#group-variations)
17
+ - [Pricing and job building](#pricing-and-job-building)
18
+ - [Allowed exports](#allowed-exports)
19
+ - [Development](#development)
20
+ - [License](#license)
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ npm install @merchi/product-form-sdk
26
+ ```
27
+
28
+ Peer dependency: `react` ^18 or ^19.
29
+
30
+ In the Merchi monorepo, the dashboard installs a packed tarball:
31
+
32
+ ```bash
33
+ cd packages/product-form-sdk
34
+ npm run build && npm pack
35
+ cd ../merchi_dashboard && npm install
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ A minimal form renders independent variation fields and relies on the host shell for quantity, live pricing, and submit actions:
41
+
42
+ ```tsx
43
+ import React from 'react';
44
+ import {
45
+ Section,
46
+ Stack,
47
+ Heading,
48
+ Text,
49
+ Field,
50
+ } from '@merchi/product-form-sdk';
51
+
52
+ export default function Form({ product }) {
53
+ const fields = product?.independentVariationFields ?? [];
54
+
55
+ return (
56
+ <Stack gap={24}>
57
+ <Section>
58
+ <Heading>{product?.name || 'Order form'}</Heading>
59
+ {product?.description ? <Text muted>{product.description}</Text> : null}
60
+ </Section>
61
+ <Section title="Options">
62
+ <Stack gap={16}>
63
+ {fields.map((field) => (
64
+ <Field key={field.id} field={field} />
65
+ ))}
66
+ </Stack>
67
+ </Section>
68
+ </Stack>
69
+ );
70
+ }
71
+ ```
72
+
73
+ See `examples/colour-grid-form.tsx` for a per-colour quantity grid (wristbands, multi-SKU products).
74
+
75
+ ## How forms run
76
+
77
+ 1. **Authoring** — Form source is stored as TSX on a `ProductForm` in Merchi.
78
+ 2. **Gate** — Only imports from `react` and `@merchi/product-form-sdk` are allowed (legacy `merchi_sdk_product_form` may still be accepted for older drafts).
79
+ 3. **Compile** — esbuild bundles the form to an IIFE; `react` → `window.React`, `@merchi/product-form-sdk` → `window.MerchiProductFormSdk`.
80
+ 4. **Host** — The dashboard or embed loads the bundle, provides `createProductFormRuntime`, and wraps the form in `ProductFormShell` (quantity control, live quote footer, submit buttons).
81
+
82
+ Form authors should **not** reimplement quantity controls or pricing footers. To replace Get quote / Buy now / Add to cart, use `ProductFormActions` (or `Form.footerActions`) — do not hand-roll checkout buttons.
83
+
84
+ ## Form component contract
85
+
86
+ Default-export a React component matching `ProductFormComponent`:
87
+
88
+ ```tsx
89
+ export default function Form({ product, pricing, actions, helpers }) {
90
+ // product — ProductJson for the item being ordered
91
+ // pricing — request quotes as the user edits (createPricing wrapper)
92
+ // actions — submit handlers (addToCart, buyNow, …); may be undefined
93
+ // helpers — serializeJob, nonEmptyGroups, formatCurrency, urlFor
94
+ }
95
+ ```
96
+
97
+ Inside composed UI, prefer `useProductForm()` from a `ProductFormProvider` / `ProductFormShell` subtree for quote state and submit wiring.
98
+
99
+ ## Layout and fields
100
+
101
+ | Export | Purpose |
102
+ |--------|---------|
103
+ | `Section`, `Stack`, `Card` | Page structure |
104
+ | `Heading`, `Text`, `Divider` | Typography |
105
+ | `Field` | Render a product variation field (options, text, files, etc.) |
106
+ | `theme` | Shared spacing/colour tokens |
107
+
108
+ Loop `product.independentVariationFields` for options that apply to the **whole order** (`job.variations`).
109
+
110
+ There is **no** `TextInput`, `Select`, `Button`, or `ColorSwatches` — use `Field` and layout components only.
111
+
112
+ To change the host footer (for example one **Place order** button that opens the Get quote modal):
113
+
114
+ ```tsx
115
+ import { ProductFormActions } from '@merchi/product-form-sdk';
116
+
117
+ export default function Form({ product }) {
118
+ return (
119
+ <Stack gap={24}>
120
+ <ProductFormActions
121
+ actions={[{ action: 'getQuote', label: 'Place order', primary: true }]}
122
+ />
123
+ {/* layout */}
124
+ </Stack>
125
+ );
126
+ }
127
+ Form.footerActions = [{ action: 'getQuote', label: 'Place order', primary: true }];
128
+ ```
129
+
130
+ ## Group variations
131
+
132
+ Some products need **per-batch** choices (e.g. colour × quantity grids):
133
+
134
+ | Helper / component | When to use |
135
+ |--------------------|-------------|
136
+ | `productHasGroups(product)` | Product has `groupVariationFields` |
137
+ | `isOptionQuantityGridProduct(product)` | Single option-based group field → use grid |
138
+ | `OptionQuantityGrid` | Table: swatch, quantity, row cost, inventory |
139
+ | `GroupRows` | Multiple group fields or free-text group fields |
140
+ | `Field` with `groupIndex` | Field inside a specific batch row |
141
+
142
+ When groups exist, each batch has its own `quantity` in `job.variationsGroups`. Do not use top-level `job.quantity` — `ProductFormShell` hides the global quantity control for group products.
143
+
144
+ ## Pricing and job building
145
+
146
+ | Export | Purpose |
147
+ |--------|---------|
148
+ | `createPricing` / `createProductFormRuntime` | Host/runtime wiring (embed & dashboard) |
149
+ | `buildProductFormJob` | Build a `JobJson` from form state |
150
+ | `selectionsToVariations` | Map field selections to variation payloads |
151
+ | `serializeJob` | Strip empty groups before submit |
152
+ | `nonEmptyGroups` | Filter groups with `quantity > 0` |
153
+ | `formatCurrency`, `urlFor` | Display helpers |
154
+
155
+ ## Allowed exports
156
+
157
+ The compile gate allowlists these **runtime** named imports (type-only imports are erased and not checked):
158
+
159
+ `SDK_VERSION`, `serializeJob`, `nonEmptyGroups`, `formatCurrency`, `urlFor`, `createPricing`, `createProductFormRuntime`, `productHasGroups`, `groupFieldsOf`, `independentFieldsOf`, `isOptionQuantityGridProduct`, `buildProductFormJob`, `selectionsToVariations`, `Section`, `Stack`, `Card`, `Heading`, `Text`, `Divider`, `Field`, `GroupRows`, `OptionQuantityGrid`, `ProductFormProvider`, `ProductFormShell`, `ProductFormActions`, `useProductForm`, `OPTION_FIELD_TYPES`, `theme`
160
+
161
+ Keep this list in sync with `src/index.ts` and `merchi_api/common/js/product_form_gate.cjs`.
162
+
163
+ ## Development
164
+
165
+ ```bash
166
+ npm install
167
+ npm run build # tsc → dist/
168
+ npm test # jest
169
+ npm run lint # eslint src
170
+ npm pack # merchi-product-form-sdk-<version>.tgz
171
+ ```
172
+
173
+ Repository: [github.com/merchisdk/merchi_ts/tree/main/packages/product-form-sdk](https://github.com/merchisdk/merchi_ts/tree/main/packages/product-form-sdk)
174
+
175
+ ## License
176
+
177
+ GPL-3.0 — see [LICENSE](https://www.gnu.org/licenses/gpl-3.0) (same as [@merchi/sdk](https://www.npmjs.com/package/@merchi/sdk)).
@@ -0,0 +1,7 @@
1
+ import * as React from 'react';
2
+ import { VariationFieldJson } from '../types/variation';
3
+ export declare function Field({ field, groupIndex, }: {
4
+ field?: VariationFieldJson | null;
5
+ /** When set, reads/writes that batch's group-field selections (see GroupRows). */
6
+ groupIndex?: number;
7
+ }): React.JSX.Element | null;
@@ -0,0 +1,353 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from 'react';
3
+ import { useProductForm } from './context';
4
+ import { theme } from './layout';
5
+ import { clampWithAspectRatio, defaultModalityForAreaUnit, displayToMm, estimateAreaCosts, formatAreaSummary, formatAreaValue, mmToDisplay, parseAreaValue, stepInDisplayUnit, unitLabel, } from '../helpers/area';
6
+ import { formatCurrency } from '../helpers/format';
7
+ const FIELD = {
8
+ TEXT_INPUT: 1,
9
+ SELECT: 2,
10
+ FILE_UPLOAD: 3,
11
+ TEXT_AREA: 4,
12
+ NUMBER_INPUT: 5,
13
+ CHECKBOX: 6,
14
+ RADIO: 7,
15
+ FIELD_INSTRUCTIONS: 8,
16
+ IMAGE_SELECT: 9,
17
+ COLOUR_PICKER: 10,
18
+ COLOUR_SELECT: 11,
19
+ TURNAROUND_TIME: 12,
20
+ COLOUR_EXTRACT: 13,
21
+ AREA: 14,
22
+ };
23
+ function Label({ field }) {
24
+ if (!field.name)
25
+ return null;
26
+ return (_jsxs("label", { style: {
27
+ fontFamily: theme.font,
28
+ fontSize: 14,
29
+ fontWeight: 700,
30
+ color: theme.text,
31
+ marginBottom: 8,
32
+ display: 'block',
33
+ }, children: [field.name, field.required ? _jsx("span", { style: { color: theme.primary }, children: " *" }) : null] }));
34
+ }
35
+ function inputStyle() {
36
+ return {
37
+ width: '100%',
38
+ padding: '10px 12px',
39
+ fontFamily: theme.font,
40
+ fontSize: 14,
41
+ color: theme.text,
42
+ background: theme.card,
43
+ border: `1px solid ${theme.border}`,
44
+ borderRadius: 8,
45
+ outline: 'none',
46
+ boxSizing: 'border-box',
47
+ };
48
+ }
49
+ function visibleOptions(field) {
50
+ return (field.options || []).filter((o) => o?.isVisible !== false);
51
+ }
52
+ function AreaFieldControl({ field, value, onChange, }) {
53
+ const areaUnit = (field.areaUnit || 'mm');
54
+ const aspectLocked = Boolean(field.aspectRatioLock && field.aspectRatio);
55
+ const aspectRatio = Number(field.aspectRatio) || 0;
56
+ const [modality, setModality] = useState(() => defaultModalityForAreaUnit(areaUnit));
57
+ const parsed = parseAreaValue(value);
58
+ const heightMm = parsed?.heightMm ?? 0;
59
+ const widthMm = parsed?.widthMm ?? 0;
60
+ const heightDisplay = heightMm ? mmToDisplay(heightMm, modality, areaUnit) : '';
61
+ const widthDisplay = widthMm ? mmToDisplay(widthMm, modality, areaUnit) : '';
62
+ const label = unitLabel(modality, areaUnit);
63
+ const summary = useMemo(() => formatAreaSummary(value, modality, areaUnit), [value, modality, areaUnit]);
64
+ const costDetail = useMemo(() => {
65
+ const estimated = estimateAreaCosts(field, value);
66
+ if (!estimated)
67
+ return '';
68
+ const currency = field.currency || 'AUD';
69
+ const parts = [];
70
+ if (estimated.onceOffCost > 0) {
71
+ parts.push(`${formatCurrency(estimated.onceOffCost, currency)} once off`);
72
+ }
73
+ if (estimated.unitCost > 0) {
74
+ parts.push(`${formatCurrency(estimated.unitCost, currency)} per unit`);
75
+ }
76
+ return parts.length ? `+ ${parts.join(', ')}` : '';
77
+ }, [field, value]);
78
+ const commitMm = (nextHeightMm, nextWidthMm, changed) => {
79
+ let h = nextHeightMm;
80
+ let w = nextWidthMm;
81
+ if (aspectLocked && aspectRatio > 0) {
82
+ const clamped = clampWithAspectRatio({
83
+ heightMm: h,
84
+ widthMm: w,
85
+ changed,
86
+ aspectRatio,
87
+ heightMin: field.heightFieldMin ?? null,
88
+ heightMax: field.heightFieldMax ?? null,
89
+ widthMin: field.widthFieldMin ?? null,
90
+ widthMax: field.widthFieldMax ?? null,
91
+ });
92
+ h = clamped.heightMm;
93
+ w = clamped.widthMm;
94
+ }
95
+ if (!Number.isFinite(h) || !Number.isFinite(w) || h <= 0 || w <= 0) {
96
+ onChange('');
97
+ return;
98
+ }
99
+ onChange(formatAreaValue(h, w));
100
+ };
101
+ const updateHeight = (raw) => {
102
+ if (raw === '') {
103
+ onChange('');
104
+ return;
105
+ }
106
+ const n = Number(raw);
107
+ if (!Number.isFinite(n) || n <= 0)
108
+ return;
109
+ const mm = displayToMm(n, modality, areaUnit);
110
+ commitMm(mm, widthMm > 0 ? widthMm : mm * (aspectRatio || 1), 'height');
111
+ };
112
+ const updateWidth = (raw) => {
113
+ if (raw === '') {
114
+ onChange('');
115
+ return;
116
+ }
117
+ const n = Number(raw);
118
+ if (!Number.isFinite(n) || n <= 0)
119
+ return;
120
+ const mm = displayToMm(n, modality, areaUnit);
121
+ commitMm(heightMm > 0 ? heightMm : mm / (aspectRatio || 1), mm, 'width');
122
+ };
123
+ const step = stepInDisplayUnit(field.areaStep, modality, areaUnit);
124
+ return (_jsxs("div", { children: [_jsxs("div", { style: {
125
+ display: 'flex',
126
+ alignItems: 'center',
127
+ gap: 8,
128
+ marginBottom: 8,
129
+ flexWrap: 'wrap',
130
+ }, children: [_jsx("span", { style: {
131
+ fontFamily: theme.font,
132
+ fontSize: 13,
133
+ fontWeight: modality === 'metric' ? 600 : 400,
134
+ color: modality === 'metric' ? theme.text : theme.muted,
135
+ }, children: "Metric" }), _jsx("button", { type: "button", role: "switch", "aria-checked": modality === 'imperial', "aria-label": "Use imperial units", onClick: () => setModality(modality === 'metric' ? 'imperial' : 'metric'), style: {
136
+ position: 'relative',
137
+ width: 36,
138
+ height: 20,
139
+ padding: 0,
140
+ border: 'none',
141
+ borderRadius: 999,
142
+ background: modality === 'imperial' ? theme.primary || '#0d6efd' : '#ced4da',
143
+ cursor: 'pointer',
144
+ flexShrink: 0,
145
+ }, children: _jsx("span", { style: {
146
+ position: 'absolute',
147
+ top: 2,
148
+ left: modality === 'imperial' ? 18 : 2,
149
+ width: 16,
150
+ height: 16,
151
+ borderRadius: '50%',
152
+ background: '#fff',
153
+ boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
154
+ transition: 'left 0.15s ease',
155
+ } }) }), _jsx("span", { style: {
156
+ fontFamily: theme.font,
157
+ fontSize: 13,
158
+ fontWeight: modality === 'imperial' ? 600 : 400,
159
+ color: modality === 'imperial' ? theme.text : theme.muted,
160
+ }, children: "Imperial" }), aspectLocked ? (_jsx("span", { style: {
161
+ marginLeft: 'auto',
162
+ fontFamily: theme.font,
163
+ fontSize: 12,
164
+ color: theme.muted,
165
+ }, children: "Aspect ratio locked" })) : null] }), _jsxs("div", { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }, children: [_jsxs("label", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: [_jsxs("span", { style: { fontFamily: theme.font, fontSize: 13, color: theme.muted }, children: ["Height (", label, ")"] }), _jsx("input", { type: "number", value: heightDisplay === '' ? '' : heightDisplay, onChange: (e) => updateHeight(e.target.value), step: step, style: inputStyle(), "aria-label": `${field.name || 'Area'} height` })] }), _jsxs("label", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: [_jsxs("span", { style: { fontFamily: theme.font, fontSize: 13, color: theme.muted }, children: ["Width (", label, ")"] }), _jsx("input", { type: "number", value: widthDisplay === '' ? '' : widthDisplay, onChange: (e) => updateWidth(e.target.value), step: step, style: inputStyle(), "aria-label": `${field.name || 'Area'} width` })] })] }), summary ? (_jsxs("div", { style: { marginTop: 8 }, children: [_jsx("p", { style: {
166
+ fontFamily: theme.font,
167
+ fontSize: 13,
168
+ color: theme.muted,
169
+ margin: 0,
170
+ }, children: summary }), costDetail ? (_jsx("p", { style: {
171
+ fontFamily: theme.font,
172
+ fontSize: 12,
173
+ color: theme.muted,
174
+ margin: '4px 0 0',
175
+ }, children: costDetail })) : null] })) : null] }));
176
+ }
177
+ export function Field({ field, groupIndex, }) {
178
+ const { selections, setFieldSelection, groups, setGroupFieldSelection, } = useProductForm();
179
+ if (!field || field.id === undefined)
180
+ return null;
181
+ const fieldId = field.id;
182
+ const isGroupField = groupIndex !== undefined;
183
+ const current = isGroupField
184
+ ? groups[groupIndex]?.selections[fieldId]
185
+ : selections[fieldId];
186
+ const fieldType = Number(field.fieldType);
187
+ const selectedIds = current?.selectedOptionIds ?? [];
188
+ const applySelection = (sel) => {
189
+ if (isGroupField) {
190
+ setGroupFieldSelection(groupIndex, fieldId, sel);
191
+ }
192
+ else {
193
+ setFieldSelection(fieldId, sel);
194
+ }
195
+ };
196
+ const selectSingle = (optionId) => applySelection({
197
+ value: String(optionId),
198
+ selectedOptionIds: [optionId],
199
+ });
200
+ const toggleMulti = (optionId) => {
201
+ const next = selectedIds.includes(optionId)
202
+ ? selectedIds.filter((id) => id !== optionId)
203
+ : [...selectedIds, optionId];
204
+ applySelection({
205
+ value: next.join(','),
206
+ selectedOptionIds: next,
207
+ });
208
+ };
209
+ const setText = (value) => applySelection({ value, selectedOptionIds: [] });
210
+ // ── Image select ────────────────────────────────────────────────
211
+ if (fieldType === FIELD.IMAGE_SELECT) {
212
+ const opts = visibleOptions(field);
213
+ return (_jsxs("div", { children: [_jsx(Label, { field: field }), _jsx("div", { style: {
214
+ display: 'grid',
215
+ gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
216
+ gap: 12,
217
+ }, children: opts.map((o) => {
218
+ const checked = o.id !== undefined && selectedIds.includes(o.id);
219
+ const available = o.available !== false;
220
+ return (_jsxs("button", { type: "button", disabled: !available, onClick: () => o.id !== undefined && selectSingle(o.id), style: {
221
+ position: 'relative',
222
+ textAlign: 'center',
223
+ padding: 12,
224
+ background: theme.card,
225
+ border: `2px solid ${checked ? theme.primary : theme.border}`,
226
+ borderRadius: theme.radius,
227
+ cursor: available ? 'pointer' : 'not-allowed',
228
+ opacity: available ? 1 : 0.45,
229
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
230
+ }, children: [o.linkedFile?.viewUrl ? (_jsx("img", { src: o.linkedFile.viewUrl, alt: o.value || '', style: {
231
+ display: 'block',
232
+ width: '100%',
233
+ height: 130,
234
+ objectFit: 'contain',
235
+ margin: '0 auto 8px',
236
+ } })) : null, _jsx("span", { style: {
237
+ display: 'block',
238
+ fontFamily: theme.font,
239
+ fontSize: 13,
240
+ fontWeight: 500,
241
+ color: theme.text,
242
+ }, children: o.value }), checked ? (_jsx("span", { style: {
243
+ position: 'absolute',
244
+ top: 8,
245
+ right: 8,
246
+ width: 20,
247
+ height: 20,
248
+ borderRadius: '50%',
249
+ background: theme.primary,
250
+ color: theme.primaryText,
251
+ fontSize: 13,
252
+ lineHeight: '20px',
253
+ }, children: "\u2713" })) : null] }, o.id));
254
+ }) })] }));
255
+ }
256
+ // ── Colour select ───────────────────────────────────────────────
257
+ if (fieldType === FIELD.COLOUR_SELECT) {
258
+ const opts = visibleOptions(field);
259
+ return (_jsxs("div", { children: [_jsx(Label, { field: field }), _jsx("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 12 }, children: opts.map((o) => {
260
+ const checked = o.id !== undefined && selectedIds.includes(o.id);
261
+ return (_jsxs("button", { type: "button", title: o.value, onClick: () => o.id !== undefined && selectSingle(o.id), style: {
262
+ display: 'flex',
263
+ flexDirection: 'column',
264
+ alignItems: 'center',
265
+ gap: 4,
266
+ background: 'transparent',
267
+ border: 'none',
268
+ cursor: 'pointer',
269
+ }, children: [_jsx("span", { style: {
270
+ width: 36,
271
+ height: 36,
272
+ borderRadius: '50%',
273
+ background: o.colour || '#ccc',
274
+ boxShadow: checked
275
+ ? `0 0 0 2px ${theme.card}, 0 0 0 4px ${theme.primary}`
276
+ : `0 0 0 1px ${theme.border}`,
277
+ } }), _jsx("span", { style: { fontFamily: theme.font, fontSize: 11, color: theme.muted }, children: o.value })] }, o.id));
278
+ }) })] }));
279
+ }
280
+ // ── Select dropdown ─────────────────────────────────────────────
281
+ if (fieldType === FIELD.SELECT) {
282
+ const opts = visibleOptions(field);
283
+ return (_jsxs("div", { children: [_jsx(Label, { field: field }), _jsxs("select", { value: selectedIds[0] !== undefined ? String(selectedIds[0]) : '', onChange: (e) => selectSingle(Number(e.target.value)), style: inputStyle(), children: [_jsx("option", { value: "", disabled: true, children: "Select\u2026" }), opts.map((o) => (_jsx("option", { value: o.id, children: o.value }, o.id)))] })] }));
284
+ }
285
+ // ── Radio ───────────────────────────────────────────────────────
286
+ if (fieldType === FIELD.RADIO) {
287
+ const opts = visibleOptions(field);
288
+ return (_jsxs("div", { children: [_jsx(Label, { field: field }), _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 8 }, children: opts.map((o) => (_jsxs("label", { style: {
289
+ display: 'flex',
290
+ alignItems: 'center',
291
+ gap: 8,
292
+ fontFamily: theme.font,
293
+ fontSize: 14,
294
+ color: theme.text,
295
+ cursor: 'pointer',
296
+ }, children: [_jsx("input", { type: "radio", name: `field-${fieldId}`, checked: o.id !== undefined && selectedIds.includes(o.id), onChange: () => o.id !== undefined && selectSingle(o.id) }), o.value] }, o.id))) })] }));
297
+ }
298
+ // ── Checkbox (multi) ────────────────────────────────────────────
299
+ if (fieldType === FIELD.CHECKBOX) {
300
+ const opts = visibleOptions(field);
301
+ return (_jsxs("div", { children: [_jsx(Label, { field: field }), _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 8 }, children: opts.map((o) => (_jsxs("label", { style: {
302
+ display: 'flex',
303
+ alignItems: 'center',
304
+ gap: 8,
305
+ fontFamily: theme.font,
306
+ fontSize: 14,
307
+ color: theme.text,
308
+ cursor: 'pointer',
309
+ }, children: [_jsx("input", { type: "checkbox", checked: o.id !== undefined && selectedIds.includes(o.id), onChange: () => o.id !== undefined && toggleMulti(o.id) }), o.value] }, o.id))) })] }));
310
+ }
311
+ // ── Text / number / textarea / colour picker ────────────────────
312
+ if (fieldType === FIELD.TEXT_AREA) {
313
+ return (_jsxs("div", { children: [_jsx(Label, { field: field }), _jsx("textarea", { value: current?.value ?? '', onChange: (e) => setText(e.target.value), rows: 3, style: { ...inputStyle(), resize: 'vertical' } })] }));
314
+ }
315
+ if (fieldType === FIELD.NUMBER_INPUT) {
316
+ return (_jsxs("div", { children: [_jsx(Label, { field: field }), _jsx("input", { type: "number", value: current?.value ?? '', onChange: (e) => setText(e.target.value), style: inputStyle() })] }));
317
+ }
318
+ if (fieldType === FIELD.AREA) {
319
+ return (_jsxs("div", { children: [_jsx(Label, { field: field }), _jsx(AreaFieldControl, { field: field, value: current?.value ?? '', onChange: setText })] }));
320
+ }
321
+ if (fieldType === FIELD.COLOUR_PICKER) {
322
+ return (_jsxs("div", { children: [_jsx(Label, { field: field }), _jsx("input", { type: "color", value: current?.value || '#000000', onChange: (e) => setText(e.target.value), style: { width: 48, height: 36, border: 'none', background: 'none' } })] }));
323
+ }
324
+ if (fieldType === FIELD.FIELD_INSTRUCTIONS) {
325
+ return (_jsx("div", { children: _jsx(Label, { field: field }) }));
326
+ }
327
+ // TEXT_INPUT (default) + unsupported-in-preview types fall back to a text box.
328
+ if (fieldType === FIELD.FILE_UPLOAD ||
329
+ fieldType === FIELD.TURNAROUND_TIME ||
330
+ fieldType === FIELD.COLOUR_EXTRACT) {
331
+ const message = fieldType === FIELD.FILE_UPLOAD
332
+ ? 'File upload is available on the live store.'
333
+ : fieldType === FIELD.COLOUR_EXTRACT
334
+ ? 'Colour extract (upload artwork and edit extracted colours) is available on the live store.'
335
+ : 'Turnaround options are available on the live store.';
336
+ const selectedColours = (current?.selectedOptions || []).filter((option) => option.colour || option.value);
337
+ return (_jsxs("div", { children: [_jsx(Label, { field: field }), _jsx("p", { style: { fontFamily: theme.font, fontSize: 13, color: theme.muted, margin: 0 }, children: message }), fieldType === FIELD.COLOUR_EXTRACT && selectedColours.length > 0 ? (_jsx("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 10 }, children: selectedColours.map((option, index) => (_jsxs("span", { style: {
338
+ display: 'inline-flex',
339
+ alignItems: 'center',
340
+ gap: 6,
341
+ fontFamily: theme.font,
342
+ fontSize: 12,
343
+ color: theme.text,
344
+ }, children: [_jsx("span", { style: {
345
+ width: 18,
346
+ height: 18,
347
+ borderRadius: 4,
348
+ background: option.colour || option.value || '#ccc',
349
+ border: `1px solid ${theme.border}`,
350
+ } }), (option.colour || option.value || '').toUpperCase()] }, option.id ?? index))) })) : null] }));
351
+ }
352
+ return (_jsxs("div", { children: [_jsx(Label, { field: field }), _jsx("input", { type: "text", value: current?.value ?? '', onChange: (e) => setText(e.target.value), style: inputStyle() })] }));
353
+ }
@@ -0,0 +1,11 @@
1
+ import { FooterAction } from './footerActions';
2
+ /**
3
+ * Configure the host ProductFormShell footer from form source.
4
+ * Render as the first child of the default-exported form.
5
+ *
6
+ * Example — one "Place order" button that opens the Get quote modal:
7
+ * <ProductFormActions actions={[{ action: 'getQuote', label: 'Place order', primary: true }]} />
8
+ */
9
+ export declare function ProductFormActions({ actions }: {
10
+ actions: FooterAction[];
11
+ }): null;
@@ -0,0 +1,20 @@
1
+ import * as React from 'react';
2
+ import { useProductForm } from './context';
3
+ import { normalizeFooterActions } from './footerActions';
4
+ /**
5
+ * Configure the host ProductFormShell footer from form source.
6
+ * Render as the first child of the default-exported form.
7
+ *
8
+ * Example — one "Place order" button that opens the Get quote modal:
9
+ * <ProductFormActions actions={[{ action: 'getQuote', label: 'Place order', primary: true }]} />
10
+ */
11
+ export function ProductFormActions({ actions }) {
12
+ const { setFooterActions } = useProductForm();
13
+ const key = JSON.stringify(actions);
14
+ const normalized = React.useMemo(() => normalizeFooterActions(actions), [key]);
15
+ React.useLayoutEffect(() => {
16
+ setFooterActions(normalized);
17
+ return () => setFooterActions(null);
18
+ }, [normalized, setFooterActions]);
19
+ return null;
20
+ }
@@ -0,0 +1,85 @@
1
+ import * as React from 'react';
2
+ import { ProductJson } from '../types/product';
3
+ import { JobJson } from '../types/job';
4
+ import { VariationFieldJson } from '../types/variation';
5
+ import { Pricing } from '../runtime/pricing';
6
+ import { Actions, Helpers } from '../runtime/runtime';
7
+ import { GroupRow } from '../helpers/buildJob';
8
+ import { OPTION_FIELD_TYPES } from '../helpers/constants';
9
+ import { FooterAction, type SubmitAction } from './footerActions';
10
+ export { OPTION_FIELD_TYPES };
11
+ export type { FooterAction, SubmitAction } from './footerActions';
12
+ export interface FieldSelection {
13
+ /** Comma-separated option ids (selectable fields) or the raw text/number. */
14
+ value: string;
15
+ selectedOptionIds: number[];
16
+ /** Uploaded files for file / colour-extract fields. */
17
+ variationFiles?: Array<{
18
+ id?: string;
19
+ name?: string;
20
+ viewUrl?: string;
21
+ }>;
22
+ /** Selected option rows (used by colour-extract for colour/value edits). */
23
+ selectedOptions?: Array<{
24
+ id?: number;
25
+ value?: string;
26
+ colour?: string;
27
+ isExtracted?: boolean;
28
+ }>;
29
+ }
30
+ export interface ProductFormContextValue {
31
+ product: ProductJson;
32
+ hasGroups: boolean;
33
+ /** True when one group field has options — prefer OptionQuantityGrid. */
34
+ optionQuantityGrid: boolean;
35
+ groupFields: VariationFieldJson[];
36
+ independentFields: VariationFieldJson[];
37
+ quantity: number;
38
+ setQuantity: (n: number) => void;
39
+ /** Selections for independent (whole-order) variation fields. */
40
+ selections: Record<number, FieldSelection>;
41
+ setFieldSelection: (fieldId: number, sel: FieldSelection) => void;
42
+ /** Per-batch group rows (multi group-field products). */
43
+ groups: GroupRow[];
44
+ setGroupQuantity: (index: number, quantity: number) => void;
45
+ setGroupFieldSelection: (groupIndex: number, fieldId: number, sel: FieldSelection) => void;
46
+ addGroup: () => void;
47
+ removeGroup: (index: number) => void;
48
+ /** Per-option quantities for single group-field grid products. */
49
+ optionQuantities: Record<number, number>;
50
+ setOptionQuantity: (optionId: number, quantity: number) => void;
51
+ quote: JobJson | null;
52
+ loading: boolean;
53
+ currency?: string;
54
+ helpers: Helpers;
55
+ /** Which submit actions the host enabled. */
56
+ available: {
57
+ addToCart: boolean;
58
+ buyNow: boolean;
59
+ getQuote: boolean;
60
+ };
61
+ /** Optional overrides for footer button labels. */
62
+ actionLabels: Partial<Record<SubmitAction, string>>;
63
+ /** Form-configured footer buttons; null uses the default Get quote / Buy now / Add to cart set. */
64
+ footerActions: FooterAction[] | null;
65
+ /** Replace or clear the host footer buttons from form source. */
66
+ setFooterActions: (actions: FooterAction[] | null) => void;
67
+ /** Build the current job and invoke the given host action. */
68
+ submit: (action: SubmitAction) => void;
69
+ }
70
+ export declare function useProductForm(): ProductFormContextValue;
71
+ export interface ProductFormProviderProps {
72
+ product: ProductJson;
73
+ pricing: Pricing;
74
+ actions: Actions;
75
+ helpers: Helpers;
76
+ children: React.ReactNode;
77
+ /** Optional cart-item / quote job used to hydrate quantity and selections. */
78
+ initialJob?: JobJson | null;
79
+ /** Override footer button labels (e.g. cart edit: addToCart → "Save"). */
80
+ actionLabels?: Partial<Record<SubmitAction, string>>;
81
+ /** Restrict / relabel host footer buttons. Host labels still win when set. */
82
+ footerActions?: FooterAction[] | null;
83
+ }
84
+ export declare function ProductFormProvider({ product, pricing, actions, helpers, children, initialJob, actionLabels, footerActions: footerActionsProp, }: ProductFormProviderProps): React.JSX.Element;
85
+ export type { GroupRow };