@osimart/create-storefront 0.0.0-stage → 1.0.1

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.
Files changed (206) hide show
  1. package/README.md +80 -2
  2. package/THEME_DEV_API.md +80 -0
  3. package/app/app.vue +154 -0
  4. package/app/assets/css/osi-controls.css +824 -0
  5. package/app/components/SectionRenderer.vue +46 -0
  6. package/app/components/builder/BuilderOverlay.vue +582 -0
  7. package/app/components/checkout/ExtraFees.vue +329 -0
  8. package/app/components/checkout/PaymentHandoff.vue +388 -0
  9. package/app/components/common/Banner.vue +35 -0
  10. package/app/components/common/ConfirmDeleteModal.vue +117 -0
  11. package/app/components/common/DropdownSelect.vue +432 -0
  12. package/app/components/common/Loader.vue +32 -0
  13. package/app/components/common/OsiField.vue +218 -0
  14. package/app/components/common/PhoneNumInput.vue +434 -0
  15. package/app/composables/UseBodyScrollLock.js +122 -0
  16. package/app/composables/UseThemeGroup.js +575 -0
  17. package/app/composables/useAddToCart.js +87 -0
  18. package/app/composables/useAnalytics.js +15 -0
  19. package/app/composables/useAnnouncements.js +29 -0
  20. package/app/composables/useApiErrorToast.js +104 -0
  21. package/app/composables/useAuth.js +99 -0
  22. package/app/composables/useBanners.js +25 -0
  23. package/app/composables/useBuilderPreview.js +320 -0
  24. package/app/composables/useButtonStyle.js +107 -0
  25. package/app/composables/useCarousel.js +160 -0
  26. package/app/composables/useCart.js +109 -0
  27. package/app/composables/useCheckout.js +76 -0
  28. package/app/composables/useClassificationItems.js +269 -0
  29. package/app/composables/useClassificationPage.js +545 -0
  30. package/app/composables/useClassificationUrl.js +63 -0
  31. package/app/composables/useColorScheme.js +44 -0
  32. package/app/composables/useContactForm.js +53 -0
  33. package/app/composables/useContentTree.js +770 -0
  34. package/app/composables/useDragScroll.js +73 -0
  35. package/app/composables/useFooterSocials.js +107 -0
  36. package/app/composables/useInputStyle.js +45 -0
  37. package/app/composables/useLayoutShell.js +114 -0
  38. package/app/composables/useLazyVideo.js +36 -0
  39. package/app/composables/useMediaTypes.js +66 -0
  40. package/app/composables/useMediaUrl.js +71 -0
  41. package/app/composables/useMenu.js +100 -0
  42. package/app/composables/useNavStyle.js +219 -0
  43. package/app/composables/usePlaceholders.js +294 -0
  44. package/app/composables/usePolicies.js +26 -0
  45. package/app/composables/useProductCardMedia.js +135 -0
  46. package/app/composables/useProductCardStyle.js +132 -0
  47. package/app/composables/useProductDetail.js +982 -0
  48. package/app/composables/useProductGallery.js +166 -0
  49. package/app/composables/useProductItems.js +270 -0
  50. package/app/composables/useProductUrl.js +60 -0
  51. package/app/composables/useProductVariants.js +210 -0
  52. package/app/composables/useQuickShop.js +115 -0
  53. package/app/composables/useRelatedProducts.js +186 -0
  54. package/app/composables/useRenderContext.js +45 -0
  55. package/app/composables/useScopedCss.js +106 -0
  56. package/app/composables/useSearch.js +35 -0
  57. package/app/composables/useSectionData.js +462 -0
  58. package/app/composables/useSectionShell.js +113 -0
  59. package/app/composables/useSidebar.js +69 -0
  60. package/app/composables/useStoreData.js +57 -0
  61. package/app/composables/useStoreInit.js +52 -0
  62. package/app/composables/useStorefront.js +239 -0
  63. package/app/composables/useT.js +21 -0
  64. package/app/composables/useTextStyles.js +84 -0
  65. package/app/composables/useThemeControls.js +34 -0
  66. package/app/composables/useThemeSeo.js +91 -0
  67. package/app/composables/useThemeTokens.js +171 -0
  68. package/app/composables/useWishlist.js +66 -0
  69. package/app/logic/components/AnnouncementBar.js +118 -0
  70. package/app/logic/components/CardGrid.js +125 -0
  71. package/app/logic/components/CardList.js +98 -0
  72. package/app/logic/components/CartDrawer.js +273 -0
  73. package/app/logic/components/CartItem.js +31 -0
  74. package/app/logic/components/ClassificationBento.js +6 -0
  75. package/app/logic/components/ClassificationCard.js +6 -0
  76. package/app/logic/components/ClassificationCarousel.js +115 -0
  77. package/app/logic/components/ClassificationEditorial.js +41 -0
  78. package/app/logic/components/ClassificationFeatured.js +57 -0
  79. package/app/logic/components/ClassificationGrid.js +6 -0
  80. package/app/logic/components/ClassificationNav.js +61 -0
  81. package/app/logic/components/ClassificationPart.js +72 -0
  82. package/app/logic/components/FooterBlock.js +91 -0
  83. package/app/logic/components/FooterSocials.js +60 -0
  84. package/app/logic/components/HeaderDropdownList.js +29 -0
  85. package/app/logic/components/HeaderIcon.js +68 -0
  86. package/app/logic/components/HeaderLogo.js +39 -0
  87. package/app/logic/components/HeaderNav.js +268 -0
  88. package/app/logic/components/MobileMenu.js +280 -0
  89. package/app/logic/components/ProductBuyButtons.js +66 -0
  90. package/app/logic/components/ProductCard.js +160 -0
  91. package/app/logic/components/ProductFilters.js +37 -0
  92. package/app/logic/components/ProductGallery.js +19 -0
  93. package/app/logic/components/ProductInfoBlock.js +411 -0
  94. package/app/logic/components/ProductLightbox.js +12 -0
  95. package/app/logic/components/ProductQuantity.js +31 -0
  96. package/app/logic/components/ProductVariantPicker.js +125 -0
  97. package/app/logic/components/QuickBuy.js +194 -0
  98. package/app/logic/components/SearchOverlay.js +41 -0
  99. package/app/logic/components/SidebarMenuItem.js +39 -0
  100. package/app/logic/components/SiteFooter.js +476 -0
  101. package/app/logic/components/SiteHeader.js +266 -0
  102. package/app/logic/components/WhatsAppButton.js +75 -0
  103. package/app/logic/layouts/default.js +66 -0
  104. package/app/logic/sections/AnnouncementBarSection.js +6 -0
  105. package/app/logic/sections/BlockCarousel.js +103 -0
  106. package/app/logic/sections/ClassificationListSection.js +40 -0
  107. package/app/logic/sections/ContactFormSection.js +294 -0
  108. package/app/logic/sections/ContactInfoSection.js +419 -0
  109. package/app/logic/sections/ContentNode.js +189 -0
  110. package/app/logic/sections/CountdownSection.js +254 -0
  111. package/app/logic/sections/CustomHtmlSection.js +51 -0
  112. package/app/logic/sections/DividerSection.js +31 -0
  113. package/app/logic/sections/FaqSection.js +53 -0
  114. package/app/logic/sections/FeatureListSection.js +6 -0
  115. package/app/logic/sections/FeaturedProductSection.js +40 -0
  116. package/app/logic/sections/FooterSection.js +16 -0
  117. package/app/logic/sections/FooterUtilitiesSection.js +24 -0
  118. package/app/logic/sections/GallerySection.js +232 -0
  119. package/app/logic/sections/HeaderSection.js +24 -0
  120. package/app/logic/sections/HeroSection.js +24 -0
  121. package/app/logic/sections/LogoListSection.js +6 -0
  122. package/app/logic/sections/MainClassificationSection.js +473 -0
  123. package/app/logic/sections/MainProductSection.js +211 -0
  124. package/app/logic/sections/MainSearchSection.js +6 -0
  125. package/app/logic/sections/MapSection.js +92 -0
  126. package/app/logic/sections/MediaContentSection.js +38 -0
  127. package/app/logic/sections/MediaWithTextSection.js +238 -0
  128. package/app/logic/sections/NewsletterSection.js +126 -0
  129. package/app/logic/sections/ProductHighlightSection.js +201 -0
  130. package/app/logic/sections/ProductListSection.js +45 -0
  131. package/app/logic/sections/ProductRecommendationsSection.js +113 -0
  132. package/app/logic/sections/RichTextSection.js +6 -0
  133. package/app/logic/sections/ScrollingTextSection.js +69 -0
  134. package/app/logic/sections/SearchHeaderSection.js +22 -0
  135. package/app/logic/sections/SlideshowSection.js +118 -0
  136. package/app/logic/sections/SpacerSection.js +37 -0
  137. package/app/logic/sections/SplitShowcaseSection.js +6 -0
  138. package/app/logic/sections/TestimonialListSection.js +6 -0
  139. package/app/logic/sections/VideoSection.js +235 -0
  140. package/app/pages/[category]/[product].vue +196 -0
  141. package/app/pages/__builder-preview.vue +38 -0
  142. package/app/pages/categories/[slug].vue +208 -0
  143. package/app/pages/checkout.vue +1972 -0
  144. package/app/pages/forgot-password.vue +115 -0
  145. package/app/pages/index.vue +64 -0
  146. package/app/pages/login.vue +585 -0
  147. package/app/pages/order-detail.vue +442 -0
  148. package/app/pages/pages/[handle].vue +58 -0
  149. package/app/pages/privacy-policy.vue +46 -0
  150. package/app/pages/resetpassword.vue +138 -0
  151. package/app/pages/resetverificationcode.vue +163 -0
  152. package/app/pages/result.vue +203 -0
  153. package/app/pages/return-policy.vue +50 -0
  154. package/app/pages/search.vue +100 -0
  155. package/app/pages/shipping-policy.vue +46 -0
  156. package/app/pages/user-profile/components/AddAddressModal.vue +452 -0
  157. package/app/pages/user-profile/components/Whishlist.vue +137 -0
  158. package/app/pages/user-profile/components/left-bar.vue +233 -0
  159. package/app/pages/user-profile/components/orders.vue +162 -0
  160. package/app/pages/user-profile/components/profile-details.vue +566 -0
  161. package/app/pages/user-profile/components/settings.vue +194 -0
  162. package/app/pages/user-profile/index.vue +252 -0
  163. package/app/pages/verificationcode.vue +188 -0
  164. package/app/plugins/analytics.client.js +99 -0
  165. package/app/plugins/geo.js +24 -0
  166. package/app/plugins/reveal.client.js +53 -0
  167. package/app/plugins/toast.js +10 -0
  168. package/app/plugins/vue-query.js +5 -0
  169. package/app/services/ApiServices.js +74 -0
  170. package/app/services/AuthBus.js +162 -0
  171. package/app/services/Authentication/AuthenticationService.js +133 -0
  172. package/app/services/Authentication/TokenService.js +62 -0
  173. package/app/services/CartServices.js +91 -0
  174. package/app/services/CartToken.js +26 -0
  175. package/app/services/CategoryServices.js +54 -0
  176. package/app/services/CheckoutServices.js +113 -0
  177. package/app/services/HomeServices.js +133 -0
  178. package/app/services/NewsletterServices.js +29 -0
  179. package/app/services/PaymentServices.js +71 -0
  180. package/app/services/PoliciesServices.js +16 -0
  181. package/app/services/ProductServices.js +79 -0
  182. package/app/services/SearchServices.js +18 -0
  183. package/app/services/UserServices.js +105 -0
  184. package/app/services/UtilsServices.js +46 -0
  185. package/app/services/WishlistServices.js +51 -0
  186. package/app/services/storefront.service.js +57 -0
  187. package/app/stores/userStore.js +13 -0
  188. package/app/theme/categoryTree.js +68 -0
  189. package/app/theme/controls.js +88 -0
  190. package/app/theme/registry.js +56 -0
  191. package/app/utils/analytics.js +864 -0
  192. package/app/utils/device.js +50 -0
  193. package/app/utils/eventBus.js +24 -0
  194. package/app/utils/icon-presets.js +24 -0
  195. package/app/utils/productOffers.js +118 -0
  196. package/app/utils/requestCache.js +36 -0
  197. package/app/utils/scrollToError.js +59 -0
  198. package/app/utils/toPlainError.js +30 -0
  199. package/bin/osimart-theme.mjs +282 -0
  200. package/nuxt/osi-sections.ts +56 -0
  201. package/nuxt.config.ts +60 -0
  202. package/package.json +44 -4
  203. package/public/css/input.css +29 -0
  204. package/public/css/output.css +2716 -0
  205. package/public/favicon-32x32.png +0 -0
  206. package/public/robots.txt +2 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Shared Swiper wiring.
3
+ *
4
+ * The same instance handling was written five times in Terazar (announcement
5
+ * bar, category rail, recommended rail, product card, product gallery) with
6
+ * subtly different bug fixes in each. All of them are folded in here:
7
+ *
8
+ * - shallowRef + markRaw. A plain ref() deep-proxies the Swiper instance,
9
+ * which breaks its internal identity checks and leaves `animating` stuck
10
+ * true, so the carousel silently stops responding after one swipe.
11
+ * - update() on nextTick after init. Skeleton -> carousel swaps happen in the
12
+ * same tick, so Swiper measures a 0-width container, reports isEnd === true,
13
+ * and permanently disables the next arrow.
14
+ * - Loop padding. Swiper's loop relocates real slides rather than cloning
15
+ * them, so a list shorter than the visible count leaves nothing to move and
16
+ * the edge goes blank mid-transition. Duplicating the list up to
17
+ * `minLoopSlides` is what fixes it.
18
+ * - `destroyed` guards. Swiper nulls its internals on destroy; anything
19
+ * touching the instance from a watcher or timeout after unmount throws.
20
+ *
21
+ * Section components pass their settings in rather than hardcoding constants:
22
+ * const { slides, onSwiper, goPrev, goNext, isBeginning, isEnd } =
23
+ * useCarousel({ items: () => products.value, loop: () => s.LOOP });
24
+ */
25
+ export function useCarousel(options = {}) {
26
+ const {
27
+ items = () => [],
28
+ loop = false,
29
+ minLoopSlides = 0,
30
+ breakpoints = null,
31
+ } = options;
32
+
33
+ const read = (value) => (typeof value === "function" ? value() : unref(value));
34
+
35
+ const swiper = shallowRef(null);
36
+ const activeIndex = ref(0);
37
+ const isBeginning = ref(true);
38
+ const isEnd = ref(false);
39
+
40
+ const source = computed(() => read(items) ?? []);
41
+ const loopEnabled = computed(() => !!read(loop) && source.value.length > 1);
42
+
43
+ /**
44
+ * The list actually rendered. Identical to `items` unless looping needs
45
+ * padding — see the loop note above.
46
+ */
47
+ const slides = computed(() => {
48
+ const list = source.value;
49
+ const minimum = read(minLoopSlides) ?? 0;
50
+ if (!list.length || !loopEnabled.value || list.length >= minimum) return list;
51
+
52
+ const copies = Math.max(1, Math.ceil(minimum / list.length));
53
+ return Array.from({ length: copies }, () => list).flat();
54
+ });
55
+
56
+ const alive = () => swiper.value && !swiper.value.destroyed;
57
+
58
+ /** Refresh the arrow disabled-state from the instance. */
59
+ function sync(instance) {
60
+ const s = instance || swiper.value;
61
+ if (!s || s.destroyed) return;
62
+ isBeginning.value = s.isBeginning;
63
+ isEnd.value = s.isEnd;
64
+ activeIndex.value = s.realIndex ?? s.activeIndex ?? 0;
65
+ }
66
+
67
+ /** Bind to Swiper's `@swiper` event. */
68
+ function onSwiper(instance) {
69
+ swiper.value = markRaw(instance);
70
+ sync(instance);
71
+
72
+ nextTick(() => {
73
+ if (instance.destroyed) return;
74
+ instance.update();
75
+ sync(instance);
76
+ });
77
+ }
78
+
79
+ /** Bind to `@slideChange`. */
80
+ function onSlideChange(instance) {
81
+ sync(instance);
82
+ }
83
+
84
+ function goPrev() {
85
+ if (alive()) swiper.value.slidePrev();
86
+ }
87
+
88
+ function goNext() {
89
+ if (alive()) swiper.value.slideNext();
90
+ }
91
+
92
+ /** Jump to a slide, honouring loop mode so thumbnails map to the right image. */
93
+ function goTo(index, speed = 300) {
94
+ if (!alive()) return;
95
+ const instance = swiper.value;
96
+ if (loopEnabled.value && typeof instance.slideToLoop === "function") {
97
+ instance.slideToLoop(index, speed);
98
+ } else {
99
+ instance.slideTo(index, speed);
100
+ }
101
+ }
102
+
103
+ function update() {
104
+ if (!alive()) return;
105
+ swiper.value.update();
106
+ sync();
107
+ }
108
+
109
+ /**
110
+ * Toggle arrows at runtime — the announcement bar enables navigation on
111
+ * mobile only, and Swiper needs init()/destroy() called on the module, not
112
+ * just the param flipped.
113
+ */
114
+ function setNavigationEnabled(enabled) {
115
+ if (!alive() || !import.meta.client) return;
116
+ const instance = swiper.value;
117
+ if (instance.params?.navigation) instance.params.navigation.enabled = enabled;
118
+
119
+ const navigation = instance.navigation;
120
+ if (!navigation) return;
121
+ if (enabled) {
122
+ navigation.init();
123
+ navigation.update();
124
+ } else {
125
+ navigation.destroy();
126
+ }
127
+ }
128
+
129
+ // The rendered list changing (filters applied, variant switched) invalidates
130
+ // Swiper's measurements; without this the last slides are unreachable.
131
+ watch(slides, () => nextTick(update));
132
+
133
+ if (import.meta.client) {
134
+ const onResize = () => update();
135
+ window.addEventListener("resize", onResize, { passive: true });
136
+ onScopeDispose(() => window.removeEventListener("resize", onResize));
137
+ }
138
+
139
+ onScopeDispose(() => {
140
+ swiper.value = null;
141
+ });
142
+
143
+ return {
144
+ swiper,
145
+ slides,
146
+ activeIndex,
147
+ isBeginning,
148
+ isEnd,
149
+ loopEnabled,
150
+ breakpoints,
151
+ onSwiper,
152
+ onSlideChange,
153
+ goPrev,
154
+ goNext,
155
+ goTo,
156
+ update,
157
+ sync,
158
+ setNavigationEnabled,
159
+ };
160
+ }
@@ -0,0 +1,109 @@
1
+ import CartServices from "#osi/services/CartServices";
2
+ import { clearCartToken } from "#osi/services/CartToken";
3
+
4
+ export function useCart() {
5
+ // The raw server payload. Checkout needs it to build its order lines, so
6
+ // it's kept rather than discarded after mapping.
7
+ const cart = useState("cart-raw", () => null);
8
+ const cartItems = useState("cart-items", () => []);
9
+ const cartTotal = useState("cart-total", () => 0);
10
+ const loading = useState("cart-loading", () => false);
11
+ const updatingIds = useState("cart-updating-ids", () => []);
12
+
13
+ /**
14
+ * Single write path. Every mutation endpoint returns the whole cart, so
15
+ * there's never a reason to follow an edit with a second fetch.
16
+ */
17
+ function apply(response) {
18
+ if (!response) return response;
19
+ cart.value = response;
20
+ cartTotal.value = response.total_price || 0;
21
+ cartItems.value = Object.entries(response.cart || {}).map(([id, item]) => ({
22
+ id,
23
+ ...item,
24
+ }));
25
+ return response;
26
+ }
27
+
28
+ const isUpdating = (item) => {
29
+ const id = item?.id ?? item;
30
+ return updatingIds.value.includes(id);
31
+ };
32
+
33
+ function markUpdating(id, busy) {
34
+ updatingIds.value = busy
35
+ ? [...updatingIds.value, id]
36
+ : updatingIds.value.filter((existing) => existing !== id);
37
+ }
38
+
39
+ async function fetchCart() {
40
+ loading.value = true;
41
+ try {
42
+ return apply(await CartServices.fetchCart());
43
+ } catch (error) {
44
+ console.error("[useCart] Error fetching cart:", error);
45
+ return null;
46
+ } finally {
47
+ loading.value = false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Add or remove quantity on one line. Per-line busy state, not a single
53
+ * global flag — one slow line shouldn't freeze the controls on the others.
54
+ *
55
+ * Throws on failure so the caller can decide how to surface it (the drawer
56
+ * has useApiErrorToast, checkout has its own extractErrorMessage).
57
+ */
58
+ async function editItem(item, action, quantity) {
59
+ const id = item?.id ?? item;
60
+ if (!id || isUpdating(id)) return null;
61
+
62
+ const step = quantity ?? item?.quantity_step ?? 1;
63
+ markUpdating(id, true);
64
+ try {
65
+ return apply(await CartServices.EditCart(id, step, action));
66
+ } finally {
67
+ markUpdating(id, false);
68
+ }
69
+ }
70
+
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);
75
+
76
+ /**
77
+ * Call only once the server has confirmed there's nothing left to pay. The
78
+ * guest cart lives server-side keyed by the token, so dropping the token is
79
+ * what actually detaches this browser from the converted cart; the next
80
+ * fetch gets a fresh one.
81
+ */
82
+ function clear() {
83
+ clearCartToken();
84
+ cart.value = null;
85
+ cartItems.value = [];
86
+ cartTotal.value = 0;
87
+ updatingIds.value = [];
88
+ }
89
+
90
+ const count = computed(() =>
91
+ cartItems.value.reduce((total, item) => total + (item.quantity || 0), 0),
92
+ );
93
+
94
+ return {
95
+ cart,
96
+ cartItems,
97
+ cartTotal,
98
+ loading,
99
+ count,
100
+ isUpdating,
101
+ fetchCart,
102
+ editItem,
103
+ increaseItem,
104
+ decreaseItem,
105
+ removeItem,
106
+ apply,
107
+ clear,
108
+ };
109
+ }
@@ -0,0 +1,76 @@
1
+ import CheckoutServices from "#osi/services/CheckoutServices";
2
+ import UtilsServices from "#osi/services/UtilsServices";
3
+ import UserServices from "#osi/services/UserServices";
4
+ import { authBus } from "#osi/services/AuthBus";
5
+ import { useToast } from "vue-toastification";
6
+
7
+ export function useCheckout() {
8
+ const router = useRouter();
9
+ const isLoading = useState("checkout-loading", () => false);
10
+ const cartData = useState("checkout-cart-data", () => null);
11
+ const countries = useState("checkout-countries", () => []);
12
+ const paymentMethods = useState("checkout-payment-methods", () => []);
13
+ const promoCodeData = useState("checkout-promo-code", () => null);
14
+
15
+ const isLoggedIn = computed(() => authBus.isLoggedIn);
16
+
17
+ async function loadCheckoutData() {
18
+ isLoading.value = true;
19
+ try {
20
+ const [cartRes, countriesRes, paymentsRes] = await Promise.allSettled([
21
+ CheckoutServices.fetchCart(),
22
+ UtilsServices.getCountries(),
23
+ UtilsServices.getAvailablePaymentMethods(),
24
+ ]);
25
+
26
+ if (cartRes.status === "fulfilled") cartData.value = cartRes.value;
27
+ if (countriesRes.status === "fulfilled") countries.value = countriesRes.value?.results || countriesRes.value || [];
28
+ if (paymentsRes.status === "fulfilled") paymentMethods.value = paymentsRes.value?.results || paymentsRes.value || [];
29
+ } catch (error) {
30
+ console.error("[useCheckout] Error loading checkout data:", error);
31
+ } finally {
32
+ isLoading.value = false;
33
+ }
34
+ }
35
+
36
+ async function applyPromoCode(code) {
37
+ const toast = useToast();
38
+ try {
39
+ const res = await CheckoutServices.promoCode(code);
40
+ promoCodeData.value = res;
41
+ toast.success("Promo code applied successfully!");
42
+ return res;
43
+ } catch (error) {
44
+ toast.error(error?.data?.detail || "Invalid promo code");
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ async function submitOrder(orderPayload) {
50
+ const toast = useToast();
51
+ isLoading.value = true;
52
+ try {
53
+ const response = await CheckoutServices.placeOrder(orderPayload);
54
+ toast.success("Order placed successfully!");
55
+ router.push(`/ordersummary?order_id=${response.id || response.order_id}`);
56
+ return response;
57
+ } catch (error) {
58
+ toast.error(error?.data?.detail || "Failed to place order");
59
+ throw error;
60
+ } finally {
61
+ isLoading.value = false;
62
+ }
63
+ }
64
+
65
+ return {
66
+ isLoading,
67
+ cartData,
68
+ countries,
69
+ paymentMethods,
70
+ promoCodeData,
71
+ isLoggedIn,
72
+ loadCheckoutData,
73
+ applyPromoCode,
74
+ submitOrder,
75
+ };
76
+ }
@@ -0,0 +1,269 @@
1
+ import { isOn, isTopLevel } from "#osi/theme/categoryTree";
2
+
3
+ /**
4
+ * One item shape for every classification layout.
5
+ *
6
+ * The grid, the carousel and the featured block all render the same thing — a
7
+ * picture, a name, maybe a count, a link. What differs is arrangement. So the
8
+ * "which of the six places does this field live in" problem is solved once,
9
+ * here, and the three layout components receive a plain array.
10
+ *
11
+ * Two sources feed it:
12
+ *
13
+ * SOURCE = MANUAL the section's blocks, in the merchant's order
14
+ * SOURCE = ALL_CATEGORIES section.data.classifications, resolved by the
15
+ * SOURCE = ALL_COLLECTIONS backend before the payload left the server
16
+ *
17
+ * For a manual block, the item's data can arrive three ways, in this order of
18
+ * preference:
19
+ *
20
+ * 1. block.relations.category / .collection — the op engine resolved the pick
21
+ * into a real FK and the serializer expanded it. This is the normal path
22
+ * on a live storefront, and it's why the storefront never has to fetch a
23
+ * manually picked category by id.
24
+ * 2. block.data — same idea, if your serializer nests it there instead.
25
+ * 3. a row matched out of `classifications` by id — the fallback
26
+ * useSectionData fills in when neither of the above is present (builder
27
+ * preview, or a backend that doesn't resolve MANUAL yet).
28
+ *
29
+ * Block settings always win over the resolved object: TITLE, MEDIA and
30
+ * DESCRIPTION are deliberate merchant overrides.
31
+ *
32
+ * @param {object|Ref|(() => object)} section pass a getter, not the object
33
+ * @param {Ref<Array>} classifications from useSectionData
34
+ */
35
+ export function useClassificationItems(section, classifications) {
36
+ const t = useT();
37
+ const { mediaUrl } = useMediaUrl();
38
+ const { classificationUrl, classificationKind } = useClassificationUrl();
39
+
40
+ const settings = computed(() => toValue(section)?.settings ?? {});
41
+
42
+ const isFeatured = computed(
43
+ () => String(settings.value.LAYOUT ?? "GRID").toUpperCase() === "FEATURED"
44
+ );
45
+
46
+ /**
47
+ * MANUAL is explicit now. The legacy fallback exists because sections created
48
+ * before SOURCE had a MANUAL member stored blocks with no source at all —
49
+ * ignoring them would blank a live storefront on deploy.
50
+ */
51
+ const isManual = computed(() => {
52
+ // Featured never lists — it always renders one explicitly chosen item.
53
+ if (isFeatured.value) return true;
54
+ // Picked in the panel: exactly those, in that order. Empty = all.
55
+ if ((settings.value.CLASSIFICATIONS ?? []).length) return true;
56
+
57
+ const source = String(settings.value.SOURCE ?? "").toUpperCase();
58
+ if (source === "MANUAL") return true;
59
+ if (source) return false;
60
+ // No source at all: an old section whose blocks were the picks. Only blocks
61
+ // that point at something count — the header and card are blocks too.
62
+ return (toValue(section)?.blocks ?? []).some((b) => sourceOf(b));
63
+ });
64
+
65
+ /** The kind an auto source produces, used when a row doesn't carry its own. */
66
+ const sourceKind = computed(() =>
67
+ String(settings.value.SOURCE ?? "").toUpperCase().includes("COLLECTION")
68
+ ? "COLLECTION"
69
+ : "CATEGORY"
70
+ );
71
+
72
+ /** Index the resolved rows so a manual block can find its object by id. */
73
+ const rowsByKey = computed(() => {
74
+ const map = new Map();
75
+ for (const row of classifications.value ?? []) {
76
+ if (row?.id == null) continue;
77
+ map.set(`${classificationKind(row, sourceKind.value)}:${row.id}`, row);
78
+ }
79
+ return map;
80
+ });
81
+
82
+ /** Decode one stored pick — { kind, id } or the encoded "CATEGORY:12". */
83
+ function decodePick(raw) {
84
+ if (!raw) return null;
85
+ if (typeof raw === "object") {
86
+ return raw.id == null ? null : { kind: classificationKind(raw), id: raw.id };
87
+ }
88
+ const [kind, id] = String(raw).split(":");
89
+ if (!id) return null;
90
+ return { kind: classificationKind({ kind }), id: /^\d+$/.test(id) ? Number(id) : id };
91
+ }
92
+
93
+ /** The resolved row behind a pick, whichever way it arrived. */
94
+ function rowFor(pick) {
95
+ return pick ? rowsByKey.value.get(`${pick.kind}:${pick.id}`) ?? null : null;
96
+ }
97
+
98
+ /** The resolved category/collection behind a block. */
99
+ function sourceOf(block) {
100
+ const relations = block?.relations ?? {};
101
+ if (relations.category) return { kind: "CATEGORY", data: relations.category };
102
+ if (relations.collection) return { kind: "COLLECTION", data: relations.collection };
103
+ if (block?.data?.classification) {
104
+ const data = block.data.classification;
105
+ return { kind: classificationKind(data), data };
106
+ }
107
+
108
+ const pick = decodePick(block?.settings?.CLASSIFICATION ?? relations.classification);
109
+ const row = rowFor(pick);
110
+ return row ? { kind: pick.kind, data: row } : null;
111
+ }
112
+
113
+ function normalize({ key, kind, data, overrides = {} }) {
114
+ if (!data) return null;
115
+
116
+ const title = t(overrides.TITLE) || data.name || data.title || "";
117
+ const description = t(overrides.DESCRIPTION) || data.description || "";
118
+ const image = mediaUrl(
119
+ overrides.MEDIA ?? data.main_image ?? data.image ?? data.media ?? null
120
+ );
121
+
122
+ return {
123
+ key,
124
+ id: data.id,
125
+ kind,
126
+ title,
127
+ description,
128
+ image,
129
+ // Category art is rarely decorative — it's the only label some cards have
130
+ // when the title is hidden — but repeating the title verbatim would make a
131
+ // screen reader announce it twice on cards that show both.
132
+ imageAlt: title,
133
+ count: data.products_count ?? data.product_count ?? null,
134
+ url: classificationUrl({ ...data, kind }, kind),
135
+ // Carried so a card can tell a dummy row from a real one. Nothing styles
136
+ // off it today; it exists so the distinction is available without
137
+ // sniffing the id.
138
+ isPlaceholder: data.is_placeholder === true,
139
+ raw: data,
140
+ };
141
+ }
142
+ const items = computed(() => {
143
+ const current = toValue(section);
144
+ if (!current) return [];
145
+
146
+ let list;
147
+
148
+ if (isFeatured.value) {
149
+ // FEATURED renders one item, and which one is an explicit setting — not
150
+ // "whatever happened to be first", which is what made the layout feel
151
+ // random when the source changed.
152
+ const pick =
153
+ decodePick(settings.value.FEATURED_CLASSIFICATION) ??
154
+ decodePick((settings.value.CLASSIFICATIONS ?? [])[0]) ??
155
+ null;
156
+
157
+ const data = rowFor(pick) ?? sourceOf((current.blocks ?? [])[0])?.data ?? null;
158
+
159
+ const item = normalize({
160
+ key: pick ? `${pick.kind}-${pick.id}` : "featured",
161
+ kind: pick?.kind ?? classificationKind(data, sourceKind.value),
162
+ data,
163
+ // A category is catalog structure and usually has no marketing copy.
164
+ // These overrides are where the promo's headline and body actually
165
+ // come from most of the time; the item's own values are the fallback.
166
+ overrides: {
167
+ TITLE: settings.value.FEATURED_TITLE,
168
+ DESCRIPTION: settings.value.FEATURED_DESCRIPTION,
169
+ },
170
+ });
171
+
172
+ if (item) return [item];
173
+
174
+ const fallbackRow = classifications.value?.[0];
175
+ if (fallbackRow) {
176
+ const phItem = normalize({
177
+ key: "featured-placeholder",
178
+ kind: classificationKind(fallbackRow, sourceKind.value),
179
+ data: fallbackRow,
180
+ overrides: {
181
+ TITLE: settings.value.FEATURED_TITLE,
182
+ DESCRIPTION: settings.value.FEATURED_DESCRIPTION,
183
+ },
184
+ });
185
+ if (phItem) return [phItem];
186
+ }
187
+
188
+ return [];
189
+ }
190
+
191
+ if (isManual.value) {
192
+ list = (settings.value.CLASSIFICATIONS ?? [])
193
+ .map((entry) => {
194
+ const pick = decodePick(entry);
195
+ const data = rowFor(pick);
196
+ return data
197
+ ? normalize({ key: `${pick.kind}-${pick.id}`, kind: pick.kind, data })
198
+ : null;
199
+ })
200
+ .filter(Boolean);
201
+
202
+ // Blocks are merged, not appended blindly: a block that points at an
203
+ // item already picked in the panel is an override of it, so it replaces
204
+ // that entry in place rather than rendering the same category twice.
205
+ for (const block of current.blocks ?? []) {
206
+ const resolved = sourceOf(block);
207
+ if (!resolved) continue;
208
+
209
+ const item = normalize({
210
+ key: block.key ?? `${resolved.kind}-${resolved.data?.id}`,
211
+ kind: resolved.kind,
212
+ data: resolved.data,
213
+ overrides: block.settings ?? {},
214
+ });
215
+ if (!item) continue;
216
+
217
+ const at = list.findIndex((row) => row.kind === item.kind && row.id === item.id);
218
+ if (at === -1) list.push(item);
219
+ else list[at] = item;
220
+ }
221
+
222
+ if (!list.length && classifications.value?.length) {
223
+ list = (classifications.value ?? [])
224
+ .map((row) =>
225
+ normalize({
226
+ key: `${classificationKind(row, sourceKind.value)}-${row?.id}`,
227
+ kind: classificationKind(row, sourceKind.value),
228
+ data: row,
229
+ })
230
+ )
231
+ .filter(Boolean);
232
+ }
233
+ } else {
234
+ list = (classifications.value ?? [])
235
+ .map((row) =>
236
+ normalize({
237
+ key: `${classificationKind(row, sourceKind.value)}-${row?.id}`,
238
+ kind: classificationKind(row, sourceKind.value),
239
+ data: row,
240
+ })
241
+ )
242
+ .filter(Boolean);
243
+ }
244
+
245
+ // These three also run on the backend where it resolves the source. Running
246
+ // them again is idempotent, and it's what keeps the builder preview — which
247
+ // fetches the raw lists client-side — showing the same thing the live page
248
+ // will show.
249
+ const isUsingPlaceholders = list.some((item) => item.isPlaceholder);
250
+ if (!isManual.value || isUsingPlaceholders) {
251
+ if (isOn(settings.value.PARENTS_ONLY, true) && sourceKind.value === "CATEGORY") {
252
+ list = list.filter((item) => isTopLevel(item.raw ?? {}));
253
+ }
254
+
255
+ const limit = Number(settings.value.LIMIT);
256
+ if (Number.isFinite(limit) && limit > 0) list = list.slice(0, limit);
257
+ }
258
+
259
+ if (settings.value.HIDE_EMPTY) {
260
+ // Only when the count is actually known — an API that doesn't return
261
+ // products_count would otherwise hide everything.
262
+ list = list.filter((item) => item.count == null || item.count > 0);
263
+ }
264
+
265
+ return list;
266
+ });
267
+
268
+ return { items, isManual, isFeatured, sourceKind };
269
+ }