@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.
- package/README.md +177 -0
- package/dist/components/Field.d.ts +7 -0
- package/dist/components/Field.js +353 -0
- package/dist/components/ProductFormActions.d.ts +11 -0
- package/dist/components/ProductFormActions.js +20 -0
- package/dist/components/context.d.ts +85 -0
- package/dist/components/context.js +198 -0
- package/dist/components/footerActions.d.ts +18 -0
- package/dist/components/footerActions.js +67 -0
- package/dist/components/groups.d.ts +25 -0
- package/dist/components/groups.js +204 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.js +7 -0
- package/dist/components/layout.d.ts +43 -0
- package/dist/components/layout.js +69 -0
- package/dist/components/shell.d.ts +15 -0
- package/dist/components/shell.js +100 -0
- package/dist/helpers/area.d.ts +90 -0
- package/dist/helpers/area.js +287 -0
- package/dist/helpers/buildJob.d.ts +29 -0
- package/dist/helpers/buildJob.js +190 -0
- package/dist/helpers/constants.d.ts +8 -0
- package/dist/helpers/constants.js +8 -0
- package/dist/helpers/format.d.ts +4 -0
- package/dist/helpers/format.js +18 -0
- package/dist/helpers/gridQuote.d.ts +10 -0
- package/dist/helpers/gridQuote.js +32 -0
- package/dist/helpers/product.d.ts +7 -0
- package/dist/helpers/product.js +27 -0
- package/dist/helpers/serialize.d.ts +9 -0
- package/dist/helpers/serialize.js +35 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +11 -0
- package/dist/runtime/clientPricing.d.ts +31 -0
- package/dist/runtime/clientPricing.js +70 -0
- package/dist/runtime/pricing.d.ts +29 -0
- package/dist/runtime/pricing.js +70 -0
- package/dist/runtime/runtime.d.ts +36 -0
- package/dist/runtime/runtime.js +32 -0
- package/dist/types/form.d.ts +26 -0
- package/dist/types/form.js +1 -0
- package/dist/types/job.d.ts +26 -0
- package/dist/types/job.js +1 -0
- package/dist/types/product.d.ts +30 -0
- package/dist/types/product.js +1 -0
- package/dist/types/variation.d.ts +46 -0
- package/dist/types/variation.js +1 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +6 -0
- package/package.json +49 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
+
import { buildProductFormJob, jobToFormState, } from '../helpers/buildJob';
|
|
4
|
+
import { OPTION_FIELD_TYPES } from '../helpers/constants';
|
|
5
|
+
import { groupFieldsOf, independentFieldsOf, isOptionQuantityGridProduct, productHasGroups, } from '../helpers/product';
|
|
6
|
+
import { normalizeFooterActions, } from './footerActions';
|
|
7
|
+
export { OPTION_FIELD_TYPES };
|
|
8
|
+
const Ctx = createContext(null);
|
|
9
|
+
export function useProductForm() {
|
|
10
|
+
const ctx = useContext(Ctx);
|
|
11
|
+
if (!ctx) {
|
|
12
|
+
throw new Error('Merchi form components must be rendered inside <ProductFormProvider>.');
|
|
13
|
+
}
|
|
14
|
+
return ctx;
|
|
15
|
+
}
|
|
16
|
+
function initialSelectionsForFields(fields) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const f of fields) {
|
|
19
|
+
if (f?.id === undefined)
|
|
20
|
+
continue;
|
|
21
|
+
if (OPTION_FIELD_TYPES.has(Number(f.fieldType)) && f.fieldType !== 6) {
|
|
22
|
+
const opt = (f.options || []).find((o) => o?.isVisible !== false && o?.available !== false);
|
|
23
|
+
if (opt?.id !== undefined) {
|
|
24
|
+
out[f.id] = { value: String(opt.id), selectedOptionIds: [opt.id] };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function initialGroupRow(product) {
|
|
31
|
+
const minQty = Number(product.minOrderQuantity ?? product.minimum) || 1;
|
|
32
|
+
return {
|
|
33
|
+
quantity: minQty,
|
|
34
|
+
selections: initialSelectionsForFields(groupFieldsOf(product)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function ProductFormProvider({ product, pricing, actions, helpers, children, initialJob = null, actionLabels, footerActions: footerActionsProp = null, }) {
|
|
38
|
+
const hasGroups = productHasGroups(product);
|
|
39
|
+
const optionQuantityGrid = hasGroups && isOptionQuantityGridProduct(product);
|
|
40
|
+
const groupFields = groupFieldsOf(product);
|
|
41
|
+
const independentFields = independentFieldsOf(product);
|
|
42
|
+
const minQty = Number(product.minOrderQuantity ?? product.minimum) || 1;
|
|
43
|
+
const hydrated = jobToFormState(product, initialJob);
|
|
44
|
+
const [quantity, setQuantity] = useState(() => (initialJob ? hydrated.quantity : minQty));
|
|
45
|
+
const [selections, setSelections] = useState(() => initialJob
|
|
46
|
+
? hydrated.selections
|
|
47
|
+
: initialSelectionsForFields(independentFields));
|
|
48
|
+
const [groups, setGroups] = useState(() => initialJob
|
|
49
|
+
? hydrated.groups
|
|
50
|
+
: hasGroups && !optionQuantityGrid
|
|
51
|
+
? [initialGroupRow(product)]
|
|
52
|
+
: []);
|
|
53
|
+
const [optionQuantities, setOptionQuantities] = useState(() => {
|
|
54
|
+
if (initialJob)
|
|
55
|
+
return hydrated.optionQuantities;
|
|
56
|
+
if (!optionQuantityGrid)
|
|
57
|
+
return {};
|
|
58
|
+
const field = groupFields[0];
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const opt of field?.options || []) {
|
|
61
|
+
if (opt?.id !== undefined)
|
|
62
|
+
out[opt.id] = 0;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
});
|
|
66
|
+
const [quote, setQuote] = useState(null);
|
|
67
|
+
const [loading, setLoading] = useState(false);
|
|
68
|
+
const [footerActionsOverride, setFooterActionsState] = useState(null);
|
|
69
|
+
const debounceRef = useRef(null);
|
|
70
|
+
const reqIdRef = useRef(0);
|
|
71
|
+
const setFieldSelection = useCallback((fieldId, sel) => {
|
|
72
|
+
setSelections((prev) => ({ ...prev, [fieldId]: sel }));
|
|
73
|
+
}, []);
|
|
74
|
+
const setGroupQuantity = useCallback((index, qty) => {
|
|
75
|
+
setGroups((prev) => prev.map((row, i) => (i === index ? { ...row, quantity: qty } : row)));
|
|
76
|
+
}, []);
|
|
77
|
+
const setGroupFieldSelection = useCallback((groupIndex, fieldId, sel) => {
|
|
78
|
+
setGroups((prev) => prev.map((row, i) => i === groupIndex
|
|
79
|
+
? { ...row, selections: { ...row.selections, [fieldId]: sel } }
|
|
80
|
+
: row));
|
|
81
|
+
}, []);
|
|
82
|
+
const addGroup = useCallback(() => {
|
|
83
|
+
setGroups((prev) => [...prev, initialGroupRow(product)]);
|
|
84
|
+
}, [product]);
|
|
85
|
+
const removeGroup = useCallback((index) => {
|
|
86
|
+
setGroups((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)));
|
|
87
|
+
}, []);
|
|
88
|
+
const setOptionQuantity = useCallback((optionId, qty) => {
|
|
89
|
+
setOptionQuantities((prev) => ({ ...prev, [optionId]: Math.max(0, qty) }));
|
|
90
|
+
}, []);
|
|
91
|
+
const buildJob = useCallback(() => buildProductFormJob({
|
|
92
|
+
product,
|
|
93
|
+
quantity,
|
|
94
|
+
selections,
|
|
95
|
+
groups: hasGroups && !optionQuantityGrid ? groups : undefined,
|
|
96
|
+
optionQuantities: optionQuantityGrid ? optionQuantities : undefined,
|
|
97
|
+
}), [
|
|
98
|
+
product,
|
|
99
|
+
quantity,
|
|
100
|
+
selections,
|
|
101
|
+
groups,
|
|
102
|
+
optionQuantities,
|
|
103
|
+
hasGroups,
|
|
104
|
+
optionQuantityGrid,
|
|
105
|
+
]);
|
|
106
|
+
const pricingRef = useRef(pricing);
|
|
107
|
+
pricingRef.current = pricing;
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (debounceRef.current)
|
|
110
|
+
clearTimeout(debounceRef.current);
|
|
111
|
+
debounceRef.current = setTimeout(async () => {
|
|
112
|
+
const reqId = ++reqIdRef.current;
|
|
113
|
+
setLoading(true);
|
|
114
|
+
try {
|
|
115
|
+
const priced = await pricingRef.current.getQuote(buildJob());
|
|
116
|
+
if (reqId === reqIdRef.current)
|
|
117
|
+
setQuote(priced);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* keep the previous quote on failure */
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
if (reqId === reqIdRef.current)
|
|
124
|
+
setLoading(false);
|
|
125
|
+
}
|
|
126
|
+
}, 300);
|
|
127
|
+
return () => {
|
|
128
|
+
if (debounceRef.current)
|
|
129
|
+
clearTimeout(debounceRef.current);
|
|
130
|
+
};
|
|
131
|
+
}, [buildJob]);
|
|
132
|
+
const submit = useCallback((action) => {
|
|
133
|
+
const fn = actions[action];
|
|
134
|
+
if (fn)
|
|
135
|
+
fn(buildJob());
|
|
136
|
+
}, [actions, buildJob]);
|
|
137
|
+
const setFooterActions = useCallback((next) => {
|
|
138
|
+
setFooterActionsState(normalizeFooterActions(next));
|
|
139
|
+
}, []);
|
|
140
|
+
const footerActions = footerActionsOverride ?? normalizeFooterActions(footerActionsProp);
|
|
141
|
+
const value = useMemo(() => ({
|
|
142
|
+
product,
|
|
143
|
+
hasGroups,
|
|
144
|
+
optionQuantityGrid,
|
|
145
|
+
groupFields,
|
|
146
|
+
independentFields,
|
|
147
|
+
quantity,
|
|
148
|
+
setQuantity,
|
|
149
|
+
selections,
|
|
150
|
+
setFieldSelection,
|
|
151
|
+
groups,
|
|
152
|
+
setGroupQuantity,
|
|
153
|
+
setGroupFieldSelection,
|
|
154
|
+
addGroup,
|
|
155
|
+
removeGroup,
|
|
156
|
+
optionQuantities,
|
|
157
|
+
setOptionQuantity,
|
|
158
|
+
quote,
|
|
159
|
+
loading,
|
|
160
|
+
currency: quote?.currency,
|
|
161
|
+
helpers,
|
|
162
|
+
available: {
|
|
163
|
+
addToCart: !!actions.addToCart,
|
|
164
|
+
buyNow: !!actions.buyNow,
|
|
165
|
+
getQuote: !!actions.getQuote,
|
|
166
|
+
},
|
|
167
|
+
actionLabels: actionLabels || {},
|
|
168
|
+
footerActions,
|
|
169
|
+
setFooterActions,
|
|
170
|
+
submit,
|
|
171
|
+
}), [
|
|
172
|
+
product,
|
|
173
|
+
hasGroups,
|
|
174
|
+
optionQuantityGrid,
|
|
175
|
+
groupFields,
|
|
176
|
+
independentFields,
|
|
177
|
+
quantity,
|
|
178
|
+
selections,
|
|
179
|
+
groups,
|
|
180
|
+
optionQuantities,
|
|
181
|
+
quote,
|
|
182
|
+
loading,
|
|
183
|
+
helpers,
|
|
184
|
+
actions,
|
|
185
|
+
actionLabels,
|
|
186
|
+
footerActions,
|
|
187
|
+
setFooterActions,
|
|
188
|
+
submit,
|
|
189
|
+
setQuantity,
|
|
190
|
+
setFieldSelection,
|
|
191
|
+
setGroupQuantity,
|
|
192
|
+
setGroupFieldSelection,
|
|
193
|
+
addGroup,
|
|
194
|
+
removeGroup,
|
|
195
|
+
setOptionQuantity,
|
|
196
|
+
]);
|
|
197
|
+
return _jsx(Ctx.Provider, { value: value, children: children });
|
|
198
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type SubmitAction = 'addToCart' | 'buyNow' | 'getQuote';
|
|
2
|
+
/** One host-footer button. `getQuote` opens the Get quote modal. */
|
|
3
|
+
export interface FooterAction {
|
|
4
|
+
action: SubmitAction;
|
|
5
|
+
label?: string;
|
|
6
|
+
primary?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const DEFAULT_FOOTER_LABELS: Record<SubmitAction, string>;
|
|
9
|
+
export declare const DEFAULT_FOOTER_ORDER: SubmitAction[];
|
|
10
|
+
export declare function isSubmitAction(value: unknown): value is SubmitAction;
|
|
11
|
+
/** Keep only well-formed footer actions. Empty / invalid input → null (use defaults). */
|
|
12
|
+
export declare function normalizeFooterActions(raw: unknown): FooterAction[] | null;
|
|
13
|
+
export declare function footerActionsFromComponent(type: unknown): FooterAction[] | null;
|
|
14
|
+
export declare function resolveFooterActions(options: {
|
|
15
|
+
available: Record<SubmitAction, boolean>;
|
|
16
|
+
actionLabels?: Partial<Record<SubmitAction, string>>;
|
|
17
|
+
footerActions?: FooterAction[] | null;
|
|
18
|
+
}): FooterAction[];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const DEFAULT_FOOTER_LABELS = {
|
|
2
|
+
getQuote: 'Get quote',
|
|
3
|
+
buyNow: 'Buy now',
|
|
4
|
+
addToCart: 'Add to cart',
|
|
5
|
+
};
|
|
6
|
+
export const DEFAULT_FOOTER_ORDER = [
|
|
7
|
+
'getQuote',
|
|
8
|
+
'buyNow',
|
|
9
|
+
'addToCart',
|
|
10
|
+
];
|
|
11
|
+
export function isSubmitAction(value) {
|
|
12
|
+
return value === 'addToCart' || value === 'buyNow' || value === 'getQuote';
|
|
13
|
+
}
|
|
14
|
+
/** Keep only well-formed footer actions. Empty / invalid input → null (use defaults). */
|
|
15
|
+
export function normalizeFooterActions(raw) {
|
|
16
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
17
|
+
return null;
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const item of raw) {
|
|
20
|
+
if (!item || typeof item !== 'object')
|
|
21
|
+
continue;
|
|
22
|
+
const action = item.action;
|
|
23
|
+
if (!isSubmitAction(action))
|
|
24
|
+
continue;
|
|
25
|
+
const label = item.label;
|
|
26
|
+
out.push({
|
|
27
|
+
action,
|
|
28
|
+
label: typeof label === 'string' && label.trim() ? label : undefined,
|
|
29
|
+
primary: !!item.primary,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return out.length ? out : null;
|
|
33
|
+
}
|
|
34
|
+
export function footerActionsFromComponent(type) {
|
|
35
|
+
if (type == null || (typeof type !== 'function' && typeof type !== 'object')) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return normalizeFooterActions(type.footerActions);
|
|
39
|
+
}
|
|
40
|
+
export function resolveFooterActions(options) {
|
|
41
|
+
const { available, actionLabels = {}, footerActions } = options;
|
|
42
|
+
const labelFor = (action, override) => actionLabels[action] || override || DEFAULT_FOOTER_LABELS[action];
|
|
43
|
+
const fromConfig = normalizeFooterActions(footerActions);
|
|
44
|
+
if (fromConfig) {
|
|
45
|
+
const resolved = fromConfig
|
|
46
|
+
.filter((item) => available[item.action])
|
|
47
|
+
.map((item) => ({
|
|
48
|
+
...item,
|
|
49
|
+
label: labelFor(item.action, item.label),
|
|
50
|
+
}));
|
|
51
|
+
if (resolved.length) {
|
|
52
|
+
if (!resolved.some((item) => item.primary)) {
|
|
53
|
+
resolved[resolved.length - 1] = {
|
|
54
|
+
...resolved[resolved.length - 1],
|
|
55
|
+
primary: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return DEFAULT_FOOTER_ORDER.filter((action) => available[action]).map((action, index, list) => ({
|
|
62
|
+
action,
|
|
63
|
+
label: labelFor(action),
|
|
64
|
+
primary: action === 'addToCart' ||
|
|
65
|
+
(index === list.length - 1 && !available.addToCart),
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { VariationFieldJson } from '../types/variation';
|
|
3
|
+
/**
|
|
4
|
+
* Per-option quantity grid for products with a single group variation field
|
|
5
|
+
* (e.g. Size or Colour). Wired to useProductForm() for live quoting.
|
|
6
|
+
*/
|
|
7
|
+
export declare function OptionQuantityGrid({ field, title, showCost, showInventory, optionColumnLabel, }: {
|
|
8
|
+
/** Defaults to product.groupVariationFields[0]. */
|
|
9
|
+
field?: VariationFieldJson | null;
|
|
10
|
+
title?: string;
|
|
11
|
+
/** Show per-row cost from the live quote (default true). */
|
|
12
|
+
showCost?: boolean;
|
|
13
|
+
/** Show inventory status per row (defaults to product.needsInventory). */
|
|
14
|
+
showInventory?: boolean;
|
|
15
|
+
/** Header for the option/colour column (defaults to the field name). */
|
|
16
|
+
optionColumnLabel?: string;
|
|
17
|
+
}): React.JSX.Element | null;
|
|
18
|
+
/**
|
|
19
|
+
* Multi-batch group variation UI: each batch has its own quantity and group-field
|
|
20
|
+
* selections. Use for products with multiple groupVariationFields or free-text
|
|
21
|
+
* group fields.
|
|
22
|
+
*/
|
|
23
|
+
export declare function GroupRows({ addLabel }: {
|
|
24
|
+
addLabel?: string;
|
|
25
|
+
}): React.JSX.Element | null;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { useProductForm } from './context';
|
|
4
|
+
import { Field } from './Field';
|
|
5
|
+
import { Card, Heading, Stack, theme } from './layout';
|
|
6
|
+
import { inventoryStatusText, quoteGroupForOption } from '../helpers/gridQuote';
|
|
7
|
+
const COLOUR_SELECT = 11;
|
|
8
|
+
const IMAGE_SELECT = 9;
|
|
9
|
+
function qtyInputStyle(disabled) {
|
|
10
|
+
return {
|
|
11
|
+
width: 88,
|
|
12
|
+
padding: '8px 10px',
|
|
13
|
+
fontFamily: theme.font,
|
|
14
|
+
fontSize: 14,
|
|
15
|
+
color: theme.text,
|
|
16
|
+
background: theme.card,
|
|
17
|
+
border: `1px solid ${theme.border}`,
|
|
18
|
+
borderRadius: 8,
|
|
19
|
+
outline: 'none',
|
|
20
|
+
boxSizing: 'border-box',
|
|
21
|
+
opacity: disabled ? 0.55 : 1,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const headerCell = {
|
|
25
|
+
fontFamily: theme.font,
|
|
26
|
+
fontSize: 11,
|
|
27
|
+
fontWeight: 800,
|
|
28
|
+
textTransform: 'uppercase',
|
|
29
|
+
letterSpacing: '0.06em',
|
|
30
|
+
color: theme.muted,
|
|
31
|
+
padding: '0 0 8px',
|
|
32
|
+
};
|
|
33
|
+
const inventoryToneColor = {
|
|
34
|
+
ok: '#65cf85',
|
|
35
|
+
warn: '#ffc928',
|
|
36
|
+
error: theme.primary,
|
|
37
|
+
};
|
|
38
|
+
/** Quantity input that allows clearing the field while typing (empty → 0 on blur). */
|
|
39
|
+
function QuantityInput({ value, onChange, disabled, ariaLabel, placeholder = '0', style, }) {
|
|
40
|
+
const [draft, setDraft] = React.useState(null);
|
|
41
|
+
const displayValue = draft !== null ? draft : value === 0 ? '' : String(value);
|
|
42
|
+
return (_jsx("input", { type: "number", min: 0, disabled: disabled, placeholder: placeholder, value: displayValue, onChange: (e) => {
|
|
43
|
+
const raw = e.target.value;
|
|
44
|
+
if (raw === '') {
|
|
45
|
+
setDraft('');
|
|
46
|
+
onChange(0);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const parsed = Number(raw);
|
|
50
|
+
if (!Number.isNaN(parsed) && parsed >= 0) {
|
|
51
|
+
setDraft(null);
|
|
52
|
+
onChange(parsed);
|
|
53
|
+
}
|
|
54
|
+
}, onBlur: () => setDraft(null), style: style, "aria-label": ariaLabel }));
|
|
55
|
+
}
|
|
56
|
+
function OptionSwatch({ option, fieldType, }) {
|
|
57
|
+
if (fieldType === IMAGE_SELECT && option.linkedFile?.viewUrl) {
|
|
58
|
+
return (_jsx("img", { src: option.linkedFile.viewUrl, alt: "", style: {
|
|
59
|
+
width: 36,
|
|
60
|
+
height: 36,
|
|
61
|
+
objectFit: 'contain',
|
|
62
|
+
borderRadius: 6,
|
|
63
|
+
border: `1px solid ${theme.border}`,
|
|
64
|
+
flexShrink: 0,
|
|
65
|
+
} }));
|
|
66
|
+
}
|
|
67
|
+
if (option.colour) {
|
|
68
|
+
return (_jsx("span", { "aria-hidden": true, style: {
|
|
69
|
+
width: 28,
|
|
70
|
+
height: 28,
|
|
71
|
+
borderRadius: '50%',
|
|
72
|
+
background: option.colour,
|
|
73
|
+
border: `1px solid ${theme.border}`,
|
|
74
|
+
flexShrink: 0,
|
|
75
|
+
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.06)',
|
|
76
|
+
} }));
|
|
77
|
+
}
|
|
78
|
+
if (fieldType === COLOUR_SELECT) {
|
|
79
|
+
return (_jsx("span", { "aria-hidden": true, style: {
|
|
80
|
+
width: 28,
|
|
81
|
+
height: 28,
|
|
82
|
+
borderRadius: '50%',
|
|
83
|
+
background: theme.surface,
|
|
84
|
+
border: `1px solid ${theme.border}`,
|
|
85
|
+
flexShrink: 0,
|
|
86
|
+
} }));
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
function InventoryLine({ needsInventory, group, }) {
|
|
91
|
+
const status = inventoryStatusText(group, needsInventory);
|
|
92
|
+
if (!status)
|
|
93
|
+
return null;
|
|
94
|
+
return (_jsxs("div", { style: {
|
|
95
|
+
display: 'flex',
|
|
96
|
+
alignItems: 'center',
|
|
97
|
+
gap: 6,
|
|
98
|
+
marginTop: 4,
|
|
99
|
+
fontFamily: theme.font,
|
|
100
|
+
fontSize: 12,
|
|
101
|
+
color: theme.muted,
|
|
102
|
+
}, children: [_jsx("span", { "aria-hidden": true, style: {
|
|
103
|
+
width: 8,
|
|
104
|
+
height: 8,
|
|
105
|
+
borderRadius: '50%',
|
|
106
|
+
background: inventoryToneColor[status.tone],
|
|
107
|
+
flexShrink: 0,
|
|
108
|
+
} }), _jsxs("span", { children: ["Inventory: ", status.label] })] }));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Per-option quantity grid for products with a single group variation field
|
|
112
|
+
* (e.g. Size or Colour). Wired to useProductForm() for live quoting.
|
|
113
|
+
*/
|
|
114
|
+
export function OptionQuantityGrid({ field, title, showCost = true, showInventory, optionColumnLabel, }) {
|
|
115
|
+
const { product, groupFields, optionQuantities, setOptionQuantity, quote, loading, currency, helpers, } = useProductForm();
|
|
116
|
+
const groupField = field ?? groupFields[0];
|
|
117
|
+
if (!groupField?.options?.length || groupField.id === undefined)
|
|
118
|
+
return null;
|
|
119
|
+
const fieldType = Number(groupField.fieldType);
|
|
120
|
+
const fieldId = groupField.id;
|
|
121
|
+
const needsInventory = showInventory ?? Boolean(product?.needsInventory);
|
|
122
|
+
const optionLabel = optionColumnLabel ?? groupField.name ?? 'Option';
|
|
123
|
+
const options = groupField.options.filter((o) => o?.isVisible !== false && o?.id !== undefined);
|
|
124
|
+
const fmtCost = (amount) => {
|
|
125
|
+
if (typeof amount !== 'number')
|
|
126
|
+
return '—';
|
|
127
|
+
return helpers.formatCurrency(amount, currency);
|
|
128
|
+
};
|
|
129
|
+
return (_jsxs(Stack, { gap: 12, children: [title ? _jsx(Heading, { style: { fontSize: 15 }, children: title }) : null, _jsx("div", { style: { width: '100%', overflowX: 'auto' }, children: _jsxs("div", { style: {
|
|
130
|
+
display: 'grid',
|
|
131
|
+
gridTemplateColumns: showCost ? '1fr 96px 112px' : '1fr 96px',
|
|
132
|
+
gap: '12px 16px',
|
|
133
|
+
alignItems: 'center',
|
|
134
|
+
minWidth: 320,
|
|
135
|
+
}, children: [_jsx("div", { style: headerCell, children: optionLabel }), _jsx("div", { style: { ...headerCell, textAlign: 'center' }, children: "Quantity" }), showCost ? _jsx("div", { style: { ...headerCell, textAlign: 'right' }, children: "Cost" }) : null, options.map((opt) => {
|
|
136
|
+
const optionId = opt.id;
|
|
137
|
+
const quotedGroup = quoteGroupForOption(quote, fieldId, optionId);
|
|
138
|
+
const qty = optionQuantities[optionId] ?? 0;
|
|
139
|
+
const unavailable = opt.available === false;
|
|
140
|
+
const rowCost = quotedGroup?.groupCost;
|
|
141
|
+
const displayCost = typeof rowCost === 'number' ? rowCost : 0;
|
|
142
|
+
return (_jsxs(React.Fragment, { children: [_jsxs("div", { style: {
|
|
143
|
+
display: 'flex',
|
|
144
|
+
alignItems: 'flex-start',
|
|
145
|
+
gap: 12,
|
|
146
|
+
minWidth: 0,
|
|
147
|
+
}, children: [_jsx(OptionSwatch, { option: opt, fieldType: fieldType }), _jsxs("div", { style: { minWidth: 0 }, children: [_jsx("div", { style: {
|
|
148
|
+
fontFamily: theme.font,
|
|
149
|
+
fontSize: 14,
|
|
150
|
+
fontWeight: 600,
|
|
151
|
+
color: theme.text,
|
|
152
|
+
lineHeight: 1.35,
|
|
153
|
+
}, children: opt.value }), _jsx(InventoryLine, { needsInventory: needsInventory, group: quotedGroup })] })] }), _jsx("div", { style: { display: 'flex', justifyContent: 'center' }, children: _jsx(QuantityInput, { value: qty, disabled: unavailable, onChange: (next) => setOptionQuantity(optionId, next), style: qtyInputStyle(unavailable), ariaLabel: `Quantity for ${opt.value ?? 'option'}` }) }), showCost ? (_jsx("div", { style: {
|
|
154
|
+
fontFamily: theme.font,
|
|
155
|
+
fontSize: 14,
|
|
156
|
+
fontWeight: 600,
|
|
157
|
+
color: theme.text,
|
|
158
|
+
textAlign: 'right',
|
|
159
|
+
opacity: loading && qty > 0 ? 0.55 : 1,
|
|
160
|
+
}, children: fmtCost(displayCost) })) : null] }, optionId));
|
|
161
|
+
})] }) })] }));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Multi-batch group variation UI: each batch has its own quantity and group-field
|
|
165
|
+
* selections. Use for products with multiple groupVariationFields or free-text
|
|
166
|
+
* group fields.
|
|
167
|
+
*/
|
|
168
|
+
export function GroupRows({ addLabel = 'Add another batch' }) {
|
|
169
|
+
const { groupFields, groups, setGroupQuantity, addGroup, removeGroup, } = useProductForm();
|
|
170
|
+
if (!groupFields.length)
|
|
171
|
+
return null;
|
|
172
|
+
return (_jsxs(Stack, { gap: 20, children: [groups.map((row, index) => (_jsx(Card, { children: _jsxs(Stack, { gap: 16, children: [_jsxs("div", { style: {
|
|
173
|
+
display: 'flex',
|
|
174
|
+
alignItems: 'center',
|
|
175
|
+
justifyContent: 'space-between',
|
|
176
|
+
gap: 12,
|
|
177
|
+
}, children: [_jsxs(Heading, { style: { fontSize: 15 }, children: ["Batch ", index + 1] }), groups.length > 1 ? (_jsx("button", { type: "button", onClick: () => removeGroup(index), style: {
|
|
178
|
+
fontFamily: theme.font,
|
|
179
|
+
fontSize: 13,
|
|
180
|
+
color: theme.muted,
|
|
181
|
+
background: 'none',
|
|
182
|
+
border: 'none',
|
|
183
|
+
cursor: 'pointer',
|
|
184
|
+
textDecoration: 'underline',
|
|
185
|
+
}, children: "Remove" })) : null] }), _jsxs("div", { children: [_jsx("label", { style: {
|
|
186
|
+
display: 'block',
|
|
187
|
+
fontFamily: theme.font,
|
|
188
|
+
fontSize: 14,
|
|
189
|
+
fontWeight: 700,
|
|
190
|
+
color: theme.text,
|
|
191
|
+
marginBottom: 8,
|
|
192
|
+
}, children: "Quantity" }), _jsx(QuantityInput, { value: row.quantity, onChange: (next) => setGroupQuantity(index, next), style: qtyInputStyle(), ariaLabel: `Quantity for batch ${index + 1}` })] }), groupFields.map((gf) => (_jsx(Field, { field: gf, groupIndex: index }, gf.id)))] }) }, index))), _jsx("button", { type: "button", onClick: addGroup, style: {
|
|
193
|
+
alignSelf: 'flex-start',
|
|
194
|
+
padding: '8px 14px',
|
|
195
|
+
fontFamily: theme.font,
|
|
196
|
+
fontSize: 14,
|
|
197
|
+
fontWeight: 600,
|
|
198
|
+
color: theme.primary,
|
|
199
|
+
background: theme.card,
|
|
200
|
+
border: `1px solid ${theme.border}`,
|
|
201
|
+
borderRadius: 8,
|
|
202
|
+
cursor: 'pointer',
|
|
203
|
+
}, children: addLabel })] }));
|
|
204
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Section, Stack, Card, Heading, Text, Divider, theme } from './layout';
|
|
2
|
+
export { Field } from './Field';
|
|
3
|
+
export { GroupRows, OptionQuantityGrid } from './groups';
|
|
4
|
+
export { ProductFormProvider, useProductForm, OPTION_FIELD_TYPES, } from './context';
|
|
5
|
+
export type { ProductFormContextValue, ProductFormProviderProps, FieldSelection, SubmitAction, FooterAction, GroupRow, } from './context';
|
|
6
|
+
export { ProductFormShell } from './shell';
|
|
7
|
+
export { ProductFormActions } from './ProductFormActions';
|
|
8
|
+
export { normalizeFooterActions, resolveFooterActions, footerActionsFromComponent, } from './footerActions';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { Section, Stack, Card, Heading, Text, Divider, theme } from './layout';
|
|
2
|
+
export { Field } from './Field';
|
|
3
|
+
export { GroupRows, OptionQuantityGrid } from './groups';
|
|
4
|
+
export { ProductFormProvider, useProductForm, OPTION_FIELD_TYPES, } from './context';
|
|
5
|
+
export { ProductFormShell } from './shell';
|
|
6
|
+
export { ProductFormActions } from './ProductFormActions';
|
|
7
|
+
export { normalizeFooterActions, resolveFooterActions, footerActionsFromComponent, } from './footerActions';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Presentational building blocks for custom product forms. Self-styled with
|
|
4
|
+
* inline styles so they render consistently in any host (dashboard preview or
|
|
5
|
+
* the embed) without depending on an external stylesheet.
|
|
6
|
+
*/
|
|
7
|
+
export declare const theme: {
|
|
8
|
+
text: string;
|
|
9
|
+
muted: string;
|
|
10
|
+
border: string;
|
|
11
|
+
primary: string;
|
|
12
|
+
primaryText: string;
|
|
13
|
+
card: string;
|
|
14
|
+
surface: string;
|
|
15
|
+
radius: number;
|
|
16
|
+
font: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function Stack({ children, gap, style, }: {
|
|
19
|
+
children?: React.ReactNode;
|
|
20
|
+
gap?: number;
|
|
21
|
+
style?: React.CSSProperties;
|
|
22
|
+
}): React.JSX.Element;
|
|
23
|
+
export declare function Section({ title, children, style, }: {
|
|
24
|
+
title?: string;
|
|
25
|
+
children?: React.ReactNode;
|
|
26
|
+
style?: React.CSSProperties;
|
|
27
|
+
}): React.JSX.Element;
|
|
28
|
+
export declare function Card({ children, style, }: {
|
|
29
|
+
children?: React.ReactNode;
|
|
30
|
+
style?: React.CSSProperties;
|
|
31
|
+
}): React.JSX.Element;
|
|
32
|
+
export declare function Heading({ children, style, }: {
|
|
33
|
+
children?: React.ReactNode;
|
|
34
|
+
style?: React.CSSProperties;
|
|
35
|
+
}): React.JSX.Element;
|
|
36
|
+
export declare function Text({ children, muted, style, }: {
|
|
37
|
+
children?: React.ReactNode;
|
|
38
|
+
muted?: boolean;
|
|
39
|
+
style?: React.CSSProperties;
|
|
40
|
+
}): React.JSX.Element;
|
|
41
|
+
export declare function Divider({ style }: {
|
|
42
|
+
style?: React.CSSProperties;
|
|
43
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Presentational building blocks for custom product forms. Self-styled with
|
|
4
|
+
* inline styles so they render consistently in any host (dashboard preview or
|
|
5
|
+
* the embed) without depending on an external stylesheet.
|
|
6
|
+
*/
|
|
7
|
+
export const theme = {
|
|
8
|
+
text: '#32325d',
|
|
9
|
+
muted: '#8898aa',
|
|
10
|
+
border: '#e9ecef',
|
|
11
|
+
primary: '#ff4449',
|
|
12
|
+
primaryText: '#ffffff',
|
|
13
|
+
card: '#ffffff',
|
|
14
|
+
surface: '#f6f9fc',
|
|
15
|
+
radius: 12,
|
|
16
|
+
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
17
|
+
};
|
|
18
|
+
export function Stack({ children, gap = 12, style, }) {
|
|
19
|
+
return (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap, ...style }, children: children }));
|
|
20
|
+
}
|
|
21
|
+
export function Section({ title, children, style, }) {
|
|
22
|
+
return (_jsxs("section", { style: { display: 'flex', flexDirection: 'column', gap: 12, ...style }, children: [title ? (_jsx("h3", { style: {
|
|
23
|
+
margin: 0,
|
|
24
|
+
fontFamily: theme.font,
|
|
25
|
+
fontSize: 13,
|
|
26
|
+
fontWeight: 800,
|
|
27
|
+
textTransform: 'uppercase',
|
|
28
|
+
letterSpacing: '0.06em',
|
|
29
|
+
color: theme.muted,
|
|
30
|
+
}, children: title })) : null, children] }));
|
|
31
|
+
}
|
|
32
|
+
export function Card({ children, style, }) {
|
|
33
|
+
return (_jsx("div", { style: {
|
|
34
|
+
background: theme.card,
|
|
35
|
+
border: `1px solid ${theme.border}`,
|
|
36
|
+
borderRadius: theme.radius,
|
|
37
|
+
padding: 20,
|
|
38
|
+
boxShadow: '0 1px 2px rgba(50,50,93,0.06)',
|
|
39
|
+
...style,
|
|
40
|
+
}, children: children }));
|
|
41
|
+
}
|
|
42
|
+
export function Heading({ children, style, }) {
|
|
43
|
+
return (_jsx("h2", { style: {
|
|
44
|
+
margin: 0,
|
|
45
|
+
fontFamily: theme.font,
|
|
46
|
+
fontSize: 18,
|
|
47
|
+
fontWeight: 800,
|
|
48
|
+
color: theme.text,
|
|
49
|
+
...style,
|
|
50
|
+
}, children: children }));
|
|
51
|
+
}
|
|
52
|
+
export function Text({ children, muted, style, }) {
|
|
53
|
+
return (_jsx("p", { style: {
|
|
54
|
+
margin: 0,
|
|
55
|
+
fontFamily: theme.font,
|
|
56
|
+
fontSize: 14,
|
|
57
|
+
lineHeight: 1.5,
|
|
58
|
+
color: muted ? theme.muted : theme.text,
|
|
59
|
+
...style,
|
|
60
|
+
}, children: children }));
|
|
61
|
+
}
|
|
62
|
+
export function Divider({ style }) {
|
|
63
|
+
return (_jsx("hr", { style: {
|
|
64
|
+
border: 'none',
|
|
65
|
+
borderTop: `1px solid ${theme.border}`,
|
|
66
|
+
margin: '4px 0',
|
|
67
|
+
...style,
|
|
68
|
+
} }));
|
|
69
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ProductFormProviderProps } from './context';
|
|
3
|
+
/**
|
|
4
|
+
* Wraps a custom form: provides the form context (state + live pricing) and
|
|
5
|
+
* renders a quantity control above and a total + submit footer below the form's
|
|
6
|
+
* own content. Hosts render this around the form's default export.
|
|
7
|
+
*
|
|
8
|
+
* If `product` is omitted, render children only. AI-built forms sometimes nest
|
|
9
|
+
* `<ProductFormShell>` without props; the host already provided the real shell,
|
|
10
|
+
* so a second Provider with undefined product would crash and fall back to the
|
|
11
|
+
* default GroupRows form.
|
|
12
|
+
*/
|
|
13
|
+
export declare function ProductFormShell({ product, pricing, actions, helpers, children, initialJob, actionLabels, footerActions: footerActionsProp, }: Partial<Omit<ProductFormProviderProps, 'children'>> & {
|
|
14
|
+
children?: React.ReactNode;
|
|
15
|
+
}): React.JSX.Element;
|