@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,190 @@
|
|
|
1
|
+
import { OPTION_FIELD_TYPES } from './constants';
|
|
2
|
+
import { groupFieldsOf, independentFieldsOf, isOptionQuantityGridProduct, productHasGroups, } from './product';
|
|
3
|
+
export function selectionsToVariations(selections) {
|
|
4
|
+
return Object.entries(selections).map(([fieldId, sel]) => {
|
|
5
|
+
const variation = {
|
|
6
|
+
variationField: { id: Number(fieldId) },
|
|
7
|
+
value: sel.value,
|
|
8
|
+
};
|
|
9
|
+
if (sel.variationFiles?.length) {
|
|
10
|
+
variation.variationFiles = sel.variationFiles.map((file) => ({
|
|
11
|
+
id: file.id,
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
// Prefer explicit option rows (colour-extract colour/value edits).
|
|
15
|
+
if (sel.selectedOptions?.length) {
|
|
16
|
+
variation.selectedOptions = sel.selectedOptions;
|
|
17
|
+
}
|
|
18
|
+
return variation;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/** Build the job JSON the API expects from provider/form state. */
|
|
22
|
+
export function buildProductFormJob(state) {
|
|
23
|
+
const { product, quantity, selections, groups, optionQuantities } = state;
|
|
24
|
+
const independentVariations = selectionsToVariations(pickSelections(selections, independentFieldsOf(product)));
|
|
25
|
+
const job = {
|
|
26
|
+
product: { id: product?.id },
|
|
27
|
+
variations: independentVariations,
|
|
28
|
+
};
|
|
29
|
+
if (!productHasGroups(product)) {
|
|
30
|
+
return { ...job, quantity };
|
|
31
|
+
}
|
|
32
|
+
if (isOptionQuantityGridProduct(product) && optionQuantities) {
|
|
33
|
+
const groupField = groupFieldsOf(product)[0];
|
|
34
|
+
job.variationsGroups = (groupField.options || [])
|
|
35
|
+
.filter((opt) => opt?.id !== undefined)
|
|
36
|
+
.map((opt) => ({
|
|
37
|
+
quantity: optionQuantities[opt.id] ?? 0,
|
|
38
|
+
variations: [
|
|
39
|
+
{
|
|
40
|
+
variationField: { id: groupField.id },
|
|
41
|
+
value: String(opt.id),
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}))
|
|
45
|
+
.filter((group) => group.quantity > 0);
|
|
46
|
+
return job;
|
|
47
|
+
}
|
|
48
|
+
if (groups?.length) {
|
|
49
|
+
job.variationsGroups = groups
|
|
50
|
+
.filter((row) => row.quantity > 0)
|
|
51
|
+
.map((row) => ({
|
|
52
|
+
quantity: row.quantity,
|
|
53
|
+
variations: selectionsToVariations(pickSelections(row.selections, groupFieldsOf(product))),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
return job;
|
|
57
|
+
}
|
|
58
|
+
function pickSelections(selections, fields) {
|
|
59
|
+
const ids = new Set(fields.map((f) => f.id).filter((id) => id !== undefined));
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [key, sel] of Object.entries(selections)) {
|
|
62
|
+
const id = Number(key);
|
|
63
|
+
if (ids.has(id))
|
|
64
|
+
out[id] = sel;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function fieldIdOf(variation) {
|
|
69
|
+
const field = variation.variationField;
|
|
70
|
+
if (typeof field === 'number')
|
|
71
|
+
return field;
|
|
72
|
+
return field?.id;
|
|
73
|
+
}
|
|
74
|
+
function selectionFromVariation(variation, field) {
|
|
75
|
+
const value = variation.value != null ? String(variation.value) : '';
|
|
76
|
+
const selectedOptions = variation.selectedOptions || [];
|
|
77
|
+
const fromOptions = selectedOptions
|
|
78
|
+
.map((o) => o.id)
|
|
79
|
+
.filter((id) => id !== undefined);
|
|
80
|
+
const variationFiles = (variation.variationFiles || [])
|
|
81
|
+
.filter((file) => file?.id)
|
|
82
|
+
.map((file) => ({ id: file.id }));
|
|
83
|
+
if (fromOptions.length) {
|
|
84
|
+
return {
|
|
85
|
+
value: value || fromOptions.join(','),
|
|
86
|
+
selectedOptionIds: fromOptions,
|
|
87
|
+
selectedOptions,
|
|
88
|
+
variationFiles: variationFiles.length ? variationFiles : undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (field && OPTION_FIELD_TYPES.has(Number(field.fieldType)) && value) {
|
|
92
|
+
const ids = value
|
|
93
|
+
.split(',')
|
|
94
|
+
.map((part) => parseInt(part.trim(), 10))
|
|
95
|
+
.filter((n) => !Number.isNaN(n));
|
|
96
|
+
return {
|
|
97
|
+
value,
|
|
98
|
+
selectedOptionIds: ids,
|
|
99
|
+
variationFiles: variationFiles.length ? variationFiles : undefined,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
value,
|
|
104
|
+
selectedOptionIds: [],
|
|
105
|
+
variationFiles: variationFiles.length ? variationFiles : undefined,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function variationsToSelections(variations, fields) {
|
|
109
|
+
const byId = new Map(fields
|
|
110
|
+
.filter((f) => f.id !== undefined)
|
|
111
|
+
.map((f) => [f.id, f]));
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const variation of variations || []) {
|
|
114
|
+
const id = fieldIdOf(variation);
|
|
115
|
+
if (id === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
out[id] = selectionFromVariation(variation, byId.get(id));
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** Reverse of buildProductFormJob — hydrate provider state from a cart item / job. */
|
|
122
|
+
export function jobToFormState(product, job) {
|
|
123
|
+
const minQty = Number(product.minOrderQuantity ?? product.minimum) || 1;
|
|
124
|
+
const independentFields = independentFieldsOf(product);
|
|
125
|
+
const groupFields = groupFieldsOf(product);
|
|
126
|
+
const selections = {
|
|
127
|
+
...initialDefaultSelections(independentFields),
|
|
128
|
+
...variationsToSelections(job?.variations, independentFields),
|
|
129
|
+
};
|
|
130
|
+
if (!productHasGroups(product)) {
|
|
131
|
+
return {
|
|
132
|
+
quantity: Number(job?.quantity) > 0 ? Number(job?.quantity) : minQty,
|
|
133
|
+
selections,
|
|
134
|
+
groups: [],
|
|
135
|
+
optionQuantities: {},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (isOptionQuantityGridProduct(product)) {
|
|
139
|
+
const field = groupFields[0];
|
|
140
|
+
const optionQuantities = {};
|
|
141
|
+
for (const opt of field?.options || []) {
|
|
142
|
+
if (opt?.id !== undefined)
|
|
143
|
+
optionQuantities[opt.id] = 0;
|
|
144
|
+
}
|
|
145
|
+
for (const group of job?.variationsGroups || []) {
|
|
146
|
+
const variation = group.variations?.[0];
|
|
147
|
+
const optId = variation?.value != null ? Number(variation.value) : undefined;
|
|
148
|
+
if (optId !== undefined && !Number.isNaN(optId)) {
|
|
149
|
+
optionQuantities[optId] = Number(group.quantity) || 0;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
quantity: minQty,
|
|
154
|
+
selections,
|
|
155
|
+
groups: [],
|
|
156
|
+
optionQuantities,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const groups = (job?.variationsGroups || [])
|
|
160
|
+
.map((group) => ({
|
|
161
|
+
quantity: Number(group.quantity) > 0 ? Number(group.quantity) : minQty,
|
|
162
|
+
selections: {
|
|
163
|
+
...initialDefaultSelections(groupFields),
|
|
164
|
+
...variationsToSelections(group.variations, groupFields),
|
|
165
|
+
},
|
|
166
|
+
}))
|
|
167
|
+
.filter((row) => row.quantity > 0);
|
|
168
|
+
return {
|
|
169
|
+
quantity: minQty,
|
|
170
|
+
selections,
|
|
171
|
+
groups: groups.length
|
|
172
|
+
? groups
|
|
173
|
+
: [{ quantity: minQty, selections: initialDefaultSelections(groupFields) }],
|
|
174
|
+
optionQuantities: {},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function initialDefaultSelections(fields) {
|
|
178
|
+
const out = {};
|
|
179
|
+
for (const f of fields) {
|
|
180
|
+
if (f?.id === undefined)
|
|
181
|
+
continue;
|
|
182
|
+
if (OPTION_FIELD_TYPES.has(Number(f.fieldType)) && f.fieldType !== 6) {
|
|
183
|
+
const opt = (f.options || []).find((o) => o?.isVisible !== false && o?.available !== false);
|
|
184
|
+
if (opt?.id !== undefined) {
|
|
185
|
+
out[f.id] = { value: String(opt.id), selectedOptionIds: [opt.id] };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Selectable field types whose `value` is option ids (vs. free text/number). */
|
|
2
|
+
export declare const OPTION_FIELD_TYPES: Set<number>;
|
|
3
|
+
/** Colour Extract: image upload + runtime-generated colour options. */
|
|
4
|
+
export declare const COLOUR_EXTRACT = 13;
|
|
5
|
+
/** Area: height × width stored as millimetres (`"heightMm,widthMm"`). */
|
|
6
|
+
export declare const AREA = 14;
|
|
7
|
+
/** Field types that carry uploaded files on the variation. */
|
|
8
|
+
export declare const FILE_FIELD_TYPES: Set<number>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Selectable field types whose `value` is option ids (vs. free text/number). */
|
|
2
|
+
export const OPTION_FIELD_TYPES = new Set([2, 6, 7, 9, 11]);
|
|
3
|
+
/** Colour Extract: image upload + runtime-generated colour options. */
|
|
4
|
+
export const COLOUR_EXTRACT = 13;
|
|
5
|
+
/** Area: height × width stored as millimetres (`"heightMm,widthMm"`). */
|
|
6
|
+
export const AREA = 14;
|
|
7
|
+
/** Field types that carry uploaded files on the variation. */
|
|
8
|
+
export const FILE_FIELD_TYPES = new Set([3, COLOUR_EXTRACT]);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const MERCHI_BASE = 'https://merchi.co';
|
|
2
|
+
/** Format a numeric amount as a currency string. */
|
|
3
|
+
export function formatCurrency(amount, currency = 'USD') {
|
|
4
|
+
try {
|
|
5
|
+
return new Intl.NumberFormat(undefined, {
|
|
6
|
+
style: 'currency',
|
|
7
|
+
currency,
|
|
8
|
+
}).format(amount);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return `${currency} ${amount.toFixed(2)}`;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Build an absolute merchi.co URL from a relative path. */
|
|
15
|
+
export function urlFor(path) {
|
|
16
|
+
const trimmed = path.startsWith('/') ? path.slice(1) : path;
|
|
17
|
+
return `${MERCHI_BASE}/${trimmed}`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { JobJson } from '../types/job';
|
|
2
|
+
import { VariationsGroupJson } from '../types/variation';
|
|
3
|
+
/** Find the quoted variationsGroup row for a grid option (single group field). */
|
|
4
|
+
export declare function quoteGroupForOption(quote: JobJson | null | undefined, groupFieldId: number, optionId: number): VariationsGroupJson | undefined;
|
|
5
|
+
export interface InventoryStatusText {
|
|
6
|
+
tone: 'ok' | 'warn' | 'error';
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
/** Human-readable inventory pill text (mirrors @merchi/product-form). */
|
|
10
|
+
export declare function inventoryStatusText(group: VariationsGroupJson | undefined, needsInventory: boolean): InventoryStatusText | null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function variationFieldId(variationField) {
|
|
2
|
+
if (variationField === undefined || variationField === null)
|
|
3
|
+
return undefined;
|
|
4
|
+
if (typeof variationField === 'number')
|
|
5
|
+
return variationField;
|
|
6
|
+
if (typeof variationField === 'object')
|
|
7
|
+
return variationField.id;
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
/** Find the quoted variationsGroup row for a grid option (single group field). */
|
|
11
|
+
export function quoteGroupForOption(quote, groupFieldId, optionId) {
|
|
12
|
+
if (!quote?.variationsGroups?.length)
|
|
13
|
+
return undefined;
|
|
14
|
+
const optionKey = String(optionId);
|
|
15
|
+
return quote.variationsGroups.find((group) => (group.variations || []).some((variation) => {
|
|
16
|
+
const fieldId = variationFieldId(variation.variationField);
|
|
17
|
+
return fieldId === groupFieldId && String(variation.value ?? '') === optionKey;
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
/** Human-readable inventory pill text (mirrors @merchi/product-form). */
|
|
21
|
+
export function inventoryStatusText(group, needsInventory) {
|
|
22
|
+
if (!needsInventory || !group)
|
|
23
|
+
return null;
|
|
24
|
+
if (group.inventorySufficient !== false) {
|
|
25
|
+
return { tone: 'ok', label: 'In stock' };
|
|
26
|
+
}
|
|
27
|
+
const count = group.inventoryCount ?? 0;
|
|
28
|
+
if (count > 0) {
|
|
29
|
+
return { tone: 'warn', label: `Insufficient stock (${count} in stock)` };
|
|
30
|
+
}
|
|
31
|
+
return { tone: 'error', label: 'Out of stock' };
|
|
32
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ProductJson } from '../types/product';
|
|
2
|
+
import { VariationFieldJson } from '../types/variation';
|
|
3
|
+
export declare function groupFieldsOf(product: ProductJson): VariationFieldJson[];
|
|
4
|
+
export declare function independentFieldsOf(product: ProductJson): VariationFieldJson[];
|
|
5
|
+
export declare function productHasGroups(product: ProductJson): boolean;
|
|
6
|
+
/** One group field with selectable options — use OptionQuantityGrid (per-option qty). */
|
|
7
|
+
export declare function isOptionQuantityGridProduct(product: ProductJson): boolean;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { OPTION_FIELD_TYPES } from './constants';
|
|
2
|
+
export function groupFieldsOf(product) {
|
|
3
|
+
const fields = product
|
|
4
|
+
.groupVariationFields;
|
|
5
|
+
return Array.isArray(fields) ? fields : [];
|
|
6
|
+
}
|
|
7
|
+
export function independentFieldsOf(product) {
|
|
8
|
+
const fields = product
|
|
9
|
+
.independentVariationFields;
|
|
10
|
+
return Array.isArray(fields) ? fields : [];
|
|
11
|
+
}
|
|
12
|
+
export function productHasGroups(product) {
|
|
13
|
+
return groupFieldsOf(product).length > 0;
|
|
14
|
+
}
|
|
15
|
+
/** One group field with selectable options — use OptionQuantityGrid (per-option qty). */
|
|
16
|
+
export function isOptionQuantityGridProduct(product) {
|
|
17
|
+
const groups = groupFieldsOf(product);
|
|
18
|
+
if (groups.length !== 1)
|
|
19
|
+
return false;
|
|
20
|
+
const field = groups[0];
|
|
21
|
+
if (field?.id === undefined)
|
|
22
|
+
return false;
|
|
23
|
+
const fieldType = Number(field.fieldType);
|
|
24
|
+
if (!OPTION_FIELD_TYPES.has(fieldType) || fieldType === 6)
|
|
25
|
+
return false;
|
|
26
|
+
return (field.options || []).some((o) => o?.id !== undefined);
|
|
27
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { JobJson } from '../types/job';
|
|
2
|
+
import { VariationsGroupJson } from '../types/variation';
|
|
3
|
+
/** Return a deep-ish copy of the job with variations/groups cleaned for the API:
|
|
4
|
+
* form-only fields removed, zero-quantity groups dropped, and each remaining
|
|
5
|
+
* group's quantity defaulted to 0 when missing. Does not mutate the input
|
|
6
|
+
* (unlike @merchi/product-form's in-place cleaner). */
|
|
7
|
+
export declare function serializeJob(job: JobJson): JobJson;
|
|
8
|
+
/** Keep only groups whose quantity is greater than zero. */
|
|
9
|
+
export declare function nonEmptyGroups(groups: VariationsGroupJson[]): VariationsGroupJson[];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const FORM_ONLY_VARIATION_KEYS = ['id', 'variationArrayFieldId', 'json', 'groupId'];
|
|
2
|
+
function cleanVariation(variation) {
|
|
3
|
+
const copy = { ...variation };
|
|
4
|
+
for (const key of FORM_ONLY_VARIATION_KEYS) {
|
|
5
|
+
delete copy[key];
|
|
6
|
+
}
|
|
7
|
+
return copy;
|
|
8
|
+
}
|
|
9
|
+
/** Return a deep-ish copy of the job with variations/groups cleaned for the API:
|
|
10
|
+
* form-only fields removed, zero-quantity groups dropped, and each remaining
|
|
11
|
+
* group's quantity defaulted to 0 when missing. Does not mutate the input
|
|
12
|
+
* (unlike @merchi/product-form's in-place cleaner). */
|
|
13
|
+
export function serializeJob(job) {
|
|
14
|
+
const out = { ...job };
|
|
15
|
+
if (Array.isArray(job.variations)) {
|
|
16
|
+
out.variations = job.variations.map(cleanVariation);
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(job.variationsGroups)) {
|
|
19
|
+
out.variationsGroups = nonEmptyGroups(job.variationsGroups.map((group) => {
|
|
20
|
+
const cleaned = {
|
|
21
|
+
...group,
|
|
22
|
+
quantity: group.quantity ?? 0,
|
|
23
|
+
};
|
|
24
|
+
if (Array.isArray(group.variations)) {
|
|
25
|
+
cleaned.variations = group.variations.map(cleanVariation);
|
|
26
|
+
}
|
|
27
|
+
return cleaned;
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** Keep only groups whose quantity is greater than zero. */
|
|
33
|
+
export function nonEmptyGroups(groups) {
|
|
34
|
+
return groups.filter((g) => (g.quantity ?? 0) > 0);
|
|
35
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { SDK_VERSION, getProductFormSdkVersion } from './version';
|
|
2
|
+
export type { OptionJson, VariationFieldJson, VariationJson, VariationsGroupJson } from './types/variation';
|
|
3
|
+
export type { ProductJson } from './types/product';
|
|
4
|
+
export type { JobJson, QuoteFields } from './types/job';
|
|
5
|
+
export type { ProductFormProps, ProductFormComponent } from './types/form';
|
|
6
|
+
export { serializeJob, nonEmptyGroups } from './helpers/serialize';
|
|
7
|
+
export { formatCurrency, urlFor } from './helpers/format';
|
|
8
|
+
export { productHasGroups, groupFieldsOf, independentFieldsOf, isOptionQuantityGridProduct, } from './helpers/product';
|
|
9
|
+
export { buildProductFormJob, jobToFormState, selectionsToVariations, } from './helpers/buildJob';
|
|
10
|
+
export type { GroupRow, HydratedProductFormState, ProductFormJobState, } from './helpers/buildJob';
|
|
11
|
+
export { OPTION_FIELD_TYPES, COLOUR_EXTRACT, FILE_FIELD_TYPES, AREA, } from './helpers/constants';
|
|
12
|
+
export { createPricing } from './runtime/pricing';
|
|
13
|
+
export type { Pricing } from './runtime/pricing';
|
|
14
|
+
export { createProductFormRuntime } from './runtime/runtime';
|
|
15
|
+
export type { Actions, Helpers, ProductFormRuntime, ProductFormRuntimeConfig, } from './runtime/runtime';
|
|
16
|
+
export { Section, Stack, Card, Heading, Text, Divider, Field, GroupRows, OptionQuantityGrid, ProductFormProvider, ProductFormShell, ProductFormActions, useProductForm, theme, } from './components';
|
|
17
|
+
export type { ProductFormContextValue, ProductFormProviderProps, FieldSelection, SubmitAction, FooterAction, } from './components';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { SDK_VERSION, getProductFormSdkVersion } from './version';
|
|
2
|
+
export { serializeJob, nonEmptyGroups } from './helpers/serialize';
|
|
3
|
+
export { formatCurrency, urlFor } from './helpers/format';
|
|
4
|
+
export { productHasGroups, groupFieldsOf, independentFieldsOf, isOptionQuantityGridProduct, } from './helpers/product';
|
|
5
|
+
export { buildProductFormJob, jobToFormState, selectionsToVariations, } from './helpers/buildJob';
|
|
6
|
+
export { OPTION_FIELD_TYPES, COLOUR_EXTRACT, FILE_FIELD_TYPES, AREA, } from './helpers/constants';
|
|
7
|
+
export { createPricing } from './runtime/pricing';
|
|
8
|
+
export { createProductFormRuntime } from './runtime/runtime';
|
|
9
|
+
// UI component kit — the building blocks AI-built forms compose. Form authors
|
|
10
|
+
// import these from '@merchi/product-form-sdk' (the static gate allowlists it).
|
|
11
|
+
export { Section, Stack, Card, Heading, Text, Divider, Field, GroupRows, OptionQuantityGrid, ProductFormProvider, ProductFormShell, ProductFormActions, useProductForm, theme, } from './components';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { JobJson } from '../types/job';
|
|
2
|
+
interface FieldSelection {
|
|
3
|
+
selectedOptionIds?: number[];
|
|
4
|
+
value?: string | number | null;
|
|
5
|
+
}
|
|
6
|
+
interface Selections {
|
|
7
|
+
quantity?: number;
|
|
8
|
+
fieldValues: Record<number, FieldSelection>;
|
|
9
|
+
groups?: {
|
|
10
|
+
quantity: number;
|
|
11
|
+
fieldValues: Record<number, FieldSelection>;
|
|
12
|
+
}[];
|
|
13
|
+
}
|
|
14
|
+
interface PricingField {
|
|
15
|
+
id: number;
|
|
16
|
+
isSelectable: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface PricingRules {
|
|
19
|
+
fields: PricingField[];
|
|
20
|
+
groupFields: PricingField[];
|
|
21
|
+
hasGroups: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Convert a job's form values into the `Selections` shape estimateQuote needs.
|
|
24
|
+
* Ported from @merchi/product-form's selections helper so a custom form gets
|
|
25
|
+
* identical client-side pricing behaviour. */
|
|
26
|
+
export declare function toSelections(job: JobJson, rules: PricingRules): Selections;
|
|
27
|
+
/** Compute a quote entirely client-side from pricing rules and merge the pricing
|
|
28
|
+
* fields into the job. Returns the priced job, or null when client-side pricing
|
|
29
|
+
* is unsupported for these rules (caller then falls back to a server quote). */
|
|
30
|
+
export declare function clientQuote(rules: PricingRules, job: JobJson): JobJson | null;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { pricing } from '@merchi/sdk';
|
|
2
|
+
function parseOptionIds(value) {
|
|
3
|
+
if (value === undefined || value === null || value === '')
|
|
4
|
+
return [];
|
|
5
|
+
return String(value)
|
|
6
|
+
.split(',')
|
|
7
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
8
|
+
.filter((n) => !isNaN(n));
|
|
9
|
+
}
|
|
10
|
+
function buildFieldValues(variations, selectableByField) {
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const variation of variations || []) {
|
|
13
|
+
const field = variation && variation.variationField;
|
|
14
|
+
const fieldId = typeof field === 'object' && field !== null ? field.id : field;
|
|
15
|
+
if (fieldId === undefined || fieldId === null)
|
|
16
|
+
continue;
|
|
17
|
+
if (selectableByField[fieldId]) {
|
|
18
|
+
out[fieldId] = { selectedOptionIds: parseOptionIds(variation.value) };
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
out[fieldId] = { value: variation.value ?? null };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/** Convert a job's form values into the `Selections` shape estimateQuote needs.
|
|
27
|
+
* Ported from @merchi/product-form's selections helper so a custom form gets
|
|
28
|
+
* identical client-side pricing behaviour. */
|
|
29
|
+
export function toSelections(job, rules) {
|
|
30
|
+
const selectableByField = {};
|
|
31
|
+
for (const f of [...(rules.fields || []), ...(rules.groupFields || [])]) {
|
|
32
|
+
selectableByField[f.id] = f.isSelectable;
|
|
33
|
+
}
|
|
34
|
+
if (rules.hasGroups) {
|
|
35
|
+
return {
|
|
36
|
+
fieldValues: buildFieldValues(job.variations || [], selectableByField),
|
|
37
|
+
groups: (job.variationsGroups || []).map((g) => ({
|
|
38
|
+
quantity: g.quantity || 0,
|
|
39
|
+
fieldValues: buildFieldValues(g.variations || [], selectableByField),
|
|
40
|
+
})),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
quantity: job.quantity || 0,
|
|
45
|
+
fieldValues: buildFieldValues(job.variations || [], selectableByField),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Compute a quote entirely client-side from pricing rules and merge the pricing
|
|
49
|
+
* fields into the job. Returns the priced job, or null when client-side pricing
|
|
50
|
+
* is unsupported for these rules (caller then falls back to a server quote). */
|
|
51
|
+
export function clientQuote(rules, job) {
|
|
52
|
+
const result = pricing.estimateQuote(rules, toSelections(job, rules));
|
|
53
|
+
if (!result || result.unsupported)
|
|
54
|
+
return null;
|
|
55
|
+
const next = {
|
|
56
|
+
...job,
|
|
57
|
+
cost: result.cost,
|
|
58
|
+
costPerUnit: result.costPerUnit,
|
|
59
|
+
taxAmount: result.taxAmount,
|
|
60
|
+
totalCost: result.totalCost,
|
|
61
|
+
currency: result.currency,
|
|
62
|
+
};
|
|
63
|
+
if (Array.isArray(job.variationsGroups) && Array.isArray(result.groupCosts)) {
|
|
64
|
+
next.variationsGroups = job.variationsGroups.map((g, i) => ({
|
|
65
|
+
...g,
|
|
66
|
+
groupCost: result.groupCosts[i],
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
return next;
|
|
70
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { JobJson } from '../types/job';
|
|
2
|
+
import { ProductJson } from '../types/product';
|
|
3
|
+
export interface Pricing {
|
|
4
|
+
/** Request a quote for the given job; resolves to the job json with pricing
|
|
5
|
+
* fields (cost, totalCost, currency, ...) populated. Uses client-side
|
|
6
|
+
* calculation when the product supports it, else a server quote. */
|
|
7
|
+
getQuote: (job: JobJson) => Promise<JobJson>;
|
|
8
|
+
/** Eagerly fetch + cache the client-side pricing rules (no-op unless the
|
|
9
|
+
* product has clientSideCalculation). Safe to call fire-and-forget on mount so
|
|
10
|
+
* the first quote is instant. */
|
|
11
|
+
warm: () => void;
|
|
12
|
+
}
|
|
13
|
+
export interface PricingOptions {
|
|
14
|
+
/** The product being quoted. Its `id` is used as the default product on the
|
|
15
|
+
* job, and `clientSideCalculation` enables the local pricing path. */
|
|
16
|
+
product?: ProductJson;
|
|
17
|
+
/** Pre-fetched pricing rules; if omitted and client-side is enabled, the rules
|
|
18
|
+
* are lazily fetched from `products/{id}/pricing-rules/`. */
|
|
19
|
+
pricingRules?: unknown;
|
|
20
|
+
}
|
|
21
|
+
/** Build a pricing helper bound to a backend api url.
|
|
22
|
+
*
|
|
23
|
+
* When `product.clientSideCalculation` is set, getQuote computes the quote
|
|
24
|
+
* locally via @merchi/sdk's pricing engine (mirroring @merchi/product-form),
|
|
25
|
+
* fetching the product's pricing-rules bundle once and caching it. It falls back
|
|
26
|
+
* to a server quote (`Job.getQuote` → /specialised-order-estimate/) when
|
|
27
|
+
* client-side is disabled, the rules can't be loaded, or the rules are
|
|
28
|
+
* unsupported for the given selections. */
|
|
29
|
+
export declare function createPricing(apiUrl: string, options?: PricingOptions): Pricing;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Merchi } from '@merchi/sdk';
|
|
2
|
+
import { serializeJob } from '../helpers/serialize';
|
|
3
|
+
import { clientQuote } from './clientPricing';
|
|
4
|
+
/** Build a pricing helper bound to a backend api url.
|
|
5
|
+
*
|
|
6
|
+
* When `product.clientSideCalculation` is set, getQuote computes the quote
|
|
7
|
+
* locally via @merchi/sdk's pricing engine (mirroring @merchi/product-form),
|
|
8
|
+
* fetching the product's pricing-rules bundle once and caching it. It falls back
|
|
9
|
+
* to a server quote (`Job.getQuote` → /specialised-order-estimate/) when
|
|
10
|
+
* client-side is disabled, the rules can't be loaded, or the rules are
|
|
11
|
+
* unsupported for the given selections. */
|
|
12
|
+
export function createPricing(apiUrl, options = {}) {
|
|
13
|
+
const { product, pricingRules: providedRules } = options;
|
|
14
|
+
const defaultProductId = product && product.id;
|
|
15
|
+
const merchi = new Merchi(undefined, undefined, undefined, undefined, apiUrl);
|
|
16
|
+
let rulesCache = providedRules || null;
|
|
17
|
+
let rulesFetchAttempted = false;
|
|
18
|
+
async function getRules() {
|
|
19
|
+
if (rulesCache)
|
|
20
|
+
return rulesCache;
|
|
21
|
+
if (rulesFetchAttempted || !product || product.id === undefined)
|
|
22
|
+
return null;
|
|
23
|
+
rulesFetchAttempted = true;
|
|
24
|
+
try {
|
|
25
|
+
const base = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`;
|
|
26
|
+
const res = await fetch(`${base}products/${product.id}/pricing-rules/`, {
|
|
27
|
+
credentials: 'omit',
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
return null;
|
|
31
|
+
rulesCache = (await res.json());
|
|
32
|
+
return rulesCache;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function serverQuote(job) {
|
|
39
|
+
const withProduct = job.product && job.product.id !== undefined
|
|
40
|
+
? job
|
|
41
|
+
: { ...job, product: { id: defaultProductId } };
|
|
42
|
+
const cleaned = serializeJob(withProduct);
|
|
43
|
+
const merchiJob = new merchi.Job();
|
|
44
|
+
merchiJob.fromJson(cleaned, {
|
|
45
|
+
makeDirty: false,
|
|
46
|
+
arrayValueStrict: false,
|
|
47
|
+
});
|
|
48
|
+
const quoted = await merchiJob.getQuote();
|
|
49
|
+
return quoted.toJson();
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
warm: () => {
|
|
53
|
+
if (product && product.clientSideCalculation) {
|
|
54
|
+
// fire-and-forget: populate the cache so the first quote is instant.
|
|
55
|
+
void getRules();
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
getQuote: async (job) => {
|
|
59
|
+
if (product && product.clientSideCalculation) {
|
|
60
|
+
const rules = await getRules();
|
|
61
|
+
if (rules) {
|
|
62
|
+
const local = clientQuote(rules, job);
|
|
63
|
+
if (local)
|
|
64
|
+
return local;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return serverQuote(job);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ProductJson } from '../types/product';
|
|
2
|
+
import { JobJson } from '../types/job';
|
|
3
|
+
import { Pricing } from './pricing';
|
|
4
|
+
import { serializeJob, nonEmptyGroups } from '../helpers/serialize';
|
|
5
|
+
import { formatCurrency, urlFor } from '../helpers/format';
|
|
6
|
+
export interface Actions {
|
|
7
|
+
addToCart?: (job: JobJson) => void;
|
|
8
|
+
buyNow?: (job: JobJson) => void;
|
|
9
|
+
getQuote?: (job: JobJson) => void;
|
|
10
|
+
}
|
|
11
|
+
export interface Helpers {
|
|
12
|
+
serializeJob: typeof serializeJob;
|
|
13
|
+
nonEmptyGroups: typeof nonEmptyGroups;
|
|
14
|
+
formatCurrency: typeof formatCurrency;
|
|
15
|
+
urlFor: typeof urlFor;
|
|
16
|
+
}
|
|
17
|
+
export interface ProductFormRuntimeConfig {
|
|
18
|
+
apiUrl: string;
|
|
19
|
+
product: ProductJson;
|
|
20
|
+
/** Pre-fetched pricing rules (optional). When omitted and the product has
|
|
21
|
+
* clientSideCalculation set, the rules are fetched lazily on first quote. */
|
|
22
|
+
pricingRules?: unknown;
|
|
23
|
+
onAddToCart?: (job: JobJson) => void;
|
|
24
|
+
onBuyNow?: (job: JobJson) => void;
|
|
25
|
+
onGetQuote?: (job: JobJson) => void;
|
|
26
|
+
}
|
|
27
|
+
export interface ProductFormRuntime {
|
|
28
|
+
pricing: Pricing;
|
|
29
|
+
actions: Actions;
|
|
30
|
+
helpers: Helpers;
|
|
31
|
+
}
|
|
32
|
+
/** Build the runtime (pricing + actions + helpers) the embed passes into a
|
|
33
|
+
* custom form. Each action quotes the job first, then invokes the host callback
|
|
34
|
+
* (matching @merchi/product-form's provider behaviour). An action is omitted when
|
|
35
|
+
* its callback is not supplied. */
|
|
36
|
+
export declare function createProductFormRuntime(config: ProductFormRuntimeConfig): ProductFormRuntime;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createPricing } from './pricing';
|
|
2
|
+
import { serializeJob, nonEmptyGroups } from '../helpers/serialize';
|
|
3
|
+
import { formatCurrency, urlFor } from '../helpers/format';
|
|
4
|
+
/** Build the runtime (pricing + actions + helpers) the embed passes into a
|
|
5
|
+
* custom form. Each action quotes the job first, then invokes the host callback
|
|
6
|
+
* (matching @merchi/product-form's provider behaviour). An action is omitted when
|
|
7
|
+
* its callback is not supplied. */
|
|
8
|
+
export function createProductFormRuntime(config) {
|
|
9
|
+
const pricing = createPricing(config.apiUrl, {
|
|
10
|
+
product: config.product,
|
|
11
|
+
pricingRules: config.pricingRules,
|
|
12
|
+
});
|
|
13
|
+
// Warm client-side pricing rules in the background so the first quote is
|
|
14
|
+
// instant (no-op for products without clientSideCalculation).
|
|
15
|
+
pricing.warm();
|
|
16
|
+
const helpers = { serializeJob, nonEmptyGroups, formatCurrency, urlFor };
|
|
17
|
+
const quoteThen = (cb) => cb
|
|
18
|
+
? async (job) => {
|
|
19
|
+
const quoted = await pricing.getQuote(job);
|
|
20
|
+
if (!quoted.product) {
|
|
21
|
+
quoted.product = { id: config.product.id };
|
|
22
|
+
}
|
|
23
|
+
cb(quoted);
|
|
24
|
+
}
|
|
25
|
+
: undefined;
|
|
26
|
+
const actions = {
|
|
27
|
+
addToCart: quoteThen(config.onAddToCart),
|
|
28
|
+
buyNow: quoteThen(config.onBuyNow),
|
|
29
|
+
getQuote: quoteThen(config.onGetQuote),
|
|
30
|
+
};
|
|
31
|
+
return { pricing, actions, helpers };
|
|
32
|
+
}
|