@osimart/create-storefront 1.0.17 → 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 +80 -10
- package/app/composables/useProductCustomization.js +186 -0
- package/app/composables/useProductDetail.js +39 -2
- 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/logic/components/ProductInfoBlock.js +3 -0
- package/app/logic/sections/FeaturedProductSection.js +1 -0
- package/app/logic/sections/MainProductSection.js +1 -0
- package/app/pages/checkout.vue +32 -12
- package/app/pages/login.vue +0 -2
- package/app/pages/order-detail.vue +25 -2
- package/app/pages/user-profile/components/Whishlist.vue +3 -3
- package/app/pages/user-profile/components/left-bar.vue +12 -12
- package/app/pages/user-profile/components/orders.vue +34 -13
- package/app/pages/user-profile/components/profile-details.vue +6 -6
- package/app/pages/user-profile/components/settings.vue +2 -2
- package/app/pages/user-profile/index.vue +57 -7
- package/app/services/CartServices.js +94 -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import checkoutServices from "~/services/CheckoutServices";
|
|
2
2
|
import utilsServices from "~/services/UtilsServices";
|
|
3
3
|
import paymentServices from "~/services/PaymentServices";
|
|
4
|
+
import UserServices from "~/services/UserServices";
|
|
4
5
|
import { authBus } from "~/services/AuthBus";
|
|
5
6
|
import { useToast } from "vue-toastification";
|
|
6
7
|
import { eventBus } from "~/utils/eventBus";
|
|
@@ -74,11 +75,14 @@ export function useCheckout() {
|
|
|
74
75
|
const {
|
|
75
76
|
cart: rawCart,
|
|
76
77
|
cartItems,
|
|
78
|
+
orderedItems,
|
|
79
|
+
topLevelItems,
|
|
77
80
|
cartTotal,
|
|
78
81
|
loading: cartLoading,
|
|
79
82
|
isUpdating,
|
|
80
83
|
fetchCart,
|
|
81
|
-
|
|
84
|
+
increaseItem,
|
|
85
|
+
decreaseItem,
|
|
82
86
|
removeItem,
|
|
83
87
|
} = useCart();
|
|
84
88
|
|
|
@@ -97,6 +101,11 @@ export function useCheckout() {
|
|
|
97
101
|
// --- user ---
|
|
98
102
|
const isLoggedIn = ref(false);
|
|
99
103
|
const needsUserInfo = ref(true);
|
|
104
|
+
// Phone saved on the customer's profile at load. Typing in the field must
|
|
105
|
+
// not flip this, so it's tracked apart from formData.mobile_number.
|
|
106
|
+
const profileHasPhone = ref(false);
|
|
107
|
+
// Guests, and logged-in customers with no phone on file, get PhoneNumInput.
|
|
108
|
+
const needsPhoneInput = computed(() => !isLoggedIn.value || !profileHasPhone.value);
|
|
100
109
|
const userAddresses = ref([]);
|
|
101
110
|
|
|
102
111
|
// --- reference data ---
|
|
@@ -156,14 +165,23 @@ export function useCheckout() {
|
|
|
156
165
|
// ================================================================
|
|
157
166
|
// COMPUTED
|
|
158
167
|
// ================================================================
|
|
159
|
-
// Template keeps its original names.
|
|
160
|
-
|
|
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);
|
|
161
173
|
const total_price = computed(() => cartTotal.value);
|
|
162
174
|
|
|
163
175
|
const totalItemsCount = computed(() =>
|
|
164
|
-
|
|
176
|
+
TopLevelItems.value.reduce((sum, item) => sum + Number(item.quantity || 0), 0),
|
|
165
177
|
);
|
|
166
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
|
+
|
|
167
185
|
const isLoading = computed(() => cartLoading.value || needsUserInfo.value);
|
|
168
186
|
|
|
169
187
|
const selectedAddress = computed(() =>
|
|
@@ -243,7 +261,7 @@ export function useCheckout() {
|
|
|
243
261
|
email_required: "Email",
|
|
244
262
|
first_name: "First name",
|
|
245
263
|
last_name: "Last name",
|
|
246
|
-
...(
|
|
264
|
+
...(needsPhoneInput.value ? { mobile_number_required: "Phone number" } : {}),
|
|
247
265
|
};
|
|
248
266
|
return Object.keys(labels)
|
|
249
267
|
.filter((key) => formErrors.value[key])
|
|
@@ -269,11 +287,11 @@ export function useCheckout() {
|
|
|
269
287
|
() => !!(formErrors.value.email || formErrors.value.email_required),
|
|
270
288
|
);
|
|
271
289
|
const phoneErrorMessage = computed(() =>
|
|
272
|
-
|
|
290
|
+
needsPhoneInput.value ? formErrors.value.mobile_number || "" : "",
|
|
273
291
|
);
|
|
274
292
|
const phoneHasError = computed(
|
|
275
293
|
() =>
|
|
276
|
-
|
|
294
|
+
needsPhoneInput.value &&
|
|
277
295
|
!!(formErrors.value.mobile_number || formErrors.value.mobile_number_required),
|
|
278
296
|
);
|
|
279
297
|
|
|
@@ -293,13 +311,26 @@ export function useCheckout() {
|
|
|
293
311
|
toast.error(extractErrorMessage(error));
|
|
294
312
|
}
|
|
295
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).
|
|
296
316
|
const increaseQuantity = (item) =>
|
|
297
|
-
mutateCart(() =>
|
|
317
|
+
mutateCart(() => increaseItem(item), "Error adding to cart:");
|
|
298
318
|
const decreaseQuantity = (item) =>
|
|
299
|
-
mutateCart(() =>
|
|
319
|
+
mutateCart(() => decreaseItem(item), "Error decreasing quantity:");
|
|
300
320
|
const deleteItem = (item) =>
|
|
301
321
|
mutateCart(() => removeItem(item), "Error removing from cart:");
|
|
302
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
|
+
|
|
303
334
|
// ================================================================
|
|
304
335
|
// REFERENCE DATA
|
|
305
336
|
// ================================================================
|
|
@@ -347,6 +378,7 @@ export function useCheckout() {
|
|
|
347
378
|
formData.last_name = info.last_name || "";
|
|
348
379
|
formData.email = info.email || "";
|
|
349
380
|
formData.mobile_number = info.mobile_number || "";
|
|
381
|
+
profileHasPhone.value = !!info.mobile_number;
|
|
350
382
|
}
|
|
351
383
|
}
|
|
352
384
|
|
|
@@ -581,7 +613,7 @@ export function useCheckout() {
|
|
|
581
613
|
}
|
|
582
614
|
if (!formData.payment_method_id)
|
|
583
615
|
errors.payment_method_id = "Payment method is required.";
|
|
584
|
-
if (
|
|
616
|
+
if (needsPhoneInput.value) {
|
|
585
617
|
if (!formData.mobile_number) {
|
|
586
618
|
errors.mobile_number_required = true;
|
|
587
619
|
} else if (!phoneNumberResults.value?.isValid) {
|
|
@@ -704,6 +736,27 @@ export function useCheckout() {
|
|
|
704
736
|
|
|
705
737
|
try {
|
|
706
738
|
isCheckingOut.value = true;
|
|
739
|
+
// Logged-in customer with no phone on file: save it to the profile
|
|
740
|
+
// first, so the order (and future checkouts) have it.
|
|
741
|
+
if (isLoggedIn.value && !profileHasPhone.value) {
|
|
742
|
+
try {
|
|
743
|
+
await UserServices.updateUserDetail({
|
|
744
|
+
first_name: formData.first_name,
|
|
745
|
+
last_name: formData.last_name,
|
|
746
|
+
email: formData.email,
|
|
747
|
+
mobile_number: formData.mobile_number,
|
|
748
|
+
});
|
|
749
|
+
profileHasPhone.value = true;
|
|
750
|
+
authBus.checkAuth();
|
|
751
|
+
} catch (error) {
|
|
752
|
+
const data = error?.data;
|
|
753
|
+
const msg =
|
|
754
|
+
(data && typeof data === "object" && Object.values(data).flat()[0]) ||
|
|
755
|
+
"Couldn't save your phone number. Please try again.";
|
|
756
|
+
toast.error(msg);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
707
760
|
const response = await checkoutServices.placeOrder(payload);
|
|
708
761
|
placedOrderId.value = response.order_id;
|
|
709
762
|
sessionStorage.setItem("orderId", response.order_id);
|
|
@@ -734,6 +787,13 @@ export function useCheckout() {
|
|
|
734
787
|
} catch (error) {
|
|
735
788
|
console.error("Error placing order:", error);
|
|
736
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
|
+
}
|
|
737
797
|
toast.error(extractErrorMessage(error));
|
|
738
798
|
} finally {
|
|
739
799
|
isCheckingOut.value = false;
|
|
@@ -831,6 +891,12 @@ export function useCheckout() {
|
|
|
831
891
|
refreshFreeDelivery();
|
|
832
892
|
});
|
|
833
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
|
+
|
|
834
900
|
watch(phoneNumberResults, (results) => {
|
|
835
901
|
if (formErrors.value.mobile_number && results?.isValid) {
|
|
836
902
|
clearFieldError("mobile_number");
|
|
@@ -866,15 +932,19 @@ export function useCheckout() {
|
|
|
866
932
|
|
|
867
933
|
// cart
|
|
868
934
|
CartItems,
|
|
935
|
+
TopLevelItems,
|
|
869
936
|
total_price,
|
|
870
937
|
totalItemsCount,
|
|
871
938
|
isItemUpdating,
|
|
872
939
|
increaseQuantity,
|
|
873
940
|
decreaseQuantity,
|
|
874
941
|
deleteItem,
|
|
942
|
+
cartError,
|
|
943
|
+
reviewCart,
|
|
875
944
|
|
|
876
945
|
// user + form
|
|
877
946
|
isLoggedIn,
|
|
947
|
+
needsPhoneInput,
|
|
878
948
|
formData,
|
|
879
949
|
formErrors,
|
|
880
950
|
clearFieldError,
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { computed, nextTick, reactive, watch } from "vue";
|
|
2
|
+
|
|
3
|
+
// 0 is a valid NUMBER answer, so only null / undefined / false / blank are empty.
|
|
4
|
+
const EMPTY = (value) =>
|
|
5
|
+
value === null || value === undefined || value === false ||
|
|
6
|
+
(typeof value === "string" && value.trim() === "");
|
|
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
|
+
*/
|
|
19
|
+
export function useProductCustomization(product, selectedVariant) {
|
|
20
|
+
const values = reactive({});
|
|
21
|
+
const errors = reactive({});
|
|
22
|
+
|
|
23
|
+
// A set is hidden for the selected variant when it's in excluded_variants.
|
|
24
|
+
// No variant picked yet: show every set.
|
|
25
|
+
const visibleSets = computed(() => {
|
|
26
|
+
const variantId = selectedVariant.value?.id;
|
|
27
|
+
return (product.value?.customization_sets || []).filter(
|
|
28
|
+
(set) => variantId == null || !(set.excluded_variants || []).some((id) => sameId(id, variantId)),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
const visibleFields = computed(() =>
|
|
32
|
+
visibleSets.value.flatMap((set) => set.customization_fields || []),
|
|
33
|
+
);
|
|
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.
|
|
44
|
+
watch(visibleFields, (fields) => {
|
|
45
|
+
const keys = new Set(fields.map((field) => field.key));
|
|
46
|
+
Object.keys(values).forEach((key) => { if (!keys.has(key)) delete values[key]; });
|
|
47
|
+
Object.keys(errors).forEach((key) => { if (!keys.has(key)) delete errors[key]; });
|
|
48
|
+
}, { immediate: true });
|
|
49
|
+
|
|
50
|
+
function optionFor(field) {
|
|
51
|
+
return field.options?.find((option) => sameId(option.id, values[field.key])) || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a filled field costs: { delta } on the line, or { addon } as its own line. */
|
|
55
|
+
function costOf(field) {
|
|
56
|
+
if (EMPTY(values[field.key])) return null;
|
|
57
|
+
if (field.type === "SELECT") {
|
|
58
|
+
const option = optionFor(field);
|
|
59
|
+
if (!option) return null;
|
|
60
|
+
// option.price_delta null means "use the field's price", not 0.
|
|
61
|
+
return option.addon_variant
|
|
62
|
+
? { addon: option.addon_variant }
|
|
63
|
+
: { delta: Number(option.price_delta ?? field.price_delta ?? 0) || 0 };
|
|
64
|
+
}
|
|
65
|
+
return field.addon_variant
|
|
66
|
+
? { addon: field.addon_variant }
|
|
67
|
+
: { delta: Number(field.price_delta) || 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const deltaPerUnit = computed(() => visibleFields.value.reduce(
|
|
71
|
+
(sum, field) => sum + (costOf(field)?.delta || 0), 0,
|
|
72
|
+
));
|
|
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
|
+
}
|
|
91
|
+
|
|
92
|
+
function validate() {
|
|
93
|
+
Object.keys(errors).forEach((key) => delete errors[key]);
|
|
94
|
+
for (const field of visibleFields.value) {
|
|
95
|
+
const value = values[field.key];
|
|
96
|
+
if (EMPTY(value)) {
|
|
97
|
+
if (field.required) errors[field.key] = `${field.label} is required.`;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (field.type === "NUMBER" && Number.isNaN(Number(value))) {
|
|
101
|
+
errors[field.key] = `${field.label} must be a number.`;
|
|
102
|
+
} else if (["TEXT", "TEXTAREA"].includes(field.type) && field.max_length && String(value).trim().length > field.max_length) {
|
|
103
|
+
errors[field.key] = `${field.label} must be at most ${field.max_length} characters.`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const ok = Object.keys(errors).length === 0;
|
|
107
|
+
if (!ok) scrollToFirstError();
|
|
108
|
+
return ok;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Add-to-cart `properties`: filled, visible fields only, keyed by field key. */
|
|
112
|
+
const payload = computed(() => {
|
|
113
|
+
const result = {};
|
|
114
|
+
for (const field of visibleFields.value) {
|
|
115
|
+
const value = values[field.key];
|
|
116
|
+
if (EMPTY(value)) continue;
|
|
117
|
+
if (field.type === "CHECKBOX") result[field.key] = true;
|
|
118
|
+
else if (field.type === "NUMBER") result[field.key] = Number(value);
|
|
119
|
+
else if (field.type === "SELECT") result[field.key] = value; // the option id, not its text
|
|
120
|
+
else result[field.key] = String(value).trim();
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
});
|
|
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
|
+
*/
|
|
130
|
+
function applyServerErrors(error) {
|
|
131
|
+
const data = error?.data ?? error?.response?._data ?? error?.response?.data;
|
|
132
|
+
const serverErrors = data?.properties;
|
|
133
|
+
if (!serverErrors) return false;
|
|
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;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function reset() {
|
|
147
|
+
Object.keys(values).forEach((key) => delete values[key]);
|
|
148
|
+
Object.keys(errors).forEach((key) => delete errors[key]);
|
|
149
|
+
}
|
|
150
|
+
|
|
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);
|
|
186
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ProductServices from "#osi/services/ProductServices";
|
|
2
2
|
import CartServices from "#osi/services/CartServices";
|
|
3
|
+
import { useProductCustomization } from "#osi/composables/useProductCustomization";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Product detail state — the whole of Terazar/Maqui's product page logic, lifted
|
|
@@ -334,6 +335,29 @@ export function createProductDetail({ product, settings = {}, loading = false }
|
|
|
334
335
|
return { gallery: [...unique].sort((a, b) => order(a) - order(b)) };
|
|
335
336
|
});
|
|
336
337
|
|
|
338
|
+
const customization = useProductCustomization(p, exactMatchVariant);
|
|
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
|
+
|
|
337
361
|
function selectVariant(value) {
|
|
338
362
|
if (value?.isAvailable === false) return;
|
|
339
363
|
variants.selectVariant(value);
|
|
@@ -745,19 +769,25 @@ export function createProductDetail({ product, settings = {}, loading = false }
|
|
|
745
769
|
return false;
|
|
746
770
|
}
|
|
747
771
|
|
|
772
|
+
// Required / max length / number checks; scrolls to the first bad field.
|
|
773
|
+
if (!customization.validate()) return false;
|
|
774
|
+
|
|
748
775
|
isAddingToCart.value = true;
|
|
749
776
|
try {
|
|
750
777
|
await CartServices.EditCart(exactMatchVariant.value.id, quantity.value, "add", {
|
|
751
778
|
product: p.value,
|
|
752
779
|
variant: exactMatchVariant.value,
|
|
753
|
-
});
|
|
780
|
+
}, customization.payload.value);
|
|
754
781
|
await fetchCart();
|
|
782
|
+
customization.reset();
|
|
755
783
|
|
|
756
784
|
if (buyNow) router.push("/checkout");
|
|
757
785
|
else openCartUi();
|
|
758
786
|
|
|
759
787
|
return true;
|
|
760
788
|
} catch (error) {
|
|
789
|
+
// 400 { properties: { key: message } }: shown under each field.
|
|
790
|
+
if (customization.applyServerErrors(error)) return false;
|
|
761
791
|
showApiError(error, "Couldn't add that to your cart.");
|
|
762
792
|
return false;
|
|
763
793
|
} finally {
|
|
@@ -875,6 +905,9 @@ export function createProductDetail({ product, settings = {}, loading = false }
|
|
|
875
905
|
|
|
876
906
|
function resetSelections() {
|
|
877
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();
|
|
878
911
|
quantity.value = quantityStep.value;
|
|
879
912
|
showError.value = false;
|
|
880
913
|
}
|
|
@@ -915,6 +948,10 @@ export function createProductDetail({ product, settings = {}, loading = false }
|
|
|
915
948
|
showError,
|
|
916
949
|
resetSelections,
|
|
917
950
|
|
|
951
|
+
// customization
|
|
952
|
+
customization,
|
|
953
|
+
customizationPreview,
|
|
954
|
+
|
|
918
955
|
// price
|
|
919
956
|
pricing,
|
|
920
957
|
priceRange,
|
|
@@ -978,4 +1015,4 @@ export function provideProductDetail(context) {
|
|
|
978
1015
|
*/
|
|
979
1016
|
export function useProductDetail() {
|
|
980
1017
|
return inject(PRODUCT_DETAIL_KEY, null);
|
|
981
|
-
}
|
|
1018
|
+
}
|