@osimart/create-storefront 1.0.20 → 1.0.22
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/app/composables/UseThemeGroup.js +4 -0
- package/app/composables/useAddToCart.js +16 -6
- package/app/composables/useCart.js +66 -25
- package/app/composables/useCheckout.js +47 -6
- package/app/composables/useContentTree.js +28 -2
- package/app/composables/useProductCustomization.js +107 -15
- package/app/composables/useProductDetail.js +29 -8
- package/app/composables/useProductItems.js +5 -3
- package/app/composables/useProductVariants.js +32 -1
- package/app/composables/useQuickShop.js +20 -0
- package/app/logic/components/CartDrawer.js +14 -19
- package/app/logic/components/CartItem.js +18 -1
- package/app/logic/components/ProductVariantPicker.js +7 -2
- package/app/logic/components/QuickBuy.js +14 -6
- package/app/logic/components/SiteHeader.js +2 -0
- package/app/logic/sections/MainClassificationSection.js +2 -0
- package/app/pages/checkout.vue +29 -10
- package/app/pages/order-detail.vue +25 -2
- package/app/pages/user-profile/components/orders.vue +23 -2
- package/app/services/CartServices.js +92 -18
- package/app/utils/analytics.js +22 -11
- package/package.json +1 -1
|
@@ -115,6 +115,8 @@ const DEFAULTS = {
|
|
|
115
115
|
|
|
116
116
|
// cart
|
|
117
117
|
HEADER_CART_COUNT: "BADGE",
|
|
118
|
+
HEADER_CART_BADGE_BG: null,
|
|
119
|
+
HEADER_CART_BADGE_TEXT: null,
|
|
118
120
|
|
|
119
121
|
// chrome
|
|
120
122
|
HEADER_SHOW_ACCOUNT: true,
|
|
@@ -453,6 +455,8 @@ const DEFAULTS = {
|
|
|
453
455
|
CART_DRAWER_EMPTY_BTN_STYLE: "THEME",
|
|
454
456
|
CART_DRAWER_RECS_ADD_STYLE: "THEME",
|
|
455
457
|
CART_DRAWER_QTY_SIZE: 30,
|
|
458
|
+
CART_DRAWER_SHOW_REMOVE: true,
|
|
459
|
+
CART_DRAWER_REMOVE_COLOR: null,
|
|
456
460
|
CART_DRAWER_RECS_CARD_BACKGROUND: null,
|
|
457
461
|
CART_DRAWER_RECS_PRICE_POSITION: "BELOW",
|
|
458
462
|
CART_DRAWER_RECS_NAME_LINES: 2,
|
|
@@ -2,12 +2,14 @@ import CartServices from "#osi/services/CartServices";
|
|
|
2
2
|
import { useToast } from "vue-toastification";
|
|
3
3
|
import { useCart } from "./useCart";
|
|
4
4
|
import { useSidebar } from "./useSidebar";
|
|
5
|
+
import { isCustomizationError, needsPersonalization } from "./useProductCustomization";
|
|
5
6
|
|
|
6
7
|
export function useAddToCart() {
|
|
7
8
|
const router = useRouter();
|
|
8
|
-
const {
|
|
9
|
+
const { apply: applyCart } = useCart();
|
|
9
10
|
const { toggleCartSidebar } = useSidebar();
|
|
10
11
|
const { cartType } = useStorefront();
|
|
12
|
+
const { productUrl } = useProductUrl();
|
|
11
13
|
|
|
12
14
|
function openCartUi() {
|
|
13
15
|
if (cartType.value === "PAGE") {
|
|
@@ -38,10 +40,12 @@ export function useAddToCart() {
|
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
//
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// Multiple variants, or a required personalization: picked on the product page.
|
|
44
|
+
if (
|
|
45
|
+
(Array.isArray(product.product_variants) && product.product_variants.length > 1) ||
|
|
46
|
+
needsPersonalization(product)
|
|
47
|
+
) {
|
|
48
|
+
router.push(productUrl(product));
|
|
45
49
|
return;
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -58,10 +62,16 @@ export function useAddToCart() {
|
|
|
58
62
|
variant: product.product_variants?.[0],
|
|
59
63
|
});
|
|
60
64
|
if (response) {
|
|
61
|
-
|
|
65
|
+
// The add already returned the whole cart.
|
|
66
|
+
applyCart(response);
|
|
62
67
|
openCartUi();
|
|
63
68
|
}
|
|
64
69
|
} catch (error) {
|
|
70
|
+
// 400 { properties }: required personalization the list didn't know about.
|
|
71
|
+
if (isCustomizationError(error)) {
|
|
72
|
+
router.push(productUrl(product));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
65
75
|
console.error("[useAddToCart] Error adding to cart:", error);
|
|
66
76
|
try {
|
|
67
77
|
const toast = useToast();
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import CartServices from "#osi/services/CartServices";
|
|
1
|
+
import CartServices, { cartLines } from "#osi/services/CartServices";
|
|
2
2
|
import { clearCartToken } from "#osi/services/CartToken";
|
|
3
3
|
|
|
4
|
+
/** A line's identity. Lines are keyed by order_item_id; `id` is the variant. */
|
|
5
|
+
export const lineKey = (item) => String(item?.order_item_id ?? item ?? "");
|
|
6
|
+
|
|
4
7
|
export function useCart() {
|
|
5
8
|
// The raw server payload. Checkout needs it to build its order lines, so
|
|
6
9
|
// it's kept rather than discarded after mapping.
|
|
7
10
|
const cart = useState("cart-raw", () => null);
|
|
11
|
+
// Every line, add-ons included. Each has `order_item_id` (the line),
|
|
12
|
+
// `id` (the variant), `parent_item` (the parent line for an add-on, else
|
|
13
|
+
// null), `properties` (customization answers) and `customization_price`.
|
|
8
14
|
const cartItems = useState("cart-items", () => []);
|
|
9
15
|
const cartTotal = useState("cart-total", () => 0);
|
|
10
16
|
const loading = useState("cart-loading", () => false);
|
|
@@ -18,22 +24,37 @@ export function useCart() {
|
|
|
18
24
|
if (!response) return response;
|
|
19
25
|
cart.value = response;
|
|
20
26
|
cartTotal.value = response.total_price || 0;
|
|
21
|
-
cartItems.value =
|
|
22
|
-
id,
|
|
23
|
-
...item,
|
|
24
|
-
}));
|
|
27
|
+
cartItems.value = cartLines(response);
|
|
25
28
|
return response;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
/** Lines the shopper controls: products, not the add-ons hanging off them. */
|
|
32
|
+
const topLevelItems = computed(() => cartItems.value.filter((item) => !item.parent_item));
|
|
33
|
+
|
|
34
|
+
/** Add-on lines (gift wrap, box…) of one line. */
|
|
35
|
+
const childrenOf = (item) =>
|
|
36
|
+
cartItems.value.filter((child) => child.parent_item && String(child.parent_item) === lineKey(item));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parents each followed by their add-ons — the order every cart list renders
|
|
40
|
+
* in. An add-on whose parent is missing (shouldn't happen) goes last rather
|
|
41
|
+
* than disappearing.
|
|
42
|
+
*/
|
|
43
|
+
const orderedItems = computed(() => {
|
|
44
|
+
const parents = topLevelItems.value;
|
|
45
|
+
const parentKeys = new Set(parents.map(lineKey));
|
|
46
|
+
return [
|
|
47
|
+
...parents.flatMap((parent) => [parent, ...childrenOf(parent)]),
|
|
48
|
+
...cartItems.value.filter((item) => item.parent_item && !parentKeys.has(String(item.parent_item))),
|
|
49
|
+
];
|
|
50
|
+
});
|
|
32
51
|
|
|
33
|
-
|
|
52
|
+
const isUpdating = (item) => updatingIds.value.includes(lineKey(item));
|
|
53
|
+
|
|
54
|
+
function markUpdating(key, busy) {
|
|
34
55
|
updatingIds.value = busy
|
|
35
|
-
? [...updatingIds.value,
|
|
36
|
-
: updatingIds.value.filter((existing) => existing !==
|
|
56
|
+
? [...updatingIds.value, key]
|
|
57
|
+
: updatingIds.value.filter((existing) => existing !== key);
|
|
37
58
|
}
|
|
38
59
|
|
|
39
60
|
async function fetchCart() {
|
|
@@ -49,29 +70,44 @@ export function useCart() {
|
|
|
49
70
|
}
|
|
50
71
|
|
|
51
72
|
/**
|
|
52
|
-
*
|
|
73
|
+
* Change one line, by its order_item_id. Per-line busy state, not a single
|
|
53
74
|
* global flag — one slow line shouldn't freeze the controls on the others.
|
|
54
75
|
*
|
|
76
|
+
* edit quantity = the line's new quantity
|
|
77
|
+
* remove quantity = how many to take off
|
|
78
|
+
* remove_all deletes the line (and its add-ons, server-side)
|
|
79
|
+
*
|
|
80
|
+
* Add-on lines are ignored: they follow their parent.
|
|
81
|
+
*
|
|
55
82
|
* Throws on failure so the caller can decide how to surface it (the drawer
|
|
56
83
|
* has useApiErrorToast, checkout has its own extractErrorMessage).
|
|
57
84
|
*/
|
|
58
85
|
async function editItem(item, action, quantity) {
|
|
59
|
-
const
|
|
60
|
-
if (!
|
|
86
|
+
const key = lineKey(item);
|
|
87
|
+
if (!item?.order_item_id || item.parent_item || isUpdating(key)) return null;
|
|
61
88
|
|
|
62
|
-
|
|
63
|
-
markUpdating(id, true);
|
|
89
|
+
markUpdating(key, true);
|
|
64
90
|
try {
|
|
65
|
-
return apply(await CartServices.
|
|
91
|
+
return apply(await CartServices.editLine(item, action, quantity));
|
|
66
92
|
} finally {
|
|
67
|
-
markUpdating(
|
|
93
|
+
markUpdating(key, false);
|
|
68
94
|
}
|
|
69
95
|
}
|
|
70
96
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
|
|
97
|
+
const step = (item) => Number(item?.quantity_step) || 1;
|
|
98
|
+
|
|
99
|
+
// "add" with a variant id would add a new plain line (no customization),
|
|
100
|
+
// so increasing a line sets its quantity instead.
|
|
101
|
+
const increaseItem = (item) =>
|
|
102
|
+
editItem(item, "edit", (Number(item?.quantity) || 0) + step(item));
|
|
103
|
+
const decreaseItem = (item) => editItem(item, "remove", step(item));
|
|
104
|
+
const removeItem = (item) => editItem(item, "remove_all");
|
|
105
|
+
|
|
106
|
+
/** How many of a variant the shopper has, across all its lines. */
|
|
107
|
+
const quantityOf = (variantId) =>
|
|
108
|
+
topLevelItems.value
|
|
109
|
+
.filter((item) => String(item.id) === String(variantId))
|
|
110
|
+
.reduce((total, item) => total + (Number(item.quantity) || 0), 0);
|
|
75
111
|
|
|
76
112
|
/**
|
|
77
113
|
* Call only once the server has confirmed there's nothing left to pay. The
|
|
@@ -87,17 +123,22 @@ export function useCart() {
|
|
|
87
123
|
updatingIds.value = [];
|
|
88
124
|
}
|
|
89
125
|
|
|
126
|
+
// Header badge: products the shopper picked, not their add-on lines.
|
|
90
127
|
const count = computed(() =>
|
|
91
|
-
|
|
128
|
+
topLevelItems.value.reduce((total, item) => total + (Number(item.quantity) || 0), 0),
|
|
92
129
|
);
|
|
93
130
|
|
|
94
131
|
return {
|
|
95
132
|
cart,
|
|
96
133
|
cartItems,
|
|
134
|
+
topLevelItems,
|
|
135
|
+
orderedItems,
|
|
136
|
+
childrenOf,
|
|
97
137
|
cartTotal,
|
|
98
138
|
loading,
|
|
99
139
|
count,
|
|
100
140
|
isUpdating,
|
|
141
|
+
quantityOf,
|
|
101
142
|
fetchCart,
|
|
102
143
|
editItem,
|
|
103
144
|
increaseItem,
|
|
@@ -106,4 +147,4 @@ export function useCart() {
|
|
|
106
147
|
apply,
|
|
107
148
|
clear,
|
|
108
149
|
};
|
|
109
|
-
}
|
|
150
|
+
}
|
|
@@ -75,11 +75,14 @@ export function useCheckout() {
|
|
|
75
75
|
const {
|
|
76
76
|
cart: rawCart,
|
|
77
77
|
cartItems,
|
|
78
|
+
orderedItems,
|
|
79
|
+
topLevelItems,
|
|
78
80
|
cartTotal,
|
|
79
81
|
loading: cartLoading,
|
|
80
82
|
isUpdating,
|
|
81
83
|
fetchCart,
|
|
82
|
-
|
|
84
|
+
increaseItem,
|
|
85
|
+
decreaseItem,
|
|
83
86
|
removeItem,
|
|
84
87
|
} = useCart();
|
|
85
88
|
|
|
@@ -162,14 +165,23 @@ export function useCheckout() {
|
|
|
162
165
|
// ================================================================
|
|
163
166
|
// COMPUTED
|
|
164
167
|
// ================================================================
|
|
165
|
-
// Template keeps its original names.
|
|
166
|
-
|
|
168
|
+
// Template keeps its original names. Every line, each product followed by
|
|
169
|
+
// its add-on lines (gift wrap…), which render nested with no stepper.
|
|
170
|
+
const CartItems = computed(() => orderedItems.value);
|
|
171
|
+
// The products the shopper picked (thumbnails strip, item count).
|
|
172
|
+
const TopLevelItems = computed(() => topLevelItems.value);
|
|
167
173
|
const total_price = computed(() => cartTotal.value);
|
|
168
174
|
|
|
169
175
|
const totalItemsCount = computed(() =>
|
|
170
|
-
|
|
176
|
+
TopLevelItems.value.reduce((sum, item) => sum + Number(item.quantity || 0), 0),
|
|
171
177
|
);
|
|
172
178
|
|
|
179
|
+
// Checkout re-checks each line's customization against the product's
|
|
180
|
+
// current setup. When the merchant changed it (field made required, option
|
|
181
|
+
// deleted, add-on changed) placeOrder answers { cart: "The options for X
|
|
182
|
+
// have changed…" }: shown as a banner that links to the lines.
|
|
183
|
+
const cartError = ref("");
|
|
184
|
+
|
|
173
185
|
const isLoading = computed(() => cartLoading.value || needsUserInfo.value);
|
|
174
186
|
|
|
175
187
|
const selectedAddress = computed(() =>
|
|
@@ -299,13 +311,26 @@ export function useCheckout() {
|
|
|
299
311
|
toast.error(extractErrorMessage(error));
|
|
300
312
|
}
|
|
301
313
|
}
|
|
314
|
+
// Each targets one line by order_item_id; add-on lines are never edited
|
|
315
|
+
// directly (useCart ignores them, the template offers no controls).
|
|
302
316
|
const increaseQuantity = (item) =>
|
|
303
|
-
mutateCart(() =>
|
|
317
|
+
mutateCart(() => increaseItem(item), "Error adding to cart:");
|
|
304
318
|
const decreaseQuantity = (item) =>
|
|
305
|
-
mutateCart(() =>
|
|
319
|
+
mutateCart(() => decreaseItem(item), "Error decreasing quantity:");
|
|
306
320
|
const deleteItem = (item) =>
|
|
307
321
|
mutateCart(() => removeItem(item), "Error removing from cart:");
|
|
308
322
|
|
|
323
|
+
/** "Review cart" on the banner: bring the line items into view. */
|
|
324
|
+
function reviewCart() {
|
|
325
|
+
const desktop = typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches;
|
|
326
|
+
if (!desktop) mobileSummaryOpen.value = true;
|
|
327
|
+
nextTick(() => {
|
|
328
|
+
document
|
|
329
|
+
.querySelector(desktop ? "[data-checkout-lines='rail']" : "[data-checkout-lines='mobile']")
|
|
330
|
+
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
309
334
|
// ================================================================
|
|
310
335
|
// REFERENCE DATA
|
|
311
336
|
// ================================================================
|
|
@@ -762,6 +787,13 @@ export function useCheckout() {
|
|
|
762
787
|
} catch (error) {
|
|
763
788
|
console.error("Error placing order:", error);
|
|
764
789
|
if (handleOrderPromoError(error)) return;
|
|
790
|
+
const data = errorData(error);
|
|
791
|
+
if (data && typeof data === "object" && !Array.isArray(data) && data.cart) {
|
|
792
|
+
cartError.value = [].concat(data.cart).join(" ");
|
|
793
|
+
await nextTick();
|
|
794
|
+
if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
765
797
|
toast.error(extractErrorMessage(error));
|
|
766
798
|
} finally {
|
|
767
799
|
isCheckingOut.value = false;
|
|
@@ -859,6 +891,12 @@ export function useCheckout() {
|
|
|
859
891
|
refreshFreeDelivery();
|
|
860
892
|
});
|
|
861
893
|
|
|
894
|
+
// Any cart change (the shopper removed / re-added the line) clears the
|
|
895
|
+
// "options have changed" banner; the next attempt re-checks server-side.
|
|
896
|
+
watch(cartItems, () => {
|
|
897
|
+
cartError.value = "";
|
|
898
|
+
});
|
|
899
|
+
|
|
862
900
|
watch(phoneNumberResults, (results) => {
|
|
863
901
|
if (formErrors.value.mobile_number && results?.isValid) {
|
|
864
902
|
clearFieldError("mobile_number");
|
|
@@ -894,12 +932,15 @@ export function useCheckout() {
|
|
|
894
932
|
|
|
895
933
|
// cart
|
|
896
934
|
CartItems,
|
|
935
|
+
TopLevelItems,
|
|
897
936
|
total_price,
|
|
898
937
|
totalItemsCount,
|
|
899
938
|
isItemUpdating,
|
|
900
939
|
increaseQuantity,
|
|
901
940
|
decreaseQuantity,
|
|
902
941
|
deleteItem,
|
|
942
|
+
cartError,
|
|
943
|
+
reviewCart,
|
|
903
944
|
|
|
904
945
|
// user + form
|
|
905
946
|
isLoggedIn,
|
|
@@ -438,9 +438,21 @@ export function useContentTree(section, options = {}) {
|
|
|
438
438
|
if (bleed) {
|
|
439
439
|
for (const [k, val] of Object.entries(paddingOf(v))) box[k.replace("--cb-p", "--card-p")] = val;
|
|
440
440
|
}
|
|
441
|
+
// Quick buy's colour swatches follow the card's Swatches block.
|
|
442
|
+
const findSw = (list) => {
|
|
443
|
+
for (const c of list ?? []) {
|
|
444
|
+
if (c.type === "CARD_SWATCHES") return c;
|
|
445
|
+
const hit = findSw(c.children);
|
|
446
|
+
if (hit) return hit;
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
};
|
|
450
|
+
const swatchRadius = findSw(children)?.swRadius;
|
|
451
|
+
if (swatchRadius) kids = kids.map((c) => (c.type === "CARD_IMAGE" ? { ...c, swatchRadius } : c));
|
|
441
452
|
return {
|
|
442
453
|
...base,
|
|
443
454
|
...flexOf(v),
|
|
455
|
+
swatchRadius,
|
|
444
456
|
children: kids,
|
|
445
457
|
sizeClasses: ["w-fill"],
|
|
446
458
|
sizeVars: {},
|
|
@@ -579,9 +591,15 @@ export function useContentTree(section, options = {}) {
|
|
|
579
591
|
box: boxOf(v, { background: false }),
|
|
580
592
|
max: num(v.MAX, 5),
|
|
581
593
|
square: upper(v.SHAPE, "CIRCLE") === "SQUARE",
|
|
594
|
+
// Own radius (0 = sharp) over the shape's; the card's quick buy uses it too.
|
|
595
|
+
swRadius: has(v.RADIUS) ? `${num(v.RADIUS, 0)}px` : upper(v.SHAPE, "CIRCLE") === "SQUARE" ? "2px" : undefined,
|
|
582
596
|
// COLOR: the colour option only; ALL: every option (sizes as text).
|
|
583
597
|
show: upper(v.SHOW, "ALL"),
|
|
584
|
-
swatchVars: {
|
|
598
|
+
swatchVars: {
|
|
599
|
+
"--cb-sw": `${num(v.SIZE, 16)}px`,
|
|
600
|
+
"--cb-sw-gap": `${num(v.GAP, 6)}px`,
|
|
601
|
+
...(has(v.RADIUS) ? { "--cb-sw-r": `${num(v.RADIUS, 0)}px` } : {}),
|
|
602
|
+
},
|
|
585
603
|
};
|
|
586
604
|
|
|
587
605
|
case "CARD_BUTTON":
|
|
@@ -753,8 +771,16 @@ export function useContentTree(section, options = {}) {
|
|
|
753
771
|
return {
|
|
754
772
|
...base,
|
|
755
773
|
...sizeOf(v, "WIDTH", "WIDTH_MOBILE", "FILL"),
|
|
756
|
-
|
|
774
|
+
// Background too: their Text-look settings carry BG_COLOR (Title,
|
|
775
|
+
// Description, Breadcrumbs…); blocks without it just leave it unset.
|
|
776
|
+
box: boxOf(v),
|
|
757
777
|
...(textDefault ? textLook(v, textDefault) : {}),
|
|
778
|
+
// Text parts always fill their row (no Width setting), so the text box
|
|
779
|
+
// — capped by MAX_WIDTH — would sit at the start even with the section
|
|
780
|
+
// centred. ContentNode lines it up instead: the block's own Alignment,
|
|
781
|
+
// else its group's (the inherited --cb-col-align).
|
|
782
|
+
textPart: !!textDefault,
|
|
783
|
+
selfVars: textDefault ? compact({ "--cb-self-align": COL_ALIGN[upper(v.ALIGNMENT, "")] }) : {},
|
|
758
784
|
component: markRaw(component),
|
|
759
785
|
};
|
|
760
786
|
}
|
|
@@ -1,52 +1,93 @@
|
|
|
1
|
-
import { computed, reactive, watch } from "vue";
|
|
1
|
+
import { computed, nextTick, reactive, watch } from "vue";
|
|
2
2
|
|
|
3
|
+
// 0 is a valid NUMBER answer, so only null / undefined / false / blank are empty.
|
|
3
4
|
const EMPTY = (value) =>
|
|
4
5
|
value === null || value === undefined || value === false ||
|
|
5
6
|
(typeof value === "string" && value.trim() === "");
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
const sameId = (a, b) => a != null && b != null && String(a) === String(b);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Product customization answers, scoped to the product and variant shown.
|
|
12
|
+
*
|
|
13
|
+
* `product` and `selectedVariant` are refs; the variant is the variant object
|
|
14
|
+
* (its `id` is checked against each set's excluded_variants).
|
|
15
|
+
*
|
|
16
|
+
* The backend validates and prices everything again on add to cart; the
|
|
17
|
+
* prices here are only a preview.
|
|
18
|
+
*/
|
|
8
19
|
export function useProductCustomization(product, selectedVariant) {
|
|
9
20
|
const values = reactive({});
|
|
10
21
|
const errors = reactive({});
|
|
11
22
|
|
|
23
|
+
// A set is hidden for the selected variant when it's in excluded_variants.
|
|
24
|
+
// No variant picked yet: show every set.
|
|
12
25
|
const visibleSets = computed(() => {
|
|
13
26
|
const variantId = selectedVariant.value?.id;
|
|
14
27
|
return (product.value?.customization_sets || []).filter(
|
|
15
|
-
(set) =>
|
|
28
|
+
(set) => variantId == null || !(set.excluded_variants || []).some((id) => sameId(id, variantId)),
|
|
16
29
|
);
|
|
17
30
|
});
|
|
18
31
|
const visibleFields = computed(() =>
|
|
19
32
|
visibleSets.value.flatMap((set) => set.customization_fields || []),
|
|
20
33
|
);
|
|
21
34
|
|
|
35
|
+
const hasCustomization = computed(() => visibleFields.value.length > 0);
|
|
36
|
+
const hasRequired = computed(() =>
|
|
37
|
+
(product.value?.customization_sets || []).some((set) =>
|
|
38
|
+
(set.customization_fields || []).some((field) => field.required),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// A variant change that hides a set drops its answers (and their price);
|
|
43
|
+
// answers for fields still visible are kept.
|
|
22
44
|
watch(visibleFields, (fields) => {
|
|
23
45
|
const keys = new Set(fields.map((field) => field.key));
|
|
24
46
|
Object.keys(values).forEach((key) => { if (!keys.has(key)) delete values[key]; });
|
|
25
47
|
Object.keys(errors).forEach((key) => { if (!keys.has(key)) delete errors[key]; });
|
|
26
|
-
});
|
|
48
|
+
}, { immediate: true });
|
|
27
49
|
|
|
28
50
|
function optionFor(field) {
|
|
29
|
-
return field.options?.find((option) => option.id
|
|
51
|
+
return field.options?.find((option) => sameId(option.id, values[field.key])) || null;
|
|
30
52
|
}
|
|
31
53
|
|
|
54
|
+
/** What a filled field costs: { delta } on the line, or { addon } as its own line. */
|
|
32
55
|
function costOf(field) {
|
|
33
56
|
if (EMPTY(values[field.key])) return null;
|
|
34
57
|
if (field.type === "SELECT") {
|
|
35
58
|
const option = optionFor(field);
|
|
36
59
|
if (!option) return null;
|
|
60
|
+
// option.price_delta null means "use the field's price", not 0.
|
|
37
61
|
return option.addon_variant
|
|
38
62
|
? { addon: option.addon_variant }
|
|
39
|
-
: { delta: option.price_delta ?? field.price_delta ?? 0 };
|
|
63
|
+
: { delta: Number(option.price_delta ?? field.price_delta ?? 0) || 0 };
|
|
40
64
|
}
|
|
41
65
|
return field.addon_variant
|
|
42
66
|
? { addon: field.addon_variant }
|
|
43
|
-
: { delta: field.price_delta || 0 };
|
|
67
|
+
: { delta: Number(field.price_delta) || 0 };
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
const deltaPerUnit = computed(() => visibleFields.value.reduce(
|
|
47
|
-
(sum, field) => sum + (
|
|
71
|
+
(sum, field) => sum + (costOf(field)?.delta || 0), 0,
|
|
48
72
|
));
|
|
49
|
-
const addons = computed(() =>
|
|
73
|
+
const addons = computed(() =>
|
|
74
|
+
visibleFields.value.map(costOf).filter((cost) => cost?.addon).map((cost) => cost.addon),
|
|
75
|
+
);
|
|
76
|
+
const addonsPerUnit = computed(() =>
|
|
77
|
+
addons.value.reduce((sum, addon) => sum + (Number(addon.price) || 0), 0),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** Scroll the first field with an error into view. */
|
|
81
|
+
function scrollToFirstError() {
|
|
82
|
+
if (typeof document === "undefined") return;
|
|
83
|
+
const first = visibleFields.value.find((field) => errors[field.key]);
|
|
84
|
+
if (!first) return;
|
|
85
|
+
nextTick(() => {
|
|
86
|
+
const el = [...document.querySelectorAll(".osi-pdp-customization [data-key]")]
|
|
87
|
+
.find((node) => node.dataset.key === String(first.key));
|
|
88
|
+
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
50
91
|
|
|
51
92
|
function validate() {
|
|
52
93
|
Object.keys(errors).forEach((key) => delete errors[key]);
|
|
@@ -62,9 +103,12 @@ export function useProductCustomization(product, selectedVariant) {
|
|
|
62
103
|
errors[field.key] = `${field.label} must be at most ${field.max_length} characters.`;
|
|
63
104
|
}
|
|
64
105
|
}
|
|
65
|
-
|
|
106
|
+
const ok = Object.keys(errors).length === 0;
|
|
107
|
+
if (!ok) scrollToFirstError();
|
|
108
|
+
return ok;
|
|
66
109
|
}
|
|
67
110
|
|
|
111
|
+
/** Add-to-cart `properties`: filled, visible fields only, keyed by field key. */
|
|
68
112
|
const payload = computed(() => {
|
|
69
113
|
const result = {};
|
|
70
114
|
for (const field of visibleFields.value) {
|
|
@@ -72,17 +116,31 @@ export function useProductCustomization(product, selectedVariant) {
|
|
|
72
116
|
if (EMPTY(value)) continue;
|
|
73
117
|
if (field.type === "CHECKBOX") result[field.key] = true;
|
|
74
118
|
else if (field.type === "NUMBER") result[field.key] = Number(value);
|
|
75
|
-
else if (field.type === "SELECT") result[field.key] = value;
|
|
119
|
+
else if (field.type === "SELECT") result[field.key] = value; // the option id, not its text
|
|
76
120
|
else result[field.key] = String(value).trim();
|
|
77
121
|
}
|
|
78
122
|
return result;
|
|
79
123
|
});
|
|
80
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Backend 400 { properties: { key: message } } -> messages under the fields.
|
|
127
|
+
* Returns false when nothing matched a visible field, so the caller shows
|
|
128
|
+
* the error some other way.
|
|
129
|
+
*/
|
|
81
130
|
function applyServerErrors(error) {
|
|
82
|
-
const
|
|
131
|
+
const data = error?.data ?? error?.response?._data ?? error?.response?.data;
|
|
132
|
+
const serverErrors = data?.properties;
|
|
83
133
|
if (!serverErrors) return false;
|
|
84
|
-
|
|
85
|
-
|
|
134
|
+
if (typeof serverErrors !== "object") return false;
|
|
135
|
+
const keys = new Set(visibleFields.value.map((field) => field.key));
|
|
136
|
+
let matched = false;
|
|
137
|
+
for (const [key, message] of Object.entries(serverErrors)) {
|
|
138
|
+
if (!keys.has(key)) continue;
|
|
139
|
+
errors[key] = Array.isArray(message) ? message.join(" ") : String(message);
|
|
140
|
+
matched = true;
|
|
141
|
+
}
|
|
142
|
+
if (matched) scrollToFirstError();
|
|
143
|
+
return matched;
|
|
86
144
|
}
|
|
87
145
|
|
|
88
146
|
function reset() {
|
|
@@ -90,5 +148,39 @@ export function useProductCustomization(product, selectedVariant) {
|
|
|
90
148
|
Object.keys(errors).forEach((key) => delete errors[key]);
|
|
91
149
|
}
|
|
92
150
|
|
|
93
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
values,
|
|
153
|
+
errors,
|
|
154
|
+
visibleSets,
|
|
155
|
+
visibleFields,
|
|
156
|
+
hasCustomization,
|
|
157
|
+
hasRequired,
|
|
158
|
+
deltaPerUnit,
|
|
159
|
+
addons,
|
|
160
|
+
addonsPerUnit,
|
|
161
|
+
costOf,
|
|
162
|
+
validate,
|
|
163
|
+
payload,
|
|
164
|
+
applyServerErrors,
|
|
165
|
+
scrollToFirstError,
|
|
166
|
+
reset,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A product with a required customization field can't be added from a card:
|
|
172
|
+
* the answers are picked on its page. Lists usually don't carry
|
|
173
|
+
* `customization_sets`, so this only catches what's known up front; the
|
|
174
|
+
* backend's 400 { properties } catches the rest (isCustomizationError).
|
|
175
|
+
*/
|
|
176
|
+
export function needsPersonalization(product) {
|
|
177
|
+
return !!product?.customization_sets?.some((set) =>
|
|
178
|
+
(set.customization_fields || []).some((field) => field.required),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Whether an add-to-cart error is a customization rejection (400 { properties }). */
|
|
183
|
+
export function isCustomizationError(error) {
|
|
184
|
+
const data = error?.data ?? error?.response?._data ?? error?.response?.data;
|
|
185
|
+
return !!(data && typeof data === "object" && !Array.isArray(data) && data.properties);
|
|
94
186
|
}
|
|
@@ -337,6 +337,27 @@ export function createProductDetail({ product, settings = {}, loading = false }
|
|
|
337
337
|
|
|
338
338
|
const customization = useProductCustomization(p, exactMatchVariant);
|
|
339
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Price preview with customization: per-unit fees on the line, add-on items
|
|
342
|
+
* charged as their own lines. Only a preview — the cart total from the
|
|
343
|
+
* backend is what's charged. Null until something adds to the price.
|
|
344
|
+
*/
|
|
345
|
+
const customizationPreview = computed(() => {
|
|
346
|
+
const delta = customization.deltaPerUnit.value;
|
|
347
|
+
const addonUnit = customization.addonsPerUnit.value;
|
|
348
|
+
if (!(delta > 0) && !(addonUnit > 0)) return null;
|
|
349
|
+
const qty = Number(quantity.value) || 1;
|
|
350
|
+
const line = pricing.value.total?.after ?? (Number(priceRange.value?.min) || 0) * qty;
|
|
351
|
+
const total = round2(line + (delta + addonUnit) * qty);
|
|
352
|
+
return {
|
|
353
|
+
delta,
|
|
354
|
+
addons: addonUnit,
|
|
355
|
+
quantity: qty,
|
|
356
|
+
total,
|
|
357
|
+
text: { delta: formatMoney(delta), addons: formatMoney(addonUnit), total: formatMoney(total) },
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
|
|
340
361
|
function selectVariant(value) {
|
|
341
362
|
if (value?.isAvailable === false) return;
|
|
342
363
|
variants.selectVariant(value);
|
|
@@ -748,10 +769,8 @@ export function createProductDetail({ product, settings = {}, loading = false }
|
|
|
748
769
|
return false;
|
|
749
770
|
}
|
|
750
771
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
return false;
|
|
754
|
-
}
|
|
772
|
+
// Required / max length / number checks; scrolls to the first bad field.
|
|
773
|
+
if (!customization.validate()) return false;
|
|
755
774
|
|
|
756
775
|
isAddingToCart.value = true;
|
|
757
776
|
try {
|
|
@@ -767,10 +786,8 @@ export function createProductDetail({ product, settings = {}, loading = false }
|
|
|
767
786
|
|
|
768
787
|
return true;
|
|
769
788
|
} catch (error) {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
return false;
|
|
773
|
-
}
|
|
789
|
+
// 400 { properties: { key: message } }: shown under each field.
|
|
790
|
+
if (customization.applyServerErrors(error)) return false;
|
|
774
791
|
showApiError(error, "Couldn't add that to your cart.");
|
|
775
792
|
return false;
|
|
776
793
|
} finally {
|
|
@@ -888,6 +905,9 @@ export function createProductDetail({ product, settings = {}, loading = false }
|
|
|
888
905
|
|
|
889
906
|
function resetSelections() {
|
|
890
907
|
variants.reset();
|
|
908
|
+
// Field keys repeat across products (e.g. "engraving"), so answers never
|
|
909
|
+
// carry over to the next product.
|
|
910
|
+
customization.reset();
|
|
891
911
|
quantity.value = quantityStep.value;
|
|
892
912
|
showError.value = false;
|
|
893
913
|
}
|
|
@@ -930,6 +950,7 @@ export function createProductDetail({ product, settings = {}, loading = false }
|
|
|
930
950
|
|
|
931
951
|
// customization
|
|
932
952
|
customization,
|
|
953
|
+
customizationPreview,
|
|
933
954
|
|
|
934
955
|
// price
|
|
935
956
|
pricing,
|