@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.
@@ -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,
@@ -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,
@@ -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
- // Per-line, not a single global flag: one slow line shouldn't disable the
128
- // controls on every other line in the drawer.
129
- const updatingIds = ref(new Set());
130
- const isUpdating = (item) => updatingIds.value.has(item.id);
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 CartServices.EditCart(item.id, item.quantity_step || 1, action);
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(item, "add");
147
- const decrease = (item) => edit(item, "remove");
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, updatingIds, isUpdating, edit, increase, decrease, close, go, threshold, shipping, recs, recsLoading, recsKey, pickedIds, limit, loadRecs, inCart, shownRecs, recsLayout, pricePos, adding, addRec, priceOf, analytics };
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
- return { mediaUrl, store, currency, imageSrc, variantLabel, handleRemove, handleAdd };
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
  }
@@ -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 CartItems.slice(0, 2)"
18
- :key="`strip-${item.id}`"
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="CartItems.length > 2"
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
- +{{ CartItems.length - 2 }}
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.id}`"
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
- <div class="mt-1.5 flex items-center gap-3">
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.id}`"
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
- <div class="mt-1.5 flex items-center gap-3">
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.id}`"
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
- <div class="mt-1.5 flex items-center gap-3">
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
- cartItems.value.map((item) => {
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?.checkout_cart_price + order?.checkout_delivery_fees }}
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
- * Add to / remove from a cart line (a line is a variant id).
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. Cart-side callers
25
- * (drawer, checkout) omit it and get it from what was remembered at add time.
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
- * This is the single place add_to_cart / remove_from_cart are reported: every
28
- * cart edit in the storefront goes through here, once.
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 baseUrl = getBaseUrl();
33
- const before = analytics.peekCartLine(item_id);
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 apiService(`${baseUrl}store/apis/cart/update-item/`, {
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?.cart?.[item_id] ?? null,
79
+ after: variantSnapshot(cartLines(responseData), item_id),
48
80
  context,
49
81
  });
50
82
  return responseData;
51
83
  } catch (error) {
52
- console.error('Cart fetch error:', error);
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
  };
@@ -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 (variant) id. */
75
+ /** Last cart the server sent, keyed by line id (order_item_id). */
76
76
  cart: {},
77
- /** What we know about a line beyond the cart payload: brand, category… */
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 (the server's `cart[id]` entry, keyed by variant id) -> item.
244
- * The payload doesn't repeat brand / category, so what the add-to-cart call
245
- * site knew is remembered per line and laid back over it here.
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 known = state.lineContext.get(String(id)) ?? {};
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(id),
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
- * @param {{ itemId: string|number, quantity: number, action: "add"|"remove",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osimart/create-storefront",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "description": "Osimart storefront platform — logic, platform pages and CLI; extended by every theme repo",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",