@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,166 @@
1
+ /**
2
+ * The product page's media column.
3
+ *
4
+ * Five desktop layouts, one mobile carousel, one lightbox — all driven by
5
+ * MAIN_PRODUCT settings, so a merchant gets Shopify's media choices without a
6
+ * theme fork:
7
+ *
8
+ * STACKED one image per row, full width (Maqui's layout)
9
+ * GRID_2 two-up mosaic, first image spanning both columns
10
+ * THUMBNAILS_START large image with a thumbnail rail beside it
11
+ * THUMBNAILS_BELOW large image with the rail underneath
12
+ * CAROUSEL one image, swipeable, arrows + counter
13
+ *
14
+ * The carousel is only wired for CAROUSEL / thumbnail layouts and for mobile;
15
+ * the stacked layouts don't need Swiper at all, so they don't pay for it.
16
+ *
17
+ * Ratios go out as CSS variables rather than classes for the same reason the
18
+ * card does it: adding a ratio shouldn't mean another branch in the template.
19
+ */
20
+
21
+ const RATIOS = {
22
+ NATURAL: "auto",
23
+ PORTRAIT: "3 / 4",
24
+ SQUARE: "1 / 1",
25
+ LANDSCAPE: "4 / 3",
26
+ SCREEN: "auto", // paired with a height below
27
+ };
28
+
29
+ /**
30
+ * @param {object} options
31
+ * @param {Array|Ref|(() => Array)} options.images media rows: { id, path, alt }
32
+ * @param {object|Ref|(() => object)} options.settings the section's settings
33
+ */
34
+ export function useProductGallery({ images, settings = {} } = {}) {
35
+ const list = computed(() => toValue(images) ?? []);
36
+ const s = computed(() => toValue(settings) ?? {});
37
+
38
+ const { mediaUrl, posterUrl, mediaUrlAs } = useMediaUrl();
39
+ const { isVideo } = useMediaTypes();
40
+
41
+ const layout = computed(() => s.value.MEDIA_LAYOUT || "STACKED");
42
+ const mobileLayout = computed(() => s.value.MOBILE_MEDIA_LAYOUT || "CAROUSEL");
43
+ const zoom = computed(() => s.value.MEDIA_ZOOM || "LIGHTBOX");
44
+
45
+ const usesThumbnails = computed(() =>
46
+ ["THUMBNAILS_START", "THUMBNAILS_BELOW"].includes(layout.value)
47
+ );
48
+ const usesCarousel = computed(
49
+ () => usesThumbnails.value || layout.value === "CAROUSEL"
50
+ );
51
+
52
+ const showCounter = computed(() => s.value.MEDIA_SHOW_COUNTER !== false);
53
+ const showArrows = computed(() => s.value.MEDIA_SHOW_ARROWS !== false);
54
+ const showDots = computed(() => !!s.value.MEDIA_SHOW_DOTS);
55
+
56
+ const galleryStyle = computed(() => {
57
+ const ratio = s.value.MEDIA_RATIO || "PORTRAIT";
58
+ const style = {
59
+ "--osi-pdp-media-ratio": RATIOS[ratio] ?? RATIOS.PORTRAIT,
60
+ // Phones: a portrait crop takes the whole screen, so the merchant can
61
+ // pick a shorter one there.
62
+ "--osi-pdp-media-ratio-mobile":
63
+ RATIOS[s.value.MEDIA_RATIO_MOBILE] ?? RATIOS[ratio] ?? RATIOS.PORTRAIT,
64
+ "--osi-pdp-media-fit": s.value.MEDIA_FIT === "CONTAIN" ? "contain" : "cover",
65
+ "--osi-pdp-media-gap": `${Number(s.value.MEDIA_GAP ?? 4)}px`,
66
+ "--osi-pdp-media-radius": `${Number(s.value.MEDIA_RADIUS ?? 0)}px`,
67
+ "--osi-pdp-thumb-size": `${Number(s.value.MEDIA_THUMBNAIL_SIZE ?? 84)}px`,
68
+ // SCREEN is the one ratio that's a height, not an aspect — a full-bleed
69
+ // editorial gallery can't be expressed as a ratio because it has to match
70
+ // the viewport whatever the photo is.
71
+ "--osi-pdp-media-height": ratio === "SCREEN" ? "100vh" : "auto",
72
+ };
73
+ if (s.value.MEDIA_BACKGROUND) style["--osi-pdp-media-bg"] = s.value.MEDIA_BACKGROUND;
74
+ return style;
75
+ });
76
+
77
+ /* ------------------------------------------------------------- carousel */
78
+
79
+ const carousel = useCarousel({
80
+ items: () => list.value,
81
+ loop: () => list.value.length > 1,
82
+ minLoopSlides: 3,
83
+ });
84
+
85
+ const current = computed(() => carousel.activeIndex.value ?? 0);
86
+
87
+ // A variant switch replaces the image set; leaving the carousel on slide 4 of
88
+ // the old set shows an unrelated photo for one frame and then jumps.
89
+ watch(
90
+ () => list.value.map((image) => image.path).join("|"),
91
+ () => {
92
+ nextTick(() => {
93
+ carousel.update();
94
+ carousel.goTo(0, 0);
95
+ });
96
+ }
97
+ );
98
+
99
+ /* ------------------------------------------------------------- lightbox */
100
+
101
+ const lightboxOpen = ref(false);
102
+ const lightboxIndex = ref(0);
103
+
104
+ useBodyScrollLock(() => lightboxOpen.value);
105
+
106
+ function openLightbox(index = 0) {
107
+ if (zoom.value === "NONE") return;
108
+ lightboxIndex.value = index;
109
+ lightboxOpen.value = true;
110
+ }
111
+
112
+ function closeLightbox() {
113
+ lightboxOpen.value = false;
114
+ }
115
+
116
+ function onKeydown(event) {
117
+ if (!lightboxOpen.value) return;
118
+ if (event.key === "Escape") closeLightbox();
119
+ if (event.key === "ArrowRight") {
120
+ lightboxIndex.value = Math.min(lightboxIndex.value + 1, list.value.length - 1);
121
+ }
122
+ if (event.key === "ArrowLeft") {
123
+ lightboxIndex.value = Math.max(lightboxIndex.value - 1, 0);
124
+ }
125
+ }
126
+
127
+ if (import.meta.client) {
128
+ window.addEventListener("keydown", onKeydown);
129
+ onScopeDispose(() => window.removeEventListener("keydown", onKeydown));
130
+ }
131
+
132
+ /* ----------------------------------------------------------------- media */
133
+
134
+ const src = (image) => mediaUrl(image);
135
+ const poster = (image) => posterUrl(image, image?.poster);
136
+ const sourceAs = (image, extension) => mediaUrlAs(image, extension);
137
+
138
+ return {
139
+ images: list,
140
+ layout,
141
+ mobileLayout,
142
+ zoom,
143
+ usesThumbnails,
144
+ usesCarousel,
145
+ showCounter,
146
+ showArrows,
147
+ showDots,
148
+ galleryStyle,
149
+
150
+ // carousel
151
+ ...carousel,
152
+ current,
153
+
154
+ // lightbox
155
+ lightboxOpen,
156
+ lightboxIndex,
157
+ openLightbox,
158
+ closeLightbox,
159
+
160
+ // media
161
+ isVideo,
162
+ src,
163
+ poster,
164
+ sourceAs,
165
+ };
166
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * PRODUCT_LIST's items — products in the one shape the card template reads.
3
+ *
4
+ * The card parts (image, title, price, swatches, description, button) are
5
+ * drawn by ContentNode from an `item`, the same way CLASSIFICATION_LIST's are.
6
+ * Everything product-specific — price ranges, sale / sold out, the second
7
+ * image, option swatches, quick buy, add to cart, wishlist — is worked out here once per
8
+ * product, so ContentNode stays a layout component and the per-card cost is a
9
+ * plain object, not a stack of composables per card.
10
+ *
11
+ * Which products, in which order:
12
+ * SOURCE = MANUAL settings.PRODUCTS (ids, pick order); older sections kept
13
+ * their picks on PRODUCT blocks, read after those
14
+ * anything else the rows the backend resolved (or the preview fetched),
15
+ * cut to LIMIT
16
+ * PRODUCT blocks (older sections) still override TITLE / MEDIA / BADGE.
17
+ *
18
+ * @param {object|Ref|(() => object)} section pass a getter
19
+ * @param {Ref<Array>} products from useSectionData
20
+ * @param {{ all?: boolean }} options all: every row as given, no
21
+ * SOURCE / LIMIT — the classification
22
+ * grid, which pages itself
23
+ */
24
+ import { cardOffers, tierText, rangeString } from "#osi/utils/productOffers";
25
+
26
+ export function useProductItems(section, products, options = {}) {
27
+ const t = useT();
28
+ const { mediaUrl } = useMediaUrl();
29
+ const { productUrl } = useProductUrl();
30
+ const { store } = useStorefront();
31
+ const { isPreview } = useBuilderPreview();
32
+ const { handleAddToCart } = useAddToCart();
33
+ const { addToWishlist, wishlistStatus } = useWishlist();
34
+ const analytics = useAnalytics();
35
+ const route = useRoute();
36
+ const { resource } = useRenderContext();
37
+
38
+ const settings = computed(() => toValue(section)?.settings ?? {});
39
+ const isManual = computed(() => String(settings.value.SOURCE ?? "").toUpperCase() === "MANUAL");
40
+
41
+ const currency = computed(
42
+ () => store.value?.default_currency?.symbol || store.value?.default_currency?.code || "$"
43
+ );
44
+
45
+ /* ------------------------------------------------------------------ price */
46
+
47
+ // price_range / discounted_price_range arrive as a number, a numeric string,
48
+ // or "min - max" when variants differ in price.
49
+ function priceParts(value) {
50
+ if (value === null || value === undefined || value === "") return [];
51
+ if (typeof value === "number") return Number.isFinite(value) ? [value] : [];
52
+ return String(value)
53
+ .split("-")
54
+ .map((part) => parseFloat(part))
55
+ .filter((n) => Number.isFinite(n));
56
+ }
57
+ function formatPrice(value) {
58
+ const parts = priceParts(value);
59
+ if (!parts.some((n) => n > 0)) return "";
60
+ return parts.map((n) => `${currency.value}${n}`).join(" - ");
61
+ }
62
+
63
+ /* ------------------------------------------------------------------ picks */
64
+
65
+ const idOf = (value) => (value && typeof value === "object" ? value.id : value);
66
+
67
+ /** Legacy PRODUCT blocks: { productId -> block settings }, in block order. */
68
+ const blockPicks = computed(() =>
69
+ (toValue(section)?.blocks ?? [])
70
+ .filter((b) => b.type === "PRODUCT" && !b.disabled)
71
+ .map((b) => ({ id: idOf(b.relations?.product ?? b.settings?.PRODUCT), overrides: b.settings ?? {} }))
72
+ .filter((pick) => pick.id != null)
73
+ );
74
+
75
+ const pickedIds = computed(() => {
76
+ const ids = (settings.value.PRODUCTS ?? []).map(idOf).filter((id) => id != null);
77
+ for (const pick of blockPicks.value) if (!ids.some((id) => String(id) === String(pick.id))) ids.push(pick.id);
78
+ return ids;
79
+ });
80
+
81
+ const rows = computed(() => {
82
+ const all = products.value ?? [];
83
+ if (options.all) return all;
84
+ if (!isManual.value || !pickedIds.value.length) {
85
+ const limit = Number(settings.value.LIMIT) || 8;
86
+ return isManual.value ? all : all.slice(0, limit);
87
+ }
88
+ // Manual: pick order. Placeholders (an empty store in the editor) have no
89
+ // matching ids — show them as they are rather than nothing.
90
+ const byId = new Map(all.map((row) => [String(row.id), row]));
91
+ const ordered = pickedIds.value.map((id) => byId.get(String(id))).filter(Boolean);
92
+ return ordered.length ? ordered : all.filter((row) => row.is_placeholder);
93
+ });
94
+
95
+ /* -------------------------------------------------------------- analytics */
96
+
97
+ // Every product list the storefront draws goes through here (PRODUCT_LIST,
98
+ // the category / collection grid, product highlight), so this is the one
99
+ // place that reports them: view_item_list when the products render, and the
100
+ // list remembered so a card click can say where it came from.
101
+ if (import.meta.client) {
102
+ watch(
103
+ rows,
104
+ (list) => {
105
+ if (isPreview.value || !list?.length) return;
106
+ const sec = toValue(section) ?? {};
107
+ const heading = String(t(sec.settings?.HEADING) ?? "").trim();
108
+ const res = resource.value;
109
+ const isClassification = !!options.all;
110
+ analytics.viewItemList({
111
+ id: isClassification
112
+ ? `${String(res?.type ?? "list").toLowerCase()}:${res?.slug ?? route.path}`
113
+ : `${route.path}#${sec.key ?? "products"}`,
114
+ name: isClassification ? res?.name || route.path : heading || sec.key || "Products",
115
+ products: list,
116
+ category: isClassification && ["CATEGORY", "COLLECTION"].includes(res?.type),
117
+ });
118
+ },
119
+ { immediate: true, flush: "post" }
120
+ );
121
+ }
122
+
123
+ /* ------------------------------------------------------------------ items */
124
+
125
+ function imagesOf(product) {
126
+ const out = [];
127
+ if (product.main_image) out.push(product.main_image);
128
+ for (const g of product.gallery ?? []) if (g?.media) out.push(g.media);
129
+ const seen = new Set();
130
+ return out.filter((m) => {
131
+ const k = m?.id ?? m?.path ?? m;
132
+ if (!k || seen.has(k)) return false;
133
+ seen.add(k);
134
+ return true;
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Sold out only when the product says so: "Out of Stock", or a stock count
140
+ * of 0. A missing count (null) isn't 0, and a product with no price isn't
141
+ * sold out — it just can't be bought yet (its button is disabled).
142
+ */
143
+ function soldOutOf(product) {
144
+ if (product.status === "Out of Stock") return true;
145
+ if (product.status === "In Stock" || product.status === "On Demand") return false;
146
+ if (product.remaining_stock === null || product.remaining_stock === undefined) return false;
147
+ return Number(product.remaining_stock) <= 0;
148
+ }
149
+
150
+ const isColorName = (name) => ["color", "colour"].includes(String(name).toLowerCase());
151
+
152
+ /**
153
+ * Every option (Color, Size, …) with the values that have a variant behind
154
+ * them. A colour value with a hex / colour draws as a dot; anything else —
155
+ * sizes, a colour without one — as text.
156
+ */
157
+ function optionsOf(product) {
158
+ return (product.vary_by ?? [])
159
+ .map((type) => {
160
+ const seen = new Set();
161
+ const values = [];
162
+ for (const variant of product.product_variants ?? []) {
163
+ const value = (variant.values ?? []).find((v) => v.type === type.id);
164
+ if (!value || seen.has(value.id)) continue;
165
+ seen.add(value.id);
166
+ const color = isColorName(type.name) ? value.hex_code || "" : "";
167
+ values.push({ key: value.id, name: value.name || value.value || "", color });
168
+ }
169
+ return { key: type.id, name: type.name, isColor: isColorName(type.name), values };
170
+ })
171
+ .filter((option) => option.values.length);
172
+ }
173
+
174
+ // The one quick buy open in this list: { key, mode } — SHEET | POPUP.
175
+ const quickBuy = ref(null);
176
+
177
+ const plain = (html) =>
178
+ String(html || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
179
+
180
+ const items = computed(() => {
181
+ const overrides = new Map(blockPicks.value.map((pick) => [String(pick.id), pick.overrides]));
182
+ return rows.value.map((product, i) => {
183
+ const o = overrides.get(String(product.id)) ?? {};
184
+ const images = imagesOf(product);
185
+ const title = t(o.TITLE) || product.name || product.page_title || "";
186
+ // Shopify's rules (utils/productOffers): the price for one item — only a
187
+ // 1-item sale lowers it — and the quantity tiers as boxes. Older rows
188
+ // without the variants' sales keep the API's ranges.
189
+ const offers = cardOffers(product);
190
+ let now, was;
191
+ if (offers) {
192
+ now = formatPrice(rangeString(offers.now));
193
+ was = offers.was ? formatPrice(rangeString(offers.was)) : "";
194
+ } else {
195
+ const base = product.price_range;
196
+ const sale = product.discounted_price_range;
197
+ now = formatPrice(sale ?? base);
198
+ was = sale && base && String(sale) !== String(base) ? formatPrice(base) : "";
199
+ }
200
+ const soldOut = soldOutOf(product);
201
+ const placeholder = product.is_placeholder === true;
202
+ const hasOptions = (product.product_variants?.length ?? 0) > 1;
203
+ const item = {
204
+ key: `${product.id ?? "p"}-${i}`,
205
+ id: product.id,
206
+ kind: "PRODUCT",
207
+ title,
208
+ description: plain(product.description),
209
+ url: placeholder ? null : productUrl(product),
210
+ image: mediaUrl(o.MEDIA ?? images[0] ?? null),
211
+ imageAlt: title,
212
+ hoverImage: o.MEDIA ? "" : images[1] ? mediaUrl(images[1]) : "",
213
+ price: now,
214
+ compareAt: was,
215
+ onSale: offers ? offers.sale : !!was,
216
+ // For the image's brand / category badges (BADGE_BRAND / BADGE_CATEGORIES).
217
+ brand: typeof product.brand === "object" ? product.brand?.name || "" : "",
218
+ categories: (product.categories ?? [])
219
+ .map((c) => c?.category?.name ?? c?.name ?? "")
220
+ .filter(Boolean),
221
+ // Quantity-tier sales (2+ items): badges on the image when there's no
222
+ // 1-item sale (that one shows "Sale").
223
+ offers: (offers?.tiers ?? []).map((tier) => ({ id: tier.id, text: tierText(tier, formatPrice, { short: true }) })),
224
+ soldOut,
225
+ comingSoon: !!product.coming_soon,
226
+ hasOptions,
227
+ badge: t(o.BADGE) || "",
228
+ options: optionsOf(product),
229
+ quickBuy: quickBuy.value?.key === `${product.id ?? "p"}-${i}` ? quickBuy.value.mode : null,
230
+ count: null,
231
+ wishlisted: wishlistStatus.value[product.id] ?? product.wishlisted ?? false,
232
+ isPlaceholder: placeholder,
233
+ raw: product,
234
+ };
235
+ // Card parts call these (ContentNode has no product code of its own).
236
+ item.addToCart = () => addToCart(item);
237
+ item.toggleWishlist = () => toggleWishlist(item);
238
+ item.openQuickBuy = (mode) => {
239
+ if (!inert(item) && !item.soldOut) quickBuy.value = { key: item.key, mode };
240
+ };
241
+ item.closeQuickBuy = () => {
242
+ if (quickBuy.value?.key === item.key) quickBuy.value = null;
243
+ };
244
+ return item;
245
+ });
246
+ });
247
+
248
+ /* ---------------------------------------------------------------- actions */
249
+
250
+ // In the editor a click selects the block — it must never add to a real
251
+ // cart or wishlist. Placeholders aren't real products either.
252
+ const inert = (item) => isPreview.value || !item || item.isPlaceholder;
253
+
254
+ async function addToCart(item) {
255
+ if (inert(item) || item.soldOut || !item.price) return;
256
+ try {
257
+ // Opens the cart itself; a product with options goes to its page.
258
+ await handleAddToCart(item.raw);
259
+ } catch {
260
+ // handleAddToCart shows its own error.
261
+ }
262
+ }
263
+
264
+ function toggleWishlist(item) {
265
+ if (inert(item)) return;
266
+ addToWishlist(item.raw);
267
+ }
268
+
269
+ return { items, addToCart, toggleWishlist };
270
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The href for a product.
3
+ *
4
+ * One place, because getting it wrong is invisible: a path the backend doesn't
5
+ * resolve falls through to NOT_FOUND and renders the wrong template while
6
+ * looking perfectly fine in the grid.
7
+ *
8
+ * ── Route shape ────────────────────────────────────────────────────────────
9
+ *
10
+ * `/<category-slug>/<product-slug>` — Terazar's nested form, and what the
11
+ * merchant-facing URLs are expected to look like.
12
+ *
13
+ * ⚠ The builder backend's `resolve_path()` currently maps `/products/<slug>`
14
+ * to the PRODUCT template and nothing else. Until it also matches a two-segment
15
+ * `/<category>/<product>` path, every link this produces 404s. That change has
16
+ * to land before this ships:
17
+ *
18
+ * /categories/<slug> CLASSIFICATION (already matched)
19
+ * /collections/<slug> CLASSIFICATION (already matched)
20
+ * /pages/<handle> PAGE (already matched)
21
+ * /<category>/<slug> PRODUCT ← needs adding, matched LAST so it
22
+ * can't shadow the prefixes above
23
+ *
24
+ * That ordering matters: a bare two-segment matcher placed first would swallow
25
+ * `/categories/shoes` and try to resolve a product called "shoes" in a category
26
+ * called "categories".
27
+ *
28
+ * ── Uncategorised products ─────────────────────────────────────────────────
29
+ *
30
+ * A product with no categories has no first segment. Terazar read
31
+ * `categories[0].category.slugified_name` unguarded, which is why an
32
+ * uncategorised product threw in search results rather than rendering. Here it
33
+ * falls back to `/products/<slug>`, which the backend already resolves — so the
34
+ * link works instead of crashing the page. Keep that route matched.
35
+ */
36
+ export function useProductUrl() {
37
+ /** Handles both `[{ category: { slugified_name } }]` and a flat `[{ slugified_name }]`. */
38
+ function categorySlug(product) {
39
+ const first = product?.categories?.[0];
40
+ if (!first) return null;
41
+
42
+ return (
43
+ first.category?.slugified_name ||
44
+ first.category?.slug ||
45
+ first.slugified_name ||
46
+ first.slug ||
47
+ null
48
+ );
49
+ }
50
+
51
+ function productUrl(product) {
52
+ const slug = product?.slugified_name || product?.slug;
53
+ if (!slug) return "/";
54
+
55
+ const category = categorySlug(product);
56
+ return category ? `/${category}/${slug}` : `/products/${slug}`;
57
+ }
58
+
59
+ return { productUrl, categorySlug };
60
+ }