@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.
@@ -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 { fetchCart } = useCart();
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
- // If product has multiple variants, redirect to detail page
42
- if (Array.isArray(product.product_variants) && product.product_variants.length > 1) {
43
- const categorySlug = product.categories?.[0]?.category?.slugified_name || 'products';
44
- router.push(`/${categorySlug}/${product.slugified_name}/`);
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
- await fetchCart();
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 = Object.entries(response.cart || {}).map(([id, item]) => ({
22
- id,
23
- ...item,
24
- }));
27
+ cartItems.value = cartLines(response);
25
28
  return response;
26
29
  }
27
30
 
28
- const isUpdating = (item) => {
29
- const id = item?.id ?? item;
30
- return updatingIds.value.includes(id);
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
- function markUpdating(id, busy) {
52
+ const isUpdating = (item) => updatingIds.value.includes(lineKey(item));
53
+
54
+ function markUpdating(key, busy) {
34
55
  updatingIds.value = busy
35
- ? [...updatingIds.value, id]
36
- : updatingIds.value.filter((existing) => existing !== id);
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
- * Add or remove quantity on one line. Per-line busy state, not a single
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 id = item?.id ?? item;
60
- if (!id || isUpdating(id)) return null;
86
+ const key = lineKey(item);
87
+ if (!item?.order_item_id || item.parent_item || isUpdating(key)) return null;
61
88
 
62
- const step = quantity ?? item?.quantity_step ?? 1;
63
- markUpdating(id, true);
89
+ markUpdating(key, true);
64
90
  try {
65
- return apply(await CartServices.EditCart(id, step, action));
91
+ return apply(await CartServices.editLine(item, action, quantity));
66
92
  } finally {
67
- markUpdating(id, false);
93
+ markUpdating(key, false);
68
94
  }
69
95
  }
70
96
 
71
- const increaseItem = (item) => editItem(item, "add");
72
- const decreaseItem = (item) => editItem(item, "remove");
73
- // Remove is a decrease by the full quantity — the API has no delete verb.
74
- const removeItem = (item) => editItem(item, "remove", item?.quantity);
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
- cartItems.value.reduce((total, item) => total + (item.quantity || 0), 0),
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
- editItem,
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
- const CartItems = computed(() => cartItems.value);
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
- CartItems.value.reduce((sum, item) => sum + Number(item.quantity || 0), 0),
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
- ...(isLoggedIn.value ? {} : { mobile_number_required: "Phone number" }),
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
- isLoggedIn.value ? "" : formErrors.value.mobile_number || "",
290
+ needsPhoneInput.value ? formErrors.value.mobile_number || "" : "",
273
291
  );
274
292
  const phoneHasError = computed(
275
293
  () =>
276
- !isLoggedIn.value &&
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(() => editItem(item, "add"), "Error adding to cart:");
317
+ mutateCart(() => increaseItem(item), "Error adding to cart:");
298
318
  const decreaseQuantity = (item) =>
299
- mutateCart(() => editItem(item, "remove"), "Error decreasing quantity:");
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 (!isLoggedIn.value) {
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
+ }