@osimart/create-storefront 1.0.20 → 1.0.21
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/useAddToCart.js +16 -6
- package/app/composables/useCart.js +66 -25
- package/app/composables/useCheckout.js +47 -6
- package/app/composables/useProductCustomization.js +107 -15
- package/app/composables/useProductDetail.js +29 -8
- package/app/composables/useQuickShop.js +20 -0
- package/app/logic/components/CartDrawer.js +11 -19
- package/app/logic/components/CartItem.js +18 -1
- 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
|
@@ -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,
|
|
@@ -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,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import CartServices from "#osi/services/CartServices";
|
|
2
|
+
import { isCustomizationError, needsPersonalization } from "./useProductCustomization";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Quick-shop: pick a variant and add to cart without leaving the grid.
|
|
@@ -26,6 +27,14 @@ export function useQuickShop({ product, variants, onAdded, closeOnAdd = true } =
|
|
|
26
27
|
|
|
27
28
|
const { fetchCart } = useCart();
|
|
28
29
|
const { showApiError } = useApiErrorToast();
|
|
30
|
+
const { productUrl } = useProductUrl();
|
|
31
|
+
const router = useRouter();
|
|
32
|
+
|
|
33
|
+
/** Required personalization: answered on the product page, not here. */
|
|
34
|
+
function goPersonalize() {
|
|
35
|
+
close();
|
|
36
|
+
router.push(productUrl(p.value));
|
|
37
|
+
}
|
|
29
38
|
|
|
30
39
|
// Mobile only: the sheet covers the page, the desktop overlay doesn't.
|
|
31
40
|
useBodyScrollLock(() => mode.value === "MOBILE");
|
|
@@ -61,6 +70,11 @@ export function useQuickShop({ product, variants, onAdded, closeOnAdd = true } =
|
|
|
61
70
|
return false;
|
|
62
71
|
}
|
|
63
72
|
|
|
73
|
+
if (needsPersonalization(p.value)) {
|
|
74
|
+
goPersonalize();
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
adding.value = true;
|
|
65
79
|
try {
|
|
66
80
|
await CartServices.EditCart(
|
|
@@ -74,6 +88,11 @@ export function useQuickShop({ product, variants, onAdded, closeOnAdd = true } =
|
|
|
74
88
|
if (closeOnAdd) close();
|
|
75
89
|
return true;
|
|
76
90
|
} catch (error) {
|
|
91
|
+
// 400 { properties }: the product needs personalizing first.
|
|
92
|
+
if (isCustomizationError(error)) {
|
|
93
|
+
goPersonalize();
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
77
96
|
// Stock and cart-limit errors come back as field errors, so they need the
|
|
78
97
|
// flattening — a bare error.message here would print "[object Object]".
|
|
79
98
|
flashError(showApiError(error, "Couldn't add that to your cart."));
|
|
@@ -111,5 +130,6 @@ export function useQuickShop({ product, variants, onAdded, closeOnAdd = true } =
|
|
|
111
130
|
addVariant,
|
|
112
131
|
addSelected,
|
|
113
132
|
flashError,
|
|
133
|
+
goPersonalize,
|
|
114
134
|
};
|
|
115
135
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import CartServices from "#osi/services/CartServices";
|
|
2
1
|
import ProductServices from "#osi/services/ProductServices";
|
|
3
2
|
|
|
4
3
|
export default function useLogic() {
|
|
@@ -33,7 +32,7 @@ export default function useLogic() {
|
|
|
33
32
|
const txt = (key) => String(t(settings.value[key]) ?? "").trim() || TEXT_DEFAULTS[key] || "";
|
|
34
33
|
const scheme = useColorScheme(computed(() => settings.value.CART_DRAWER_COLOR_SCHEME || null));
|
|
35
34
|
const { isCartSidebarVisible, toggleCartSidebar } = useSidebar();
|
|
36
|
-
const { cartItems, cartTotal, count, fetchCart } = useCart();
|
|
35
|
+
const { cartItems, topLevelItems, childrenOf, cartTotal, count, fetchCart, isUpdating, increaseItem, decreaseItem, removeItem } = useCart();
|
|
37
36
|
const { showApiError } = useApiErrorToast();
|
|
38
37
|
const { mediaUrl } = useMediaUrl();
|
|
39
38
|
const { productUrl } = useProductUrl();
|
|
@@ -124,27 +123,20 @@ export default function useLogic() {
|
|
|
124
123
|
|
|
125
124
|
/* ---------------------------------------------------------------- lines */
|
|
126
125
|
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
async function edit(item, action) {
|
|
133
|
-
if (isUpdating(item)) return;
|
|
134
|
-
updatingIds.value = new Set(updatingIds.value).add(item.id);
|
|
126
|
+
// Edits target one line by its order_item_id (one variant can have several
|
|
127
|
+
// lines with different customizations). Busy state is per line, in useCart,
|
|
128
|
+
// so the checkout page and header badge move with the drawer. Each edit
|
|
129
|
+
// returns the whole cart, so there's no follow-up fetch.
|
|
130
|
+
async function edit(fn, item) {
|
|
135
131
|
try {
|
|
136
|
-
await
|
|
137
|
-
await fetchCart();
|
|
132
|
+
await fn(item);
|
|
138
133
|
} catch (error) {
|
|
139
134
|
showApiError(error, "Couldn't update the cart.");
|
|
140
|
-
} finally {
|
|
141
|
-
const next = new Set(updatingIds.value);
|
|
142
|
-
next.delete(item.id);
|
|
143
|
-
updatingIds.value = next;
|
|
144
135
|
}
|
|
145
136
|
}
|
|
146
|
-
const increase = (item) => edit(
|
|
147
|
-
const decrease = (item) => edit(
|
|
137
|
+
const increase = (item) => edit(increaseItem, item);
|
|
138
|
+
const decrease = (item) => edit(decreaseItem, item);
|
|
139
|
+
const remove = (item) => edit(removeItem, item);
|
|
148
140
|
|
|
149
141
|
function close() {
|
|
150
142
|
toggleCartSidebar(false);
|
|
@@ -269,5 +261,5 @@ export default function useLogic() {
|
|
|
269
261
|
// rely on a page's onMounted to load the cart.
|
|
270
262
|
onMounted(fetchCart);
|
|
271
263
|
|
|
272
|
-
return { settings, t, TEXT_DEFAULTS, txt, scheme, isCartSidebarVisible, toggleCartSidebar, cartItems, cartTotal, count, fetchCart, showApiError, mediaUrl, productUrl, handleAddToCart, store, currency, money, px, has, VARIANTS, buttonVars, button, checkoutBtn, emptyBtn, addBtn, cardStyle, RATIOS, rootStyle,
|
|
264
|
+
return { settings, t, TEXT_DEFAULTS, txt, scheme, isCartSidebarVisible, toggleCartSidebar, cartItems, topLevelItems, childrenOf, cartTotal, count, fetchCart, showApiError, mediaUrl, productUrl, handleAddToCart, store, currency, money, px, has, VARIANTS, buttonVars, button, checkoutBtn, emptyBtn, addBtn, cardStyle, RATIOS, rootStyle, isUpdating, edit, increase, decrease, remove, close, go, threshold, shipping, recs, recsLoading, recsKey, pickedIds, limit, loadRecs, inCart, shownRecs, recsLayout, pricePos, adding, addRec, priceOf, analytics };
|
|
273
265
|
}
|
|
@@ -27,5 +27,22 @@ export default function useLogic({ props, emit }) {
|
|
|
27
27
|
emit("add-item", props.item);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
function handleDelete() {
|
|
31
|
+
if (props.loading) return;
|
|
32
|
+
emit("delete-item", props.item);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Customization answers, as "Label: value" (a ticked checkbox reads "Yes"). */
|
|
36
|
+
const properties = computed(() =>
|
|
37
|
+
(Array.isArray(props.item?.properties) ? props.item.properties : []).map((p) => ({
|
|
38
|
+
key: p.key,
|
|
39
|
+
label: p.label,
|
|
40
|
+
value: p.value === true ? "Yes" : p.value,
|
|
41
|
+
}))
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Add-on lines follow their product's quantity and go with it.
|
|
45
|
+
const isAddon = computed(() => !!props.item?.parent_item);
|
|
46
|
+
|
|
47
|
+
return { mediaUrl, store, currency, imageSrc, variantLabel, properties, isAddon, handleRemove, handleAdd, handleDelete };
|
|
31
48
|
}
|
package/app/pages/checkout.vue
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="bg-gray-50 font-body text-primaryColor">
|
|
3
3
|
|
|
4
|
+
<!-- Customization changed since the item was added: placeOrder said so. -->
|
|
5
|
+
<div v-if="cartError" class="mx-auto max-w-6xl px-4 pt-4 lg:px-9" role="alert">
|
|
6
|
+
<div class="flex items-center justify-between gap-4 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
|
7
|
+
<span>{{ cartError }}</span>
|
|
8
|
+
<button type="button" class="shrink-0 underline" @click="reviewCart">Review cart</button>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
4
12
|
<!-- ============ MOBILE ORDER STRIP ============ -->
|
|
5
13
|
<div class="border-b border-gray-200 bg-white lg:hidden">
|
|
6
14
|
<button
|
|
@@ -14,17 +22,17 @@
|
|
|
14
22
|
<template v-else>
|
|
15
23
|
<div class="flex flex-shrink-0 gap-1.5">
|
|
16
24
|
<div
|
|
17
|
-
v-for="item in
|
|
18
|
-
:key="`strip-${item.
|
|
25
|
+
v-for="item in TopLevelItems.slice(0, 2)"
|
|
26
|
+
:key="`strip-${item.order_item_id}`"
|
|
19
27
|
class="h-11 w-9 overflow-hidden rounded-md border border-gray-200 bg-white"
|
|
20
28
|
>
|
|
21
29
|
<img :src="imageUrl(item.image)" :alt="item.name" class="h-full w-full object-cover" />
|
|
22
30
|
</div>
|
|
23
31
|
<div
|
|
24
|
-
v-if="
|
|
32
|
+
v-if="TopLevelItems.length > 2"
|
|
25
33
|
class="flex h-11 w-9 items-center justify-center rounded-md border border-dashed border-gray-300 text-xs text-gray-500"
|
|
26
34
|
>
|
|
27
|
-
+{{
|
|
35
|
+
+{{ TopLevelItems.length - 2 }}
|
|
28
36
|
</div>
|
|
29
37
|
</div>
|
|
30
38
|
<div class="min-w-0 flex-1">
|
|
@@ -53,7 +61,9 @@
|
|
|
53
61
|
<!-- line items with steppers -->
|
|
54
62
|
<div
|
|
55
63
|
v-for="item in CartItems"
|
|
56
|
-
:key="`strip-item-${item.
|
|
64
|
+
:key="`strip-item-${item.order_item_id}`"
|
|
65
|
+
:class="item.parent_item ? 'ms-5 opacity-80' : ''"
|
|
66
|
+
:data-checkout-lines="item === CartItems[0] ? 'mobile' : undefined"
|
|
57
67
|
class="group flex items-center gap-3 py-2"
|
|
58
68
|
>
|
|
59
69
|
<div class="h-14 w-12 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 bg-white">
|
|
@@ -62,7 +72,8 @@
|
|
|
62
72
|
<div class="min-w-0 flex-1">
|
|
63
73
|
<p class="truncate text-[13px]">{{ item.name }}</p>
|
|
64
74
|
<p v-if="Array.isArray(item.values) ? item.values.length : item.values" class="truncate text-xs text-gray-400">{{ Array.isArray(item.values) && item.values.length ? item.values.join(' | ') : item.values || '' }}</p>
|
|
65
|
-
<
|
|
75
|
+
<p v-for="prop in item.properties || []" :key="prop.key" class="text-xs text-gray-500">{{ prop.label }}: {{ prop.value === true ? 'Yes' : prop.value }}</p>
|
|
76
|
+
<div v-if="!item.parent_item" class="mt-1.5 flex items-center gap-3">
|
|
66
77
|
<div class="flex h-7 w-fit items-center rounded-full border border-gray-300">
|
|
67
78
|
<button
|
|
68
79
|
type="button"
|
|
@@ -572,7 +583,9 @@
|
|
|
572
583
|
|
|
573
584
|
<div
|
|
574
585
|
v-for="item in CartItems"
|
|
575
|
-
:key="`rail-${item.
|
|
586
|
+
:key="`rail-${item.order_item_id}`"
|
|
587
|
+
:class="item.parent_item ? 'ms-5 opacity-80' : ''"
|
|
588
|
+
:data-checkout-lines="item === CartItems[0] ? 'rail' : undefined"
|
|
576
589
|
class="group flex items-center gap-3 py-2"
|
|
577
590
|
>
|
|
578
591
|
<div class="h-14 w-12 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 bg-white">
|
|
@@ -581,7 +594,8 @@
|
|
|
581
594
|
<div class="min-w-0 flex-1">
|
|
582
595
|
<p class="truncate text-[13px]">{{ item.name }}</p>
|
|
583
596
|
<p v-if="Array.isArray(item.values) ? item.values.length : item.values" class="truncate text-xs text-gray-400">{{ Array.isArray(item.values) && item.values.length ? item.values.join(' | ') : item.values || '' }}</p>
|
|
584
|
-
<
|
|
597
|
+
<p v-for="prop in item.properties || []" :key="prop.key" class="text-xs text-gray-500">{{ prop.label }}: {{ prop.value === true ? 'Yes' : prop.value }}</p>
|
|
598
|
+
<div v-if="!item.parent_item" class="mt-1.5 flex items-center gap-3">
|
|
585
599
|
<div class="flex h-7 w-fit items-center rounded-full border border-gray-300">
|
|
586
600
|
<button
|
|
587
601
|
type="button"
|
|
@@ -774,7 +788,8 @@
|
|
|
774
788
|
<!-- line items -->
|
|
775
789
|
<div
|
|
776
790
|
v-for="item in CartItems"
|
|
777
|
-
:key="`bottom-${item.
|
|
791
|
+
:key="`bottom-${item.order_item_id}`"
|
|
792
|
+
:class="item.parent_item ? 'ms-5 opacity-80' : ''"
|
|
778
793
|
class="group flex items-center gap-3 py-2"
|
|
779
794
|
>
|
|
780
795
|
<div class="h-14 w-12 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 bg-white">
|
|
@@ -783,7 +798,8 @@
|
|
|
783
798
|
<div class="min-w-0 flex-1">
|
|
784
799
|
<p class="truncate text-[13px]">{{ item.name }}</p>
|
|
785
800
|
<p v-if="Array.isArray(item.values) ? item.values.length : item.values" class="truncate text-xs text-gray-400">{{ Array.isArray(item.values) && item.values.length ? item.values.join(' | ') : item.values || '' }}</p>
|
|
786
|
-
|
|
801
|
+
<p v-for="prop in item.properties || []" :key="prop.key" class="text-xs text-gray-500">{{ prop.label }}: {{ prop.value === true ? 'Yes' : prop.value }}</p>
|
|
802
|
+
<div v-if="!item.parent_item" class="mt-1.5 flex items-center gap-3">
|
|
787
803
|
<div class="flex h-7 w-fit items-center rounded-full border border-gray-300">
|
|
788
804
|
<button
|
|
789
805
|
type="button"
|
|
@@ -977,12 +993,15 @@ const {
|
|
|
977
993
|
accordionAfterEnter,
|
|
978
994
|
// cart
|
|
979
995
|
CartItems,
|
|
996
|
+
TopLevelItems,
|
|
980
997
|
total_price,
|
|
981
998
|
totalItemsCount,
|
|
982
999
|
isItemUpdating,
|
|
983
1000
|
increaseQuantity,
|
|
984
1001
|
decreaseQuantity,
|
|
985
1002
|
deleteItem,
|
|
1003
|
+
cartError,
|
|
1004
|
+
reviewCart,
|
|
986
1005
|
// user + form
|
|
987
1006
|
isLoggedIn,
|
|
988
1007
|
needsPhoneInput,
|
|
@@ -209,8 +209,9 @@
|
|
|
209
209
|
<div class="flex flex-col gap-4 mb-6">
|
|
210
210
|
<div
|
|
211
211
|
v-for="(item, index) in cartItemsWithLink"
|
|
212
|
-
:key="index"
|
|
212
|
+
:key="item.id ?? index"
|
|
213
213
|
class="flex gap-4 items-start"
|
|
214
|
+
:class="item.parent_item ? 'ms-6 opacity-80' : ''"
|
|
214
215
|
>
|
|
215
216
|
<component
|
|
216
217
|
:is="item.productLink ? 'RouterLink' : 'div'"
|
|
@@ -242,6 +243,14 @@
|
|
|
242
243
|
<p v-if="item.variant_values" class="text-gray-500 text-xs mt-1">
|
|
243
244
|
{{ item.variant_values }}
|
|
244
245
|
</p>
|
|
246
|
+
<!-- Customization snapshot taken at checkout. -->
|
|
247
|
+
<p
|
|
248
|
+
v-for="prop in item.properties || []"
|
|
249
|
+
:key="prop.key"
|
|
250
|
+
class="text-gray-500 text-xs mt-1"
|
|
251
|
+
>
|
|
252
|
+
{{ prop.label }}: {{ prop.value === true ? "Yes" : prop.value }}
|
|
253
|
+
</p>
|
|
245
254
|
</div>
|
|
246
255
|
<p class="text-gray-800 font-medium text-sm whitespace-nowrap">
|
|
247
256
|
{{ currency }}{{ item.checkout_price }}
|
|
@@ -387,8 +396,22 @@ const sortedStatuses = computed(() =>
|
|
|
387
396
|
|
|
388
397
|
const currentStatus = computed(() => sortedStatuses.value[0]?.status_name || "");
|
|
389
398
|
|
|
399
|
+
// Each product followed by its add-on lines (parent_item = the parent's id).
|
|
400
|
+
const orderedItems = computed(() => {
|
|
401
|
+
const items = cartItems.value;
|
|
402
|
+
const parents = items.filter((item) => !item.parent_item);
|
|
403
|
+
const ids = new Set(parents.map((item) => String(item.id)));
|
|
404
|
+
return [
|
|
405
|
+
...parents.flatMap((parent) => [
|
|
406
|
+
parent,
|
|
407
|
+
...items.filter((child) => child.parent_item && String(child.parent_item) === String(parent.id)),
|
|
408
|
+
]),
|
|
409
|
+
...items.filter((item) => item.parent_item && !ids.has(String(item.parent_item))),
|
|
410
|
+
];
|
|
411
|
+
});
|
|
412
|
+
|
|
390
413
|
const cartItemsWithLink = computed(() =>
|
|
391
|
-
|
|
414
|
+
orderedItems.value.map((item) => {
|
|
392
415
|
const product = item?.product?.product;
|
|
393
416
|
const categorySlug = product?.categories?.[0]?.category?.slugified_name;
|
|
394
417
|
const productSlug = product?.slugified_name;
|
|
@@ -28,14 +28,14 @@
|
|
|
28
28
|
</div>
|
|
29
29
|
<div class="pb-5">
|
|
30
30
|
<p class="osi-acc-muted font-bold">
|
|
31
|
-
{{ currency }}{{ order
|
|
31
|
+
{{ currency }}{{ orderTotal(order) }}
|
|
32
32
|
</p>
|
|
33
33
|
</div>
|
|
34
34
|
</div>
|
|
35
35
|
<div class="w-full max-w-[1800px] grid grid-cols-2 sm:grid-cols-4">
|
|
36
36
|
<div
|
|
37
37
|
v-for="(item, index) in getCartItemsWithLink(order?.items || [])"
|
|
38
|
-
:key="index"
|
|
38
|
+
:key="item.id ?? index"
|
|
39
39
|
class="transition-all w-full sm:w-[200px] duration-300"
|
|
40
40
|
>
|
|
41
41
|
<component
|
|
@@ -65,6 +65,17 @@
|
|
|
65
65
|
>
|
|
66
66
|
{{ item.variant_values }}
|
|
67
67
|
</p>
|
|
68
|
+
<!-- Add-on line (gift wrap…) bought with another product. -->
|
|
69
|
+
<p v-if="item.parent_item" class="osi-acc-muted text-xs">
|
|
70
|
+
Add-on{{ parentName(order, item) ? ` · ${parentName(order, item)}` : "" }}
|
|
71
|
+
</p>
|
|
72
|
+
<p
|
|
73
|
+
v-for="prop in item.properties || []"
|
|
74
|
+
:key="prop.key"
|
|
75
|
+
class="osi-acc-muted text-xs"
|
|
76
|
+
>
|
|
77
|
+
{{ prop.label }}: {{ prop.value === true ? "Yes" : prop.value }}
|
|
78
|
+
</p>
|
|
68
79
|
</div>
|
|
69
80
|
</div>
|
|
70
81
|
</div>
|
|
@@ -146,6 +157,16 @@ const getLatestStatus = (order) => {
|
|
|
146
157
|
return latest.status_name;
|
|
147
158
|
};
|
|
148
159
|
|
|
160
|
+
// The server's own total (promo, fees and customization included). The old
|
|
161
|
+
// sum of two fields concatenated when the API returned them as strings.
|
|
162
|
+
const orderTotal = (order) =>
|
|
163
|
+
order?.checkout_order_total ??
|
|
164
|
+
(Number(order?.checkout_cart_price) || 0) + (Number(order?.checkout_delivery_fees) || 0);
|
|
165
|
+
|
|
166
|
+
/** The product an add-on line was bought with. */
|
|
167
|
+
const parentName = (order, item) =>
|
|
168
|
+
(order?.items || []).find((line) => String(line.id) === String(item.parent_item))?.name || "";
|
|
169
|
+
|
|
149
170
|
const getCartItemsWithLink = (items = []) => {
|
|
150
171
|
return items.map((item) => {
|
|
151
172
|
const product = item?.product?.product;
|
|
@@ -17,39 +17,112 @@ const persistCartToken = (responseData) => {
|
|
|
17
17
|
};
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* A cart response's lines as a list. Lines are keyed by their line id
|
|
21
|
+
* (order_item_id); one variant can sit on several lines (different
|
|
22
|
+
* customizations, or as an add-on of another product), so nothing may look a
|
|
23
|
+
* line up by variant id.
|
|
24
|
+
*/
|
|
25
|
+
export function cartLines(response) {
|
|
26
|
+
return Object.entries(response?.cart || {}).map(([key, line]) => ({
|
|
27
|
+
...line,
|
|
28
|
+
order_item_id: line?.order_item_id ?? key,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A variant's own top-level lines summed: what "this product in the cart" means now. */
|
|
33
|
+
function variantSnapshot(lines, variantId) {
|
|
34
|
+
const own = lines.filter((l) => String(l.id) === String(variantId) && !l.parent_item);
|
|
35
|
+
if (!own.length) return null;
|
|
36
|
+
return { ...own[0], quantity: own.reduce((n, l) => n + (Number(l.quantity) || 0), 0) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function post(body) {
|
|
40
|
+
const baseUrl = getBaseUrl();
|
|
41
|
+
const responseData = await apiService(`${baseUrl}store/apis/cart/update-item/`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: cartHeaders(),
|
|
44
|
+
body: JSON.stringify(body),
|
|
45
|
+
});
|
|
46
|
+
return persistCartToken(responseData);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Add a variant to the cart (the only action that takes a variant id).
|
|
21
51
|
*
|
|
22
52
|
* `context` is optional: callers that hold the product (product page, card,
|
|
23
53
|
* quick buy) pass `{ product, variant }` so the analytics event carries its
|
|
24
|
-
* brand and category, which the cart payload doesn't repeat.
|
|
25
|
-
*
|
|
54
|
+
* brand and category, which the cart payload doesn't repeat.
|
|
55
|
+
*
|
|
56
|
+
* `properties` are the customization answers, keyed by field key
|
|
57
|
+
* ({ cover_name: "Lara", cover_color: "<option id>", gift_wrap: true }). The
|
|
58
|
+
* backend validates and prices them; a bad answer comes back as
|
|
59
|
+
* 400 { properties: { key: message } } and nothing is added. Same variant +
|
|
60
|
+
* same answers merges into the existing line, different answers make a new one.
|
|
26
61
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
62
|
+
* Signature kept from before (item_id, quantity, action, context, properties)
|
|
63
|
+
* so existing callers don't change. Any action other than "add" is forwarded
|
|
64
|
+
* to editLine with `item_id` treated as the line id.
|
|
29
65
|
*/
|
|
30
|
-
const EditCart = async (item_id, quantity = 1, action, context, properties = null) => {
|
|
66
|
+
const EditCart = async (item_id, quantity = 1, action = "add", context, properties = null) => {
|
|
67
|
+
if (action !== "add") return editLine({ order_item_id: item_id }, action, quantity);
|
|
31
68
|
try {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const body = { quantity, item_id, action };
|
|
69
|
+
const before = variantSnapshot(analytics.peekCartLines(), item_id);
|
|
70
|
+
const body = { item_id, quantity, action: "add" };
|
|
35
71
|
if (properties && Object.keys(properties).length) body.properties = properties;
|
|
36
|
-
const responseData = await
|
|
37
|
-
method: "POST",
|
|
38
|
-
headers: cartHeaders(),
|
|
39
|
-
body: JSON.stringify(body),
|
|
40
|
-
});
|
|
41
|
-
persistCartToken(responseData);
|
|
72
|
+
const responseData = await post(body);
|
|
42
73
|
analytics.cartEdited({
|
|
43
74
|
itemId: item_id,
|
|
75
|
+
variantId: item_id,
|
|
44
76
|
quantity,
|
|
45
|
-
action,
|
|
77
|
+
action: "add",
|
|
46
78
|
before,
|
|
47
|
-
after: responseData
|
|
79
|
+
after: variantSnapshot(cartLines(responseData), item_id),
|
|
48
80
|
context,
|
|
49
81
|
});
|
|
50
82
|
return responseData;
|
|
51
83
|
} catch (error) {
|
|
52
|
-
console.error('Cart
|
|
84
|
+
console.error('Cart update error:', error);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Change one exact cart line.
|
|
91
|
+
*
|
|
92
|
+
* edit set the line's quantity to `quantity`
|
|
93
|
+
* remove take `quantity` off the line (0 deletes it)
|
|
94
|
+
* remove_all delete the line
|
|
95
|
+
*
|
|
96
|
+
* Add-on lines (parent_item set) follow their parent and can't be edited on
|
|
97
|
+
* their own — the backend rejects it, so callers never offer it.
|
|
98
|
+
*/
|
|
99
|
+
const editLine = async (line, action, quantity) => {
|
|
100
|
+
const orderItemId = line?.order_item_id;
|
|
101
|
+
if (!orderItemId) throw new Error("Missing cart line id.");
|
|
102
|
+
try {
|
|
103
|
+
const before = analytics.peekCartLine(orderItemId);
|
|
104
|
+
const body = { order_item_id: orderItemId, action };
|
|
105
|
+
if (action !== "remove_all") body.quantity = quantity;
|
|
106
|
+
const responseData = await post(body);
|
|
107
|
+
const after = responseData?.cart?.[orderItemId]
|
|
108
|
+
?? cartLines(responseData).find((l) => String(l.order_item_id) === String(orderItemId))
|
|
109
|
+
?? null;
|
|
110
|
+
const prev = Number(before?.quantity ?? line?.quantity) || 0;
|
|
111
|
+
const next = Number(after?.quantity) || 0;
|
|
112
|
+
// Analytics speaks add/remove; "edit" is whichever way the quantity moved.
|
|
113
|
+
if (next !== prev) {
|
|
114
|
+
analytics.cartEdited({
|
|
115
|
+
itemId: orderItemId,
|
|
116
|
+
variantId: before?.id ?? line?.id,
|
|
117
|
+
quantity: Math.abs(next - prev),
|
|
118
|
+
action: next > prev ? "add" : "remove",
|
|
119
|
+
before: before ?? line,
|
|
120
|
+
after,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return responseData;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('Cart update error:', error);
|
|
53
126
|
throw error;
|
|
54
127
|
}
|
|
55
128
|
};
|
|
@@ -88,6 +161,7 @@ const mergeCart = async () => {
|
|
|
88
161
|
|
|
89
162
|
export default {
|
|
90
163
|
EditCart,
|
|
164
|
+
editLine,
|
|
91
165
|
fetchCart,
|
|
92
166
|
mergeCart,
|
|
93
167
|
};
|
package/app/utils/analytics.js
CHANGED
|
@@ -72,9 +72,9 @@ const state = {
|
|
|
72
72
|
pixelIds: [],
|
|
73
73
|
currency: "",
|
|
74
74
|
consent: true,
|
|
75
|
-
/** Last cart the server sent, keyed by line (
|
|
75
|
+
/** Last cart the server sent, keyed by line id (order_item_id). */
|
|
76
76
|
cart: {},
|
|
77
|
-
/** What we know about a
|
|
77
|
+
/** What we know about a variant beyond the cart payload: brand, category… (keyed by variant id) */
|
|
78
78
|
lineContext: new Map(),
|
|
79
79
|
/** Once-per-page guards (view_item, view_item_list). Cleared on navigation. */
|
|
80
80
|
seen: new Set(),
|
|
@@ -240,12 +240,14 @@ export function productToItem(product, opts = {}) {
|
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
/**
|
|
243
|
-
* A cart line
|
|
244
|
-
*
|
|
245
|
-
*
|
|
243
|
+
* A cart line -> item. `line.id` is the variant id (one variant can sit on
|
|
244
|
+
* several lines now, so lines are keyed by order_item_id); `id` is only the
|
|
245
|
+
* fallback. The payload doesn't repeat brand / category, so what the
|
|
246
|
+
* add-to-cart call site knew is remembered per variant and laid back over it.
|
|
246
247
|
*/
|
|
247
248
|
function cartLineToItem(id, line, quantity) {
|
|
248
|
-
const
|
|
249
|
+
const variantId = line?.id ?? id;
|
|
250
|
+
const known = state.lineContext.get(String(variantId)) ?? {};
|
|
249
251
|
const productId =
|
|
250
252
|
known.item_id ?? line?.product_id ?? line?.product?.id ?? line?.product?.product?.id ?? id;
|
|
251
253
|
return clean({
|
|
@@ -255,7 +257,7 @@ function cartLineToItem(id, line, quantity) {
|
|
|
255
257
|
item_category: known.item_category,
|
|
256
258
|
item_category2: known.item_category2,
|
|
257
259
|
item_variant: variantLabel(line) || known.item_variant,
|
|
258
|
-
variant_id: String(
|
|
260
|
+
variant_id: String(variantId),
|
|
259
261
|
sku: line?.sku ?? known.sku,
|
|
260
262
|
price: money(line?.price ?? known.price),
|
|
261
263
|
quantity: quantity ?? Math.max(1, num(line?.quantity)),
|
|
@@ -499,11 +501,16 @@ function selectItem(product, override = {}) {
|
|
|
499
501
|
log("select_item", ga);
|
|
500
502
|
}
|
|
501
503
|
|
|
502
|
-
/** What we last heard the cart contained for this line, before an edit. */
|
|
504
|
+
/** What we last heard the cart contained for this line (order_item_id), before an edit. */
|
|
503
505
|
function peekCartLine(id) {
|
|
504
506
|
return state.cart[String(id)] ?? null;
|
|
505
507
|
}
|
|
506
508
|
|
|
509
|
+
/** Every line we last heard about, as a list (to sum a variant across lines). */
|
|
510
|
+
function peekCartLines() {
|
|
511
|
+
return Object.entries(state.cart).map(([key, line]) => ({ ...line, order_item_id: line?.order_item_id ?? key }));
|
|
512
|
+
}
|
|
513
|
+
|
|
507
514
|
/** Remembers the server's cart. Called with every cart response. */
|
|
508
515
|
function rememberCart(response) {
|
|
509
516
|
if (!isClient() || !response || typeof response !== "object") return;
|
|
@@ -524,13 +531,16 @@ function resetCart() {
|
|
|
524
531
|
* through, from product pages, cards, quick buy, the drawer and checkout — so
|
|
525
532
|
* nothing is missed and nothing is counted twice.
|
|
526
533
|
*
|
|
527
|
-
*
|
|
534
|
+
* `variantId` keys what the call site knew (brand, category); `itemId` is the
|
|
535
|
+
* line or variant the edit targeted.
|
|
536
|
+
*
|
|
537
|
+
* @param {{ itemId: string|number, variantId?: string|number, quantity: number, action: "add"|"remove",
|
|
528
538
|
* before: object|null, after: object|null,
|
|
529
539
|
* context?: { product?: object, variant?: object } }} edit
|
|
530
540
|
*/
|
|
531
|
-
function cartEdited({ itemId, quantity, action, before, after, context }) {
|
|
541
|
+
function cartEdited({ itemId, variantId, quantity, action, before, after, context }) {
|
|
532
542
|
if (!isClient()) return;
|
|
533
|
-
const id = String(itemId);
|
|
543
|
+
const id = String(variantId ?? before?.id ?? after?.id ?? itemId);
|
|
534
544
|
|
|
535
545
|
// Remember what the call site knew, for later edits made from the drawer.
|
|
536
546
|
if (context?.product) {
|
|
@@ -832,6 +842,7 @@ const api = {
|
|
|
832
842
|
viewItemList,
|
|
833
843
|
selectItem,
|
|
834
844
|
peekCartLine,
|
|
845
|
+
peekCartLines,
|
|
835
846
|
rememberCart,
|
|
836
847
|
resetCart,
|
|
837
848
|
cartEdited,
|
package/package.json
CHANGED