@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.
@@ -1,3 +1,4 @@
1
+ import { isColorType } from "#osi/composables/useProductVariants";
1
2
  /**
2
3
  * PRODUCT_LIST's items — products in the one shape the card template reads.
3
4
  *
@@ -147,7 +148,8 @@ export function useProductItems(section, products, options = {}) {
147
148
  return Number(product.remaining_stock) <= 0;
148
149
  }
149
150
 
150
- const isColorName = (name) => ["color", "colour"].includes(String(name).toLowerCase());
151
+ // By the option's display_type (the name only for older rows).
152
+ const isColorOption = (type) => isColorType(type);
151
153
 
152
154
  /**
153
155
  * Every option (Color, Size, …) with the values that have a variant behind
@@ -163,10 +165,10 @@ export function useProductItems(section, products, options = {}) {
163
165
  const value = (variant.values ?? []).find((v) => v.type === type.id);
164
166
  if (!value || seen.has(value.id)) continue;
165
167
  seen.add(value.id);
166
- const color = isColorName(type.name) ? value.hex_code || "" : "";
168
+ const color = isColorOption(type) ? value.hex_code || "" : "";
167
169
  values.push({ key: value.id, name: value.name || value.value || "", color });
168
170
  }
169
- return { key: type.id, name: type.name, isColor: isColorName(type.name), values };
171
+ return { key: type.id, name: type.name, isColor: isColorOption(type), values };
170
172
  })
171
173
  .filter((option) => option.values.length);
172
174
  }
@@ -17,6 +17,37 @@
17
17
  * @param {object|Ref|(() => object)} product pass a getter — the card is
18
18
  * rendered in a v-for and the product prop changes identity on re-fetch.
19
19
  */
20
+ /**
21
+ * Is this option (a vary_by type / a filter) drawn as colour swatches?
22
+ * Its display_type decides ("COLOR" — case doesn't matter). Only a row from an
23
+ * older API with no display_type at all falls back to the name (Color /
24
+ * Colour), so a type named "Color" set to buttons shows buttons.
25
+ */
26
+ export function isColorType(type) {
27
+ const display = type?.display_type;
28
+ if (display !== undefined && display !== null && display !== "") {
29
+ return String(display).toLowerCase() === "color";
30
+ }
31
+ return ["color", "colour"].includes(String(type?.name ?? "").toLowerCase());
32
+ }
33
+
34
+ /**
35
+ * Sold out for a picked variant — the product page's rule: the variant's own
36
+ * stock decides once one is picked (one that doesn't track inventory is always
37
+ * available); before that, the product's status, then its stock.
38
+ */
39
+ export function isVariantSoldOut(product, variant = null) {
40
+ const out = (n) => n !== null && n !== undefined && Number(n) <= 0;
41
+ if (variant?.id) {
42
+ if (!variant.track_inventory) return false;
43
+ return out(variant.remaining_stock);
44
+ }
45
+ const status = product?.status;
46
+ if (status === "In Stock" || status === "On Demand") return false;
47
+ if (status === "Out of Stock") return true;
48
+ return out(product?.remaining_stock);
49
+ }
50
+
20
51
  export function useProductVariants(product) {
21
52
  const p = computed(() => toValue(product) ?? {});
22
53
 
@@ -32,7 +63,7 @@ export function useProductVariants(product) {
32
63
  );
33
64
 
34
65
  const colorType = computed(
35
- () => variantTypes.value.find((type) => type.name === "Color") ?? null
66
+ () => variantTypes.value.find((type) => isColorType(type)) ?? null
36
67
  );
37
68
 
38
69
  /** Distinct colour values that actually have a variant behind them. */
@@ -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();
@@ -115,6 +114,7 @@ export default function useLogic() {
115
114
  if (s.CART_DRAWER_RECS_CARD_BACKGROUND) style["--recs-card-bg"] = s.CART_DRAWER_RECS_CARD_BACKGROUND;
116
115
  if (s.CART_DRAWER_RECS_PRICE_COLOR) style["--recs-price-color"] = s.CART_DRAWER_RECS_PRICE_COLOR;
117
116
  if (s.CART_DRAWER_RECS_LINE_COLOR) style["--recs-line"] = s.CART_DRAWER_RECS_LINE_COLOR;
117
+ if (s.CART_DRAWER_REMOVE_COLOR) style["--cart-remove-color"] = s.CART_DRAWER_REMOVE_COLOR;
118
118
  // Quantity buttons (inside each cart line): picked up by .osi-qty-btn below.
119
119
  const q = buttonVars("CART_DRAWER_QTY");
120
120
  for (const [k, v] of Object.entries(q)) style[k.replace("--btn-", "--qty-")] = v;
@@ -124,27 +124,22 @@ export default function useLogic() {
124
124
 
125
125
  /* ---------------------------------------------------------------- lines */
126
126
 
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);
127
+ // Edits target one line by its order_item_id (one variant can have several
128
+ // lines with different customizations). Busy state is per line, in useCart,
129
+ // so the checkout page and header badge move with the drawer. Each edit
130
+ // returns the whole cart, so there's no follow-up fetch.
131
+ async function edit(fn, item) {
135
132
  try {
136
- await CartServices.EditCart(item.id, item.quantity_step || 1, action);
137
- await fetchCart();
133
+ await fn(item);
138
134
  } catch (error) {
139
135
  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
136
  }
145
137
  }
146
- const increase = (item) => edit(item, "add");
147
- const decrease = (item) => edit(item, "remove");
138
+ const increase = (item) => edit(increaseItem, item);
139
+ const decrease = (item) => edit(decreaseItem, item);
140
+ const remove = (item) => edit(removeItem, item);
141
+ // Unset (older themes) counts as shown.
142
+ const showRemove = computed(() => settings.value.CART_DRAWER_SHOW_REMOVE !== false);
148
143
 
149
144
  function close() {
150
145
  toggleCartSidebar(false);
@@ -269,5 +264,5 @@ export default function useLogic() {
269
264
  // rely on a page's onMounted to load the cart.
270
265
  onMounted(fetchCart);
271
266
 
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 };
267
+ 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, showRemove, close, go, threshold, shipping, recs, recsLoading, recsKey, pickedIds, limit, loadRecs, inCart, shownRecs, recsLayout, pricePos, adding, addRec, priceOf, analytics };
273
268
  }
@@ -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,3 +1,4 @@
1
+ import { isColorType } from "#osi/composables/useProductVariants";
1
2
  import { useProductDetail } from "#osi/composables/useProductDetail";
2
3
 
3
4
  export default function useLogic({ props }) {
@@ -30,7 +31,10 @@ export default function useLogic({ props }) {
30
31
  const pickerStyle = computed(() =>
31
32
  compact({
32
33
  "--sw-size": px(s.value.PICKER_SWATCH_SIZE) ?? "32px",
33
- "--sw-radius": { SQUARE: "2px", ROUNDED: "8px" }[s.value.PICKER_SWATCH_SHAPE] ?? "999px",
34
+ // Own radius first (0 = sharp), else the shape's.
35
+ "--sw-radius":
36
+ px(s.value.PICKER_SWATCH_RADIUS) ??
37
+ ({ SQUARE: "2px", ROUNDED: "8px" }[s.value.PICKER_SWATCH_SHAPE] ?? "999px"),
34
38
  "--sw-ring": s.value.PICKER_SWATCH_RING_COLOR,
35
39
  "--pk-gap": px(s.value.PICKER_GAP) ?? "8px",
36
40
  "--pk-option-gap": px(s.value.PICKER_OPTION_GAP) ?? "16px",
@@ -53,7 +57,8 @@ export default function useLogic({ props }) {
53
57
  })
54
58
  );
55
59
 
56
- const isColor = (type) => ["color", "colour"].includes(String(type?.name ?? "").toLowerCase());
60
+ // Swatches by the option's display_type (the name only for older rows).
61
+ const isColor = (type) => isColorType(type);
57
62
 
58
63
  /** How one option type is drawn. */
59
64
  function modeFor(type) {
@@ -16,6 +16,7 @@
16
16
  *
17
17
  * Every click inside stops at the panel: the card around it is a link.
18
18
  */
19
+ import { isColorType, isVariantSoldOut } from "#osi/composables/useProductVariants";
19
20
 
20
21
 
21
22
  export default function useLogic({ props, emit }) {
@@ -86,9 +87,13 @@ export default function useLogic({ props, emit }) {
86
87
 
87
88
  /* ------------------------------------------------------------------- details */
88
89
 
89
- const isColor = (type) => ["color", "colour"].includes(String(type.name).toLowerCase());
90
+ // Swatches by the option's display_type, like the product page.
91
+ const isColor = (type) => isColorType(type);
90
92
  const selected = computed(() => variants.selectedVariant.value);
91
- const soldOut = computed(() => variants.stockFor(selected.value));
93
+ // The product page's rule: a picked combination that's out of stock is sold
94
+ // out even when the product as a whole is "In Stock".
95
+ const soldOut = computed(() => isVariantSoldOut(props.product, selected.value));
96
+ const comingSoon = computed(() => !!props.product?.coming_soon);
92
97
  // The picked option has no price: shown as unavailable, can't be added.
93
98
  const noPrice = computed(() => !!selected.value && !(Number(selected.value.price) > 0));
94
99
  const currency = computed(
@@ -178,6 +183,7 @@ export default function useLogic({ props, emit }) {
178
183
  quickShop.flashError("That combination isn't available.");
179
184
  return;
180
185
  }
186
+ if (soldOut.value || comingSoon.value) return;
181
187
  await quickShop.addVariant(selected.value, qty.value);
182
188
  }
183
189
 
@@ -185,10 +191,12 @@ export default function useLogic({ props, emit }) {
185
191
  quickShop.errorMessage.value ||
186
192
  (quickShop.adding.value
187
193
  ? "Adding…"
188
- : soldOut.value
189
- ? "Sold out"
190
- : "Add to cart") // no price: same label, the button is disabled
194
+ : comingSoon.value
195
+ ? "Coming soon"
196
+ : soldOut.value
197
+ ? "Out of stock"
198
+ : "Add to cart") // no price: same label, the button is disabled
191
199
  );
192
200
 
193
- return { isMobile, cartType, store, toggleCartSidebar, mediaUrl, router, variants, quickShop, shown, locked, afterClose, inline, popup, close, onAfterLeave, onKey, closeBtn, onAfterEnter, isColor, selected, soldOut, noPrice, currency, money, price, pickedName, images, track, slide, onScroll, goTo, description, descEl, descLong, descOpen, measureDesc, step, qty, dec, inc, add, ctaLabel };
201
+ return { isMobile, cartType, store, toggleCartSidebar, mediaUrl, router, variants, quickShop, shown, locked, afterClose, inline, popup, close, onAfterLeave, onKey, closeBtn, onAfterEnter, isColor, selected, soldOut, comingSoon, noPrice, currency, money, price, pickedName, images, track, slide, onScroll, goTo, description, descEl, descLong, descOpen, measureDesc, step, qty, dec, inc, add, ctaLabel };
194
202
  }
@@ -144,6 +144,8 @@ export default function useLogic() {
144
144
  style["--osi-header-icon"] = s.HEADER_ICON_COLOR || "var(--osi-text)";
145
145
  if (s.HEADER_ICON_BG) style["--osi-header-icon-bg"] = s.HEADER_ICON_BG;
146
146
  if (s.HEADER_ICON_BORDER) style["--osi-header-icon-border"] = s.HEADER_ICON_BORDER;
147
+ if (s.HEADER_CART_BADGE_BG) style["--osi-header-badge-bg"] = s.HEADER_CART_BADGE_BG;
148
+ if (s.HEADER_CART_BADGE_TEXT) style["--osi-header-badge-text"] = s.HEADER_CART_BADGE_TEXT;
147
149
  style["--osi-header-pad-y"] = `${s.HEADER_PADDING_Y}px`;
148
150
  // Side padding lives on the header itself, not on the inner box — see the
149
151
  // note on .osi-header-inner below. 16 is COMMON_STYLE_SETTINGS' PADDING_X
@@ -332,6 +332,8 @@ export default function useLogic({ props }) {
332
332
 
333
333
  // filters
334
334
  "--pf-swatch": px(num(s.value.SWATCH_SIZE, 28)),
335
+ // Empty: the shape's radius (circle / square).
336
+ "--pf-swatch-r": px(s.value.SWATCH_RADIUS),
335
337
 
336
338
  // grid
337
339
  "--plp-cols": effectiveColumns.value,
@@ -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;