@osimart/create-storefront 1.0.20 → 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -115,6 +115,8 @@ const DEFAULTS = {
115
115
 
116
116
  // cart
117
117
  HEADER_CART_COUNT: "BADGE",
118
+ HEADER_CART_BADGE_BG: null,
119
+ HEADER_CART_BADGE_TEXT: null,
118
120
 
119
121
  // chrome
120
122
  HEADER_SHOW_ACCOUNT: true,
@@ -453,6 +455,8 @@ const DEFAULTS = {
453
455
  CART_DRAWER_EMPTY_BTN_STYLE: "THEME",
454
456
  CART_DRAWER_RECS_ADD_STYLE: "THEME",
455
457
  CART_DRAWER_QTY_SIZE: 30,
458
+ CART_DRAWER_SHOW_REMOVE: true,
459
+ CART_DRAWER_REMOVE_COLOR: null,
456
460
  CART_DRAWER_RECS_CARD_BACKGROUND: null,
457
461
  CART_DRAWER_RECS_PRICE_POSITION: "BELOW",
458
462
  CART_DRAWER_RECS_NAME_LINES: 2,
@@ -2,12 +2,14 @@ import CartServices from "#osi/services/CartServices";
2
2
  import { useToast } from "vue-toastification";
3
3
  import { useCart } from "./useCart";
4
4
  import { useSidebar } from "./useSidebar";
5
+ import { isCustomizationError, needsPersonalization } from "./useProductCustomization";
5
6
 
6
7
  export function useAddToCart() {
7
8
  const router = useRouter();
8
- const { 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
+ }
@@ -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
- editItem,
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
- 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);
167
173
  const total_price = computed(() => cartTotal.value);
168
174
 
169
175
  const totalItemsCount = computed(() =>
170
- CartItems.value.reduce((sum, item) => sum + Number(item.quantity || 0), 0),
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(() => editItem(item, "add"), "Error adding to cart:");
317
+ mutateCart(() => increaseItem(item), "Error adding to cart:");
304
318
  const decreaseQuantity = (item) =>
305
- mutateCart(() => editItem(item, "remove"), "Error decreasing quantity:");
319
+ mutateCart(() => decreaseItem(item), "Error decreasing quantity:");
306
320
  const deleteItem = (item) =>
307
321
  mutateCart(() => removeItem(item), "Error removing from cart:");
308
322
 
323
+ /** "Review cart" on the banner: bring the line items into view. */
324
+ function reviewCart() {
325
+ const desktop = typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches;
326
+ if (!desktop) mobileSummaryOpen.value = true;
327
+ nextTick(() => {
328
+ document
329
+ .querySelector(desktop ? "[data-checkout-lines='rail']" : "[data-checkout-lines='mobile']")
330
+ ?.scrollIntoView({ behavior: "smooth", block: "center" });
331
+ });
332
+ }
333
+
309
334
  // ================================================================
310
335
  // REFERENCE DATA
311
336
  // ================================================================
@@ -762,6 +787,13 @@ export function useCheckout() {
762
787
  } catch (error) {
763
788
  console.error("Error placing order:", error);
764
789
  if (handleOrderPromoError(error)) return;
790
+ const data = errorData(error);
791
+ if (data && typeof data === "object" && !Array.isArray(data) && data.cart) {
792
+ cartError.value = [].concat(data.cart).join(" ");
793
+ await nextTick();
794
+ if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" });
795
+ return;
796
+ }
765
797
  toast.error(extractErrorMessage(error));
766
798
  } finally {
767
799
  isCheckingOut.value = false;
@@ -859,6 +891,12 @@ export function useCheckout() {
859
891
  refreshFreeDelivery();
860
892
  });
861
893
 
894
+ // Any cart change (the shopper removed / re-added the line) clears the
895
+ // "options have changed" banner; the next attempt re-checks server-side.
896
+ watch(cartItems, () => {
897
+ cartError.value = "";
898
+ });
899
+
862
900
  watch(phoneNumberResults, (results) => {
863
901
  if (formErrors.value.mobile_number && results?.isValid) {
864
902
  clearFieldError("mobile_number");
@@ -894,12 +932,15 @@ export function useCheckout() {
894
932
 
895
933
  // cart
896
934
  CartItems,
935
+ TopLevelItems,
897
936
  total_price,
898
937
  totalItemsCount,
899
938
  isItemUpdating,
900
939
  increaseQuantity,
901
940
  decreaseQuantity,
902
941
  deleteItem,
942
+ cartError,
943
+ reviewCart,
903
944
 
904
945
  // user + form
905
946
  isLoggedIn,
@@ -438,9 +438,21 @@ export function useContentTree(section, options = {}) {
438
438
  if (bleed) {
439
439
  for (const [k, val] of Object.entries(paddingOf(v))) box[k.replace("--cb-p", "--card-p")] = val;
440
440
  }
441
+ // Quick buy's colour swatches follow the card's Swatches block.
442
+ const findSw = (list) => {
443
+ for (const c of list ?? []) {
444
+ if (c.type === "CARD_SWATCHES") return c;
445
+ const hit = findSw(c.children);
446
+ if (hit) return hit;
447
+ }
448
+ return null;
449
+ };
450
+ const swatchRadius = findSw(children)?.swRadius;
451
+ if (swatchRadius) kids = kids.map((c) => (c.type === "CARD_IMAGE" ? { ...c, swatchRadius } : c));
441
452
  return {
442
453
  ...base,
443
454
  ...flexOf(v),
455
+ swatchRadius,
444
456
  children: kids,
445
457
  sizeClasses: ["w-fill"],
446
458
  sizeVars: {},
@@ -579,9 +591,15 @@ export function useContentTree(section, options = {}) {
579
591
  box: boxOf(v, { background: false }),
580
592
  max: num(v.MAX, 5),
581
593
  square: upper(v.SHAPE, "CIRCLE") === "SQUARE",
594
+ // Own radius (0 = sharp) over the shape's; the card's quick buy uses it too.
595
+ swRadius: has(v.RADIUS) ? `${num(v.RADIUS, 0)}px` : upper(v.SHAPE, "CIRCLE") === "SQUARE" ? "2px" : undefined,
582
596
  // COLOR: the colour option only; ALL: every option (sizes as text).
583
597
  show: upper(v.SHOW, "ALL"),
584
- swatchVars: { "--cb-sw": `${num(v.SIZE, 16)}px`, "--cb-sw-gap": `${num(v.GAP, 6)}px` },
598
+ swatchVars: {
599
+ "--cb-sw": `${num(v.SIZE, 16)}px`,
600
+ "--cb-sw-gap": `${num(v.GAP, 6)}px`,
601
+ ...(has(v.RADIUS) ? { "--cb-sw-r": `${num(v.RADIUS, 0)}px` } : {}),
602
+ },
585
603
  };
586
604
 
587
605
  case "CARD_BUTTON":
@@ -753,8 +771,16 @@ export function useContentTree(section, options = {}) {
753
771
  return {
754
772
  ...base,
755
773
  ...sizeOf(v, "WIDTH", "WIDTH_MOBILE", "FILL"),
756
- box: boxOf(v, { background: false }),
774
+ // Background too: their Text-look settings carry BG_COLOR (Title,
775
+ // Description, Breadcrumbs…); blocks without it just leave it unset.
776
+ box: boxOf(v),
757
777
  ...(textDefault ? textLook(v, textDefault) : {}),
778
+ // Text parts always fill their row (no Width setting), so the text box
779
+ // — capped by MAX_WIDTH — would sit at the start even with the section
780
+ // centred. ContentNode lines it up instead: the block's own Alignment,
781
+ // else its group's (the inherited --cb-col-align).
782
+ textPart: !!textDefault,
783
+ selfVars: textDefault ? compact({ "--cb-self-align": COL_ALIGN[upper(v.ALIGNMENT, "")] }) : {},
758
784
  component: markRaw(component),
759
785
  };
760
786
  }
@@ -1,52 +1,93 @@
1
- import { computed, reactive, watch } from "vue";
1
+ import { computed, nextTick, reactive, watch } from "vue";
2
2
 
3
+ // 0 is a valid NUMBER answer, so only null / undefined / false / blank are empty.
3
4
  const EMPTY = (value) =>
4
5
  value === null || value === undefined || value === false ||
5
6
  (typeof value === "string" && value.trim() === "");
6
7
 
7
- /** Product customization answers, scoped to the product and variant shown. */
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) => !variantId || !(set.excluded_variants || []).includes(variantId),
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 === values[field.key]) || null;
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 + (Number(costOf(field)?.delta) || 0), 0,
71
+ (sum, field) => sum + (costOf(field)?.delta || 0), 0,
48
72
  ));
49
- const addons = computed(() => visibleFields.value.map(costOf).filter((cost) => cost?.addon).map((cost) => cost.addon));
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
- return Object.keys(errors).length === 0;
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 serverErrors = error?.data?.properties || error?.response?._data?.properties;
131
+ const data = error?.data ?? error?.response?._data ?? error?.response?.data;
132
+ const serverErrors = data?.properties;
83
133
  if (!serverErrors) return false;
84
- Object.assign(errors, serverErrors);
85
- return true;
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 { values, errors, visibleSets, visibleFields, deltaPerUnit, addons, costOf, validate, payload, applyServerErrors, reset };
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
- if (!customization.validate()) {
752
- nextTick(() => document.querySelector(".osi-pdp-customization .has-error")?.scrollIntoView({ behavior: "smooth", block: "center" }));
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
- if (customization.applyServerErrors(error)) {
771
- nextTick(() => document.querySelector(".osi-pdp-customization .has-error")?.scrollIntoView({ behavior: "smooth", block: "center" }));
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,