@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.
- package/app/composables/UseThemeGroup.js +4 -0
- package/app/composables/useAddToCart.js +16 -6
- package/app/composables/useCart.js +66 -25
- package/app/composables/useCheckout.js +47 -6
- package/app/composables/useContentTree.js +28 -2
- package/app/composables/useProductCustomization.js +107 -15
- package/app/composables/useProductDetail.js +29 -8
- package/app/composables/useProductItems.js +5 -3
- package/app/composables/useProductVariants.js +32 -1
- package/app/composables/useQuickShop.js +20 -0
- package/app/logic/components/CartDrawer.js +14 -19
- package/app/logic/components/CartItem.js +18 -1
- package/app/logic/components/ProductVariantPicker.js +7 -2
- package/app/logic/components/QuickBuy.js +14 -6
- package/app/logic/components/SiteHeader.js +2 -0
- package/app/logic/sections/MainClassificationSection.js +2 -0
- package/app/pages/checkout.vue +29 -10
- package/app/pages/order-detail.vue +25 -2
- package/app/pages/user-profile/components/orders.vue +23 -2
- package/app/services/CartServices.js +92 -18
- package/app/utils/analytics.js +22 -11
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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(
|
|
147
|
-
const decrease = (item) => edit(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
189
|
-
? "
|
|
190
|
-
:
|
|
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,
|
package/app/pages/checkout.vue
CHANGED
|
@@ -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
|
|
18
|
-
:key="`strip-${item.
|
|
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="
|
|
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
|
-
+{{
|
|
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.
|
|
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
|
-
<
|
|
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.
|
|
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
|
-
<
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|