@osimart/create-storefront 0.0.0-stage → 1.0.0

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.ts +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.ts +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 +259 -0
  200. package/nuxt/osi-sections.ts +56 -0
  201. package/nuxt.config.ts +60 -0
  202. package/package.json +43 -3
  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,982 @@
1
+ import ProductServices from "#osi/services/ProductServices";
2
+ import CartServices from "#osi/services/CartServices";
3
+
4
+ /**
5
+ * Product detail state — the whole of Terazar/Maqui's product page logic, lifted
6
+ * out of the page and made shareable.
7
+ *
8
+ * Why a context rather than a page:
9
+ *
10
+ * The product page is theme-driven. MAIN_PRODUCT owns the layout, and the info
11
+ * column is an ordered list of merchant-arranged blocks (title, price, variant
12
+ * picker, quantity, buy buttons, collapsible rows, …) exactly like Shopify's
13
+ * main-product section. Those blocks are separate components, and every one of
14
+ * them needs the same selection: the price a block renders has to be the price
15
+ * the buy button adds. Prop-drilling that through a v-for over blocks means
16
+ * every block signature carries ten props it mostly ignores.
17
+ *
18
+ * So MainProductSection calls createProductDetail() once and provides it; every
19
+ * block calls useProductDetail() and reads the same refs.
20
+ *
21
+ * ── What was kept from the original page, deliberately ─────────────────────
22
+ *
23
+ * * exactMatchVariant has NO early return on an empty selection. For a product
24
+ * with no options, `variant.values` is [] and `.every()` on it is trivially
25
+ * true, so the single default variant matches immediately. Bailing out early
26
+ * is what used to break "Add to cart" on option-less products.
27
+ *
28
+ * * selectedVariant falls back to a dummy `{ gallery }` object while the
29
+ * selection is partial, so picking a colour swaps the gallery before a size
30
+ * is chosen.
31
+ *
32
+ * * Deselect-on-second-click, the partial-selection price range, the
33
+ * discounted_price_range parsing, "Buy N & Save X off", the coming-soon and
34
+ * per-variant track_inventory stock rules, quantity_step arithmetic, and the
35
+ * Web Share -> clipboard -> execCommand share chain are all as they were.
36
+ *
37
+ * ── What changed, and why ──────────────────────────────────────────────────
38
+ *
39
+ * * `${BASE_URL}${image.path}` is gone. One Nuxt process serves every tenant,
40
+ * so a build-time base URL is wrong here — media goes through useMediaUrl().
41
+ * * The hand-rolled productCache / inFlight Maps are gone. useAsyncData keyed
42
+ * by slug already dedupes, survives SSR->client, and cancels stale results.
43
+ * * Option state (selectedOptions, applyCombinationLogic, isSelected) is
44
+ * delegated to useProductVariants so the card's quick-shop and this page
45
+ * can't drift apart again.
46
+ */
47
+
48
+ export const PRODUCT_DETAIL_KEY = Symbol("osi-product-detail");
49
+
50
+ /* ------------------------------------------------------------------ utils */
51
+
52
+ /** "10-20" -> { min: 10, max: 20 } ; "10" -> { min: 10, max: 10 } */
53
+ export function parsePriceRange(value) {
54
+ if (value === null || value === undefined || value === "") {
55
+ return { min: 0, max: 0 };
56
+ }
57
+ const [rawMin, rawMax] = String(value).split("-").map(Number);
58
+ const min = Number.isFinite(rawMin) ? rawMin : 0;
59
+ const max = Number.isFinite(rawMax) ? rawMax : min;
60
+ return { min, max };
61
+ }
62
+
63
+ /* ------------------------------------------------------------- the source */
64
+
65
+ /**
66
+ * The full product for the current route.
67
+ *
68
+ * Two calls, because the list endpoint is the only one that takes a slug and
69
+ * the detail endpoint is the only one that returns variants and the full
70
+ * gallery. Merged rather than replaced — the list row carries fields
71
+ * (categories, price_range) the detail response doesn't always repeat.
72
+ */
73
+ async function loadProduct(slug) {
74
+ const list = await ProductServices.getProductId(slug);
75
+ const summary = list?.results?.[0];
76
+
77
+ if (!summary?.id) {
78
+ throw createError({
79
+ statusCode: 404,
80
+ statusMessage: "Product not found",
81
+ fatal: true,
82
+ });
83
+ }
84
+
85
+ // A failure here must not 404 a product that exists — the summary alone is
86
+ // enough to render a card-level page.
87
+ const detail = await ProductServices.getProductDetail(summary.id).catch(() => null);
88
+ return detail?.id ? { ...summary, ...detail } : summary;
89
+ }
90
+
91
+ /**
92
+ * Resolve the route's product, SSR included.
93
+ *
94
+ * Call it from the page (awaited, so the payload is in the server render) and
95
+ * again from MainProductSection (not awaited — same key, so it reads the cached
96
+ * payload instead of firing a second request). In the builder there is no route
97
+ * product at all, so it falls back to a placeholder.
98
+ */
99
+ /**
100
+ * @param {object} [options]
101
+ * @param {() => (number|string|object|null)} [options.productId]
102
+ * An explicit product (FEATURED_PRODUCT's picker): an id, or the picked
103
+ * object. When given, the route is ignored and the store's first product is
104
+ * only used while nothing is picked, in the editor.
105
+ */
106
+ export function useProductResource(options = {}) {
107
+ const route = useRoute();
108
+ const explicit = computed(() => {
109
+ const raw = toValue(options.productId ?? null);
110
+ if (raw == null || raw === "") return null;
111
+ return typeof raw === "object" ? raw.id ?? null : raw;
112
+ });
113
+ const isFeatured = options.productId !== undefined;
114
+ const { resource } = useRenderContext();
115
+ const placeholders = usePlaceholders();
116
+
117
+ const slug = computed(
118
+ () =>
119
+ isFeatured ? null :
120
+ route.params.product ||
121
+ route.params.slug ||
122
+ resource.value?.slug ||
123
+ resource.value?.slugified_name ||
124
+ null
125
+ );
126
+
127
+ const key = isFeatured
128
+ ? `product:id:${explicit.value ?? "first"}`
129
+ : `product:${slug.value ?? "preview"}`;
130
+
131
+ // The raw AsyncData is returned as well as its refs: the page awaits it so
132
+ // the product is in the SSR payload, and MainProductSection calls this again
133
+ // without awaiting — same key, so it reads that payload instead of firing a
134
+ // second request.
135
+ /**
136
+ * The dashboard previews the PRODUCT template with no product routed. Rather
137
+ * than the stand-in, show the store's first real product: the merchant is
138
+ * designing against their own photos, title length and variants, which is
139
+ * where a layout actually breaks. The stand-in stays for a store whose
140
+ * catalog is still empty.
141
+ */
142
+ async function loadFirstProduct() {
143
+ const list = await ProductServices.listProducts({ page_size: 1 }).catch(() => null);
144
+ const summary = list?.results?.[0];
145
+ if (!summary?.id) return null;
146
+ const detail = await ProductServices.getProductDetail(summary.id).catch(() => null);
147
+ return detail?.id ? { ...summary, ...detail } : summary;
148
+ }
149
+
150
+ async function loadById(id) {
151
+ const detail = await ProductServices.getProductDetail(id).catch(() => null);
152
+ return detail?.id ? detail : null;
153
+ }
154
+
155
+ const asyncData = useAsyncData(
156
+ key,
157
+ () =>
158
+ explicit.value != null
159
+ ? loadById(explicit.value)
160
+ : slug.value
161
+ ? loadProduct(slug.value)
162
+ : loadFirstProduct(),
163
+ {
164
+ watch: [slug, explicit],
165
+ default: () => null,
166
+ getCachedData: (cacheKey, nuxtApp) =>
167
+ nuxtApp.payload.data[cacheKey] ?? nuxtApp.static.data[cacheKey],
168
+ }
169
+ );
170
+
171
+ const { data, pending, error, refresh } = asyncData;
172
+
173
+ /**
174
+ * A product-shaped stand-in for the editor.
175
+ *
176
+ * Three placeholder rows folded into one so the gallery has something to
177
+ * stack; a single-image product page reads as broken layout rather than as an
178
+ * empty catalog. Never reachable on a live storefront — usePlaceholders only
179
+ * fills when the page is framed by the dashboard.
180
+ */
181
+ const placeholder = computed(() => {
182
+ const rows = placeholders.products([], 3);
183
+ if (!rows.length) return null;
184
+
185
+ const [first, ...rest] = rows;
186
+ return {
187
+ ...first,
188
+ name: "Product title",
189
+ description:
190
+ "<p>This is where the product description will appear. Add one from the " +
191
+ "products area to replace this text.</p>",
192
+ gallery: [
193
+ ...(first.gallery ?? []),
194
+ ...rest.flatMap((row) => row.gallery ?? []),
195
+ ],
196
+ };
197
+ });
198
+
199
+ const product = computed(() => data.value ?? placeholder.value ?? null);
200
+
201
+ // pending is true on the very first client-side navigation before the fetch
202
+ // settles; a placeholder standing in for a real product isn't "loaded".
203
+ const loading = computed(() => pending.value && !data.value);
204
+
205
+ return {
206
+ product,
207
+ loading,
208
+ error,
209
+ refresh,
210
+ slug,
211
+ asyncData,
212
+ isPlaceholder: computed(() => !data.value),
213
+ };
214
+ }
215
+
216
+ /* --------------------------------------------------------------- the state */
217
+
218
+ /**
219
+ * @param {object} options
220
+ * @param {object|Ref|(() => object)} options.product
221
+ * @param {object|Ref|(() => object)} [options.settings] MAIN_PRODUCT settings
222
+ * @param {boolean|Ref} [options.loading]
223
+ */
224
+ export function createProductDetail({ product, settings = {}, loading = false } = {}) {
225
+ const p = computed(() => toValue(product) ?? null);
226
+ const s = computed(() => toValue(settings) ?? {});
227
+ const isLoading = computed(() => !!toValue(loading));
228
+
229
+ const route = useRoute();
230
+ const router = useRouter();
231
+ const { store, cartType } = useStorefront();
232
+ const { mediaUrl } = useMediaUrl();
233
+ const { fetchCart } = useCart();
234
+ const { toggleCartSidebar } = useSidebar();
235
+ const { showApiError } = useApiErrorToast();
236
+ const { policies, fetchPolicies } = usePolicies();
237
+
238
+ const variants = useProductVariants(product);
239
+
240
+ /* ---------------------------------------------------------------- money */
241
+
242
+ const currency = computed(
243
+ () => store.value?.currency_symbol || store.value?.currency_sign || "$"
244
+ );
245
+
246
+ /** Matches the card's convention: "50.00" -> "$50", "49.99" -> "$49.99". */
247
+ /** "50.00" -> "$50". No price (null, empty, 0) -> "" — never "$0". */
248
+ function formatMoney(value) {
249
+ if (value === null || value === undefined || value === "") return "";
250
+ const n = Number(value);
251
+ if (!Number.isFinite(n) || n <= 0) return "";
252
+ return `${currency.value}${Number(n.toFixed(2))}`;
253
+ }
254
+
255
+ function formatRange(range) {
256
+ if (!range) return "";
257
+ const { min, max } = range;
258
+ const low = formatMoney(min);
259
+ const high = formatMoney(max);
260
+ if (!low || !high || min === max) return low || high;
261
+ return `${low} – ${high}`;
262
+ }
263
+
264
+ /* ------------------------------------------------------------- variants */
265
+
266
+ const variantTypes = computed(() => p.value?.vary_by ?? []);
267
+ const allVariants = computed(() => p.value?.product_variants ?? []);
268
+ const selectedValueIds = computed(() => Object.values(variants.selectedOptions.value));
269
+
270
+ const hasOptions = computed(() => variantTypes.value.length > 0);
271
+
272
+ /**
273
+ * Complete when every option type has a value. With no option types that's
274
+ * 0 === 0, i.e. true — which is what lets an option-less product be added.
275
+ */
276
+ const isSelectionComplete = computed(
277
+ () => selectedValueIds.value.length === variantTypes.value.length
278
+ );
279
+
280
+ const missingVariantName = computed(() => {
281
+ const missing = variantTypes.value.find(
282
+ (type) => !variants.selectedOptions.value[type.id]
283
+ );
284
+ return missing?.name?.toLowerCase() ?? "";
285
+ });
286
+
287
+ /** See the header note — no early return on an empty selection. */
288
+ const exactMatchVariant = computed(
289
+ () =>
290
+ allVariants.value.find((variant) =>
291
+ (variant.values ?? []).every((value) =>
292
+ selectedValueIds.value.includes(value.id)
293
+ )
294
+ ) ?? null
295
+ );
296
+
297
+ /** Variants sharing at least one selected value — the partial-selection set. */
298
+ const partialVariants = computed(() => {
299
+ if (!selectedValueIds.value.length) return [];
300
+ return allVariants.value.filter((variant) =>
301
+ (variant.values ?? []).some((value) => selectedValueIds.value.includes(value.id))
302
+ );
303
+ });
304
+
305
+ /**
306
+ * The real variant, or a dummy carrying only a merged gallery while the
307
+ * selection is partial. Image preview reads `.gallery`; anything that adds to
308
+ * a cart must read exactMatchVariant instead.
309
+ */
310
+ const selectedVariant = computed(() => {
311
+ if (exactMatchVariant.value) return exactMatchVariant.value;
312
+ if (!selectedValueIds.value.length) return null;
313
+
314
+ const unique = [];
315
+ partialVariants.value.forEach((variant) => {
316
+ (variant.gallery ?? []).forEach((image) => {
317
+ const id = image?.media?.id ?? image?.id;
318
+ if (!id) return;
319
+ if (unique.some((existing) => (existing?.media?.id ?? existing?.id) === id)) return;
320
+ unique.push(image);
321
+ });
322
+ });
323
+
324
+ // Keep the product's own gallery order — a variant gallery that reorders
325
+ // the same photos makes the column jump on every swatch click.
326
+ const order = (image) => {
327
+ const id = image?.media?.id ?? image?.id;
328
+ return (
329
+ p.value?.gallery?.findIndex(
330
+ (candidate) => (candidate?.media?.id ?? candidate?.id) === id
331
+ ) ?? -1
332
+ );
333
+ };
334
+
335
+ return { gallery: [...unique].sort((a, b) => order(a) - order(b)) };
336
+ });
337
+
338
+ function selectVariant(value) {
339
+ if (value?.isAvailable === false) return;
340
+ variants.selectVariant(value);
341
+ showError.value = false;
342
+ }
343
+
344
+ const isSelected = (value) => variants.isSelected(value);
345
+ const applyCombinationLogic = (typeId) => variants.applyCombinationLogic(typeId);
346
+
347
+ /** Shopify preselects; the original page didn't. Off unless the theme asks. */
348
+ function preselectFirstAvailable() {
349
+ if (!s.value.PRESELECT_FIRST_VARIANT) return;
350
+ if (selectedValueIds.value.length) return;
351
+
352
+ const first = allVariants.value.find(
353
+ (variant) => !variant.track_inventory || variant.remaining_stock > 0
354
+ );
355
+ (first?.values ?? []).forEach((value) => variants.selectVariant(value));
356
+ }
357
+
358
+ /* -------------------------------------------------------------- pricing */
359
+
360
+ /* Quantity lives up here: the price depends on it (quantity-tier sales). */
361
+ const quantityStep = computed(() => Number(p.value?.quantity_step) || 1);
362
+ const quantityUnit = computed(() => p.value?.quantity_unit?.abbreviation ?? "");
363
+ const quantity = ref(quantityStep.value);
364
+
365
+ watch(quantityStep, (step) => {
366
+ quantity.value = step;
367
+ });
368
+
369
+ /**
370
+ * Sales, the way Shopify applies discounts:
371
+ * - a 1-item sale (required_quantity 1) is a markdown on every item — the
372
+ * unit price itself drops ("save $4" → each item $4 less);
373
+ * - a quantity tier (required_quantity 2+) comes off the TOTAL once the
374
+ * quantity reaches it, once per order: "buy 12, save $4" is $4 off the
375
+ * whole line, not per item and not again at 24; a percentage tier is that
376
+ * percentage off the total;
377
+ * - discounts don't combine: the one saving the most at the quantity picked
378
+ * wins (Shopify's default, no combinations).
379
+ * So the unit price only changes for a 1-item sale; a tier shows as its box
380
+ * and in the total line. (The API's discounted_price_range treats tiers as
381
+ * per-item prices, so it isn't used.)
382
+ */
383
+ function activeSales(variant) {
384
+ const now = Date.now();
385
+ return (variant?.sales ?? []).filter(
386
+ (sale) =>
387
+ Number(sale.discount_value) > 0 &&
388
+ (!sale.start_date || Date.parse(sale.start_date) <= now) &&
389
+ (!sale.end_date || Date.parse(sale.end_date) > now)
390
+ );
391
+ }
392
+ const requiredOf = (v) => Math.max(1, Number(Array.isArray(v) ? v[0] : v) || 1);
393
+ const round2 = (n) => Math.round(n * 100) / 100;
394
+
395
+ /**
396
+ * A variant's discounts, each with what it saves on `qty` items:
397
+ * { id, required, perItem, saving(qty), label, sale }.
398
+ */
399
+ function offersOf(variant) {
400
+ const price = Number(variant?.price);
401
+ if (!variant || !(price > 0)) return [];
402
+ const offers = activeSales(variant).map((sale) => {
403
+ const required = requiredOf(sale.required_quantity);
404
+ const pct = sale.sale_type === "Percentage";
405
+ const value = Number(sale.discount_value);
406
+ const perItem = required <= 1;
407
+ const saving = (qty) => {
408
+ if (qty < required) return 0;
409
+ if (pct) return round2(qty * price * Math.min(value, 100) / 100);
410
+ return perItem ? round2(qty * Math.min(value, price)) : Math.min(value, round2(qty * price));
411
+ };
412
+ return {
413
+ id: sale.id, required, perItem, saving,
414
+ label: pct ? `${value}%` : formatMoney(value),
415
+ };
416
+ });
417
+ // The newer `discount` shape: a discounted unit price from a quantity.
418
+ const d = variant.discount;
419
+ if (d?.discounted_price !== undefined && d?.discounted_price !== null && Number(d.discounted_price) < price) {
420
+ const required = requiredOf(d.required_quantity);
421
+ const off = round2(price - Number(d.discounted_price));
422
+ offers.push({
423
+ id: `discount-${variant.id}`, required, perItem: required <= 1,
424
+ saving: (qty) => (qty < required ? 0 : required <= 1 ? round2(qty * off) : off),
425
+ label: d.discount ?? formatMoney(off),
426
+ });
427
+ }
428
+ return offers;
429
+ }
430
+
431
+ /** The discount applied to `qty` of a variant: the biggest saving, or null. */
432
+ function bestOffer(variant, qty) {
433
+ let best = null;
434
+ for (const offer of offersOf(variant)) {
435
+ const saving = offer.saving(qty);
436
+ if (saving > 0 && (!best || saving > best.saving)) best = { ...offer, saving };
437
+ }
438
+ return best;
439
+ }
440
+
441
+ /** A variant at a quantity: unit price shown, and the line total before / after. */
442
+ function lineOf(variant, qty) {
443
+ const price = Number(variant?.price) || 0;
444
+ const best = bestOffer(variant, qty);
445
+ const before = round2(price * qty);
446
+ const after = round2(before - (best?.saving ?? 0));
447
+ // Only a 1-item markdown changes the unit price.
448
+ const unit = best?.perItem ? round2(after / qty) : price;
449
+ // Display only: never a sale on its own.
450
+ const compare = Number(variant?.compare_at_price) || 0;
451
+ return { price, compare, unit, before, after, best };
452
+ }
453
+
454
+ /**
455
+ * The sale boxes under the price: one per quantity tier (2+ items) — a
456
+ * 1-item sale is already in the price, so it gets no box. Each once, fewest
457
+ * items first; `applied` when it's the discount the cart will apply.
458
+ */
459
+ function tierBadges(list, qty, appliedIds) {
460
+ const seen = new Map();
461
+ for (const variant of list ?? []) {
462
+ for (const offer of offersOf(variant)) {
463
+ if (offer.perItem || seen.has(offer.id)) continue;
464
+ seen.set(offer.id, {
465
+ id: offer.id,
466
+ required: offer.required,
467
+ text: `Buy ${offer.required}, save ${offer.label} on the total`,
468
+ reached: qty >= offer.required,
469
+ applied: appliedIds.has(offer.id),
470
+ });
471
+ }
472
+ }
473
+ return [...seen.values()].sort((a, b) => a.required - b.required);
474
+ }
475
+
476
+ /** Products whose discount only comes at product level (no variant sales). */
477
+ function productLevel(product, qty) {
478
+ const original = parsePriceRange(product.price_range);
479
+ const d = product.discounted_price_range;
480
+ if (d && typeof d === "object" && d.discounted_price_range && (d.discount ?? []).length) {
481
+ const required = Math.min(...(d.required_quantity ?? [1]).map((n) => requiredOf(n)));
482
+ const price = typeof d.discounted_price_range === "string" ? parsePriceRange(d.discounted_price_range) : original;
483
+ const perItem = required <= 1;
484
+ return {
485
+ original,
486
+ price: perItem ? price : original,
487
+ badges: perItem
488
+ ? []
489
+ : [{ id: "product", required, text: `Buy ${required}, save ${d.discount[0]} on the total`, reached: qty >= required, applied: qty >= required }],
490
+ total: null,
491
+ };
492
+ }
493
+ return { original, price: original, badges: [], total: null };
494
+ }
495
+
496
+ const pricing = computed(() => {
497
+ const product = p.value;
498
+ const empty = { original: { min: 0, max: 0 }, price: { min: 0, max: 0 }, badges: [], total: null };
499
+ if (!product) return empty;
500
+ const qty = Number(quantity.value) || 1;
501
+
502
+ const list = (exactMatchVariant.value
503
+ ? [exactMatchVariant.value]
504
+ : selectedValueIds.value.length
505
+ ? partialVariants.value
506
+ : allVariants.value
507
+ ).filter((v) => Number(v?.price) > 0);
508
+
509
+ if (!list.length) return productLevel(product, qty);
510
+ if (!exactMatchVariant.value && !list.some((v) => offersOf(v).length || Number(v.compare_at_price) > Number(v.price))) {
511
+ const level = productLevel(product, qty);
512
+ if (level.badges.length || level.price.min !== level.original.min) return level;
513
+ }
514
+
515
+ const lines = list.map((v) => lineOf(v, qty));
516
+ const span = (pick) => ({ min: Math.min(...lines.map(pick)), max: Math.max(...lines.map(pick)) });
517
+ const applied = new Set(lines.map((l) => l.best?.id).filter(Boolean));
518
+
519
+ // The total line: for one price (a variant picked, or every variant the
520
+ // same price), from 2 items.
521
+ const one = lines.every((l) => l.price === lines[0].price && l.after === lines[0].after);
522
+ const first = lines[0];
523
+ const total = one && qty > 1
524
+ ? { quantity: qty, before: first.before, after: first.after, saving: round2(first.before - first.after) }
525
+ : null;
526
+
527
+ return {
528
+ original: span((l) => l.price),
529
+ // What is struck through: compare_at_price when above the price, else the pre-sale price.
530
+ was: lines.some((l) => l.compare > l.unit || l.price > l.unit)
531
+ ? span((l) => (l.compare > l.unit ? l.compare : l.price > l.unit ? l.price : l.unit))
532
+ : null,
533
+ price: span((l) => l.unit),
534
+ badges: tierBadges(list, qty, applied),
535
+ total,
536
+ };
537
+ });
538
+
539
+ const priceRange = computed(() => pricing.value.price);
540
+ const originalPriceRange = computed(() => pricing.value.original);
541
+ /** The sale boxes under the price (tiers of 2+ items), each { id, text, reached, applied }. */
542
+ const discountBadges = computed(() => pricing.value.badges ?? []);
543
+ /**
544
+ * The line total for the quantity picked — { quantity, before, after, saving,
545
+ * text: { before, after, saving } } — or null (1 item, or no single price).
546
+ */
547
+ const lineTotal = computed(() => {
548
+ const t = pricing.value.total;
549
+ if (!t) return null;
550
+ return {
551
+ ...t,
552
+ text: { before: formatMoney(t.before), after: formatMoney(t.after), saving: formatMoney(t.saving) },
553
+ };
554
+ });
555
+ /** The same, as one line (older callers). */
556
+ const discountMessage = computed(() => discountBadges.value.map((b) => b.text).join(" · ") || null);
557
+
558
+ const isOnSale = computed(
559
+ () =>
560
+ originalPriceRange.value.min > 0 &&
561
+ priceRange.value.min > 0 &&
562
+ originalPriceRange.value.min !== priceRange.value.min
563
+ );
564
+
565
+ const savingsPercent = computed(() => {
566
+ if (!isOnSale.value) return 0;
567
+ const was = originalPriceRange.value.min;
568
+ const now = priceRange.value.min;
569
+ return Math.round(((was - now) / was) * 100);
570
+ });
571
+
572
+ const priceText = computed(() => formatRange(priceRange.value));
573
+
574
+ /**
575
+ * Whether the selection has a real price. A product (or variant) with no
576
+ * price — null, empty, 0 — shows no price and can't be bought.
577
+ */
578
+ const hasPrice = computed(() => {
579
+ const r = priceRange.value;
580
+ return Number(r?.max ?? 0) > 0 || Number(r?.min ?? 0) > 0;
581
+ });
582
+ const comparePriceText = computed(() =>
583
+ pricing.value.was
584
+ ? formatRange(pricing.value.was)
585
+ : isOnSale.value
586
+ ? formatRange(originalPriceRange.value)
587
+ : ""
588
+ );
589
+
590
+ /* ---------------------------------------------------------------- stock */
591
+
592
+ const isComingSoon = computed(() => !!p.value?.coming_soon);
593
+
594
+ const isOutOfStock = computed(() => {
595
+ const product = p.value;
596
+ if (!product) return false;
597
+
598
+ // A missing stock count (null) isn't 0 — not sold out.
599
+ const out = (n) => n !== null && n !== undefined && Number(n) <= 0;
600
+ const variant = exactMatchVariant.value;
601
+ if (variant?.id) {
602
+ if (!variant.track_inventory) return false;
603
+ return out(variant.remaining_stock);
604
+ }
605
+
606
+ if (product.status === "In Stock") return false;
607
+ if (product.status === "Out of Stock") return true;
608
+ if (product.status === "On Demand") return false;
609
+
610
+ return out(product.remaining_stock);
611
+ });
612
+
613
+ /** Units left on the current selection, or null when nothing tracks it. */
614
+ const remainingStock = computed(() => {
615
+ const variant = exactMatchVariant.value;
616
+ if (variant?.id) return variant.track_inventory ? variant.remaining_stock : null;
617
+ return p.value?.remaining_stock ?? null;
618
+ });
619
+
620
+ const isLowStock = computed(() => {
621
+ const threshold = Number(s.value.LOW_STOCK_THRESHOLD) || 0;
622
+ if (!threshold || remainingStock.value === null) return false;
623
+ return remainingStock.value > 0 && remainingStock.value <= threshold;
624
+ });
625
+
626
+ const sku = computed(() => exactMatchVariant.value?.sku || p.value?.sku || "");
627
+
628
+ /* --------------------------------------------------------------- images */
629
+
630
+ /**
631
+ * The gallery for the current selection: the variant's own images when there
632
+ * are any, otherwise the product's main image followed by its gallery.
633
+ * Deduped on path — the same photo reaches this list through both routes.
634
+ */
635
+ const activeImages = computed(() => {
636
+ const product = p.value;
637
+ if (!product) return [];
638
+
639
+ let images = [];
640
+ const variantGallery = selectedVariant.value?.gallery;
641
+
642
+ if (variantGallery?.length) {
643
+ images = variantGallery.map((entry) => ({
644
+ id: entry.media?.id ?? entry.id,
645
+ path: entry.media?.path ?? entry.path,
646
+ alt: entry.media?.alt ?? entry.alt ?? product.name,
647
+ }));
648
+ } else {
649
+ if (product.main_image) {
650
+ images.push({
651
+ id: product.main_image.id,
652
+ path: product.main_image.path,
653
+ alt: product.main_image.alt ?? product.name,
654
+ });
655
+ }
656
+ (product.gallery ?? []).forEach((entry) => {
657
+ images.push({
658
+ id: entry.media?.id ?? entry.id,
659
+ path: entry.media?.path ?? entry.path,
660
+ alt: entry.media?.alt ?? entry.alt ?? product.name,
661
+ });
662
+ });
663
+ }
664
+
665
+ const seen = new Set();
666
+ return images.filter((image) => {
667
+ if (!image.path || seen.has(image.path)) return false;
668
+ seen.add(image.path);
669
+ return true;
670
+ });
671
+ });
672
+
673
+ const imageUrl = (image) => mediaUrl(image);
674
+
675
+ /* ------------------------------------------------------------- quantity */
676
+
677
+ // quantityStep / quantityUnit / quantity are declared with the pricing.
678
+
679
+ const maxQuantity = computed(() => {
680
+ const perUser = exactMatchVariant.value?.max_quantity_per_user;
681
+ const stock = remainingStock.value;
682
+ const limits = [perUser, stock].filter((value) => Number.isFinite(Number(value)) && value > 0);
683
+ return limits.length ? Math.min(...limits.map(Number)) : null;
684
+ });
685
+
686
+ function increment() {
687
+ const next = quantity.value + quantityStep.value;
688
+ if (maxQuantity.value && next > maxQuantity.value) return;
689
+ quantity.value = next;
690
+ }
691
+
692
+ function decrement() {
693
+ if (quantity.value > quantityStep.value) quantity.value -= quantityStep.value;
694
+ }
695
+
696
+ function setQuantity(value) {
697
+ const step = quantityStep.value;
698
+ const n = Number(value);
699
+ if (!Number.isFinite(n) || n < step) {
700
+ quantity.value = step;
701
+ return;
702
+ }
703
+ // Snap to the step: a typed 7 on a step of 5 is rejected by the API anyway.
704
+ const snapped = Math.round(n / step) * step;
705
+ quantity.value = maxQuantity.value ? Math.min(snapped, maxQuantity.value) : snapped;
706
+ }
707
+
708
+ /* ----------------------------------------------------------------- cart */
709
+
710
+ const isAddingToCart = ref(false);
711
+ const showError = ref(false);
712
+ let errorTimer = null;
713
+
714
+ function flashSelectionError() {
715
+ showError.value = true;
716
+ clearTimeout(errorTimer);
717
+ errorTimer = setTimeout(() => {
718
+ showError.value = false;
719
+ }, 3000);
720
+ }
721
+
722
+ function openCartUi() {
723
+ if (cartType.value === "PAGE") router.push("/cart");
724
+ else toggleCartSidebar(true);
725
+ }
726
+
727
+ /**
728
+ * @param {object} [options]
729
+ * @param {boolean} [options.buyNow] straight to checkout instead of the drawer
730
+ * @returns {Promise<boolean>} whether the line was added
731
+ */
732
+ async function addToCart({ buyNow = false } = {}) {
733
+ if (isAddingToCart.value) return false;
734
+ if (isComingSoon.value || isOutOfStock.value || !hasPrice.value) return false;
735
+
736
+ if (!isSelectionComplete.value || !exactMatchVariant.value) {
737
+ flashSelectionError();
738
+ return false;
739
+ }
740
+
741
+ if (quantity.value % quantityStep.value !== 0) {
742
+ showApiError(
743
+ { message: `Enter a quantity in multiples of ${quantityStep.value}.` },
744
+ `Enter a quantity in multiples of ${quantityStep.value}.`
745
+ );
746
+ return false;
747
+ }
748
+
749
+ isAddingToCart.value = true;
750
+ try {
751
+ await CartServices.EditCart(exactMatchVariant.value.id, quantity.value, "add", {
752
+ product: p.value,
753
+ variant: exactMatchVariant.value,
754
+ });
755
+ await fetchCart();
756
+
757
+ if (buyNow) router.push("/checkout");
758
+ else openCartUi();
759
+
760
+ return true;
761
+ } catch (error) {
762
+ showApiError(error, "Couldn't add that to your cart.");
763
+ return false;
764
+ } finally {
765
+ isAddingToCart.value = false;
766
+ }
767
+ }
768
+
769
+ const buyDisabled = computed(
770
+ () =>
771
+ isComingSoon.value || isOutOfStock.value || !hasPrice.value || isAddingToCart.value || isLoading.value
772
+ );
773
+
774
+ const buyLabel = computed(() => {
775
+ if (isComingSoon.value) return "Coming soon";
776
+ if (isOutOfStock.value) return "Sold out";
777
+ if (isAddingToCart.value) return "Adding…";
778
+ return null; // the block supplies its own default label
779
+ });
780
+
781
+ /* ---------------------------------------------------------------- share */
782
+
783
+ const stripHtml = (html) => String(html ?? "").replace(/<[^>]*>?/gm, "").trim();
784
+
785
+ function legacyCopy(text) {
786
+ const input = document.createElement("textarea");
787
+ input.value = text;
788
+ input.style.position = "fixed";
789
+ input.style.opacity = "0";
790
+ document.body.appendChild(input);
791
+ input.focus();
792
+ input.select();
793
+ let copied = false;
794
+ try {
795
+ copied = document.execCommand("copy");
796
+ } catch {
797
+ copied = false;
798
+ }
799
+ document.body.removeChild(input);
800
+ return copied;
801
+ }
802
+
803
+ const shareState = ref("");
804
+
805
+ async function shareProduct() {
806
+ if (!import.meta.client) return;
807
+
808
+ const url = window.location.href;
809
+ const data = {
810
+ title: p.value?.name,
811
+ text: stripHtml(p.value?.description),
812
+ url,
813
+ };
814
+
815
+ try {
816
+ if (navigator.share) {
817
+ await navigator.share(data);
818
+ return;
819
+ }
820
+ throw new Error("Web Share unavailable");
821
+ } catch (error) {
822
+ // The shopper closing the native sheet isn't a failure.
823
+ if (error?.name === "AbortError") return;
824
+
825
+ try {
826
+ if (navigator.clipboard?.writeText) {
827
+ await navigator.clipboard.writeText(url);
828
+ } else if (!legacyCopy(url)) {
829
+ throw new Error("Clipboard unavailable");
830
+ }
831
+ shareState.value = "Link copied";
832
+ setTimeout(() => {
833
+ shareState.value = "";
834
+ }, 2000);
835
+ } catch (copyError) {
836
+ showApiError(copyError, "Couldn't share this product.");
837
+ }
838
+ }
839
+ }
840
+
841
+ /* ---------------------------------------------------------- breadcrumbs */
842
+
843
+ const breadcrumbs = computed(() => {
844
+ const categories = p.value?.categories ?? [];
845
+ if (!categories.length) return [];
846
+
847
+ // The route's category segment is the path the shopper actually came
848
+ // through; fall back to the product's first category on a flat URL.
849
+ const routeSlug = route.params.category;
850
+ const matched =
851
+ categories.find((entry) => entry.category?.slugified_name === routeSlug) ??
852
+ categories[0];
853
+
854
+ const category = matched?.category;
855
+ if (!category) return [];
856
+
857
+ const items = [{ name: "Home", path: "/" }];
858
+
859
+ if (category.parent_category) {
860
+ items.push({
861
+ name: category.parent_category.name,
862
+ path: `/categories/${category.parent_category.slugified_name}`,
863
+ });
864
+ }
865
+
866
+ items.push({
867
+ name: category.name,
868
+ path: `/categories/${category.slugified_name}`,
869
+ });
870
+ items.push({ name: p.value?.name });
871
+
872
+ return items;
873
+ });
874
+
875
+ /* -------------------------------------------------------------- lifecycle */
876
+
877
+ function resetSelections() {
878
+ variants.reset();
879
+ quantity.value = quantityStep.value;
880
+ showError.value = false;
881
+ }
882
+
883
+ watch(
884
+ () => p.value?.id,
885
+ () => {
886
+ resetSelections();
887
+ preselectFirstAvailable();
888
+ }
889
+ );
890
+
891
+ onMounted(() => {
892
+ fetchPolicies();
893
+ preselectFirstAvailable();
894
+ });
895
+
896
+ onScopeDispose(() => clearTimeout(errorTimer));
897
+
898
+ return {
899
+ // source
900
+ product: p,
901
+ settings: s,
902
+ loading: isLoading,
903
+
904
+ // options
905
+ variants,
906
+ variantTypes,
907
+ hasOptions,
908
+ selectedOptions: variants.selectedOptions,
909
+ selectedVariant,
910
+ exactMatchVariant,
911
+ isSelected,
912
+ selectVariant,
913
+ applyCombinationLogic,
914
+ isSelectionComplete,
915
+ missingVariantName,
916
+ showError,
917
+ resetSelections,
918
+
919
+ // price
920
+ pricing,
921
+ priceRange,
922
+ originalPriceRange,
923
+ discountMessage,
924
+ discountBadges,
925
+ lineTotal,
926
+ isOnSale,
927
+ savingsPercent,
928
+ priceText,
929
+ hasPrice,
930
+ comparePriceText,
931
+ formatMoney,
932
+ formatRange,
933
+ currency,
934
+
935
+ // stock
936
+ isComingSoon,
937
+ isOutOfStock,
938
+ remainingStock,
939
+ isLowStock,
940
+ sku,
941
+
942
+ // media
943
+ activeImages,
944
+ imageUrl,
945
+
946
+ // quantity
947
+ quantity,
948
+ quantityStep,
949
+ quantityUnit,
950
+ maxQuantity,
951
+ increment,
952
+ decrement,
953
+ setQuantity,
954
+
955
+ // cart
956
+ addToCart,
957
+ isAddingToCart,
958
+ buyDisabled,
959
+ buyLabel,
960
+
961
+ // misc
962
+ policies,
963
+ shareProduct,
964
+ shareState,
965
+ breadcrumbs,
966
+ stripHtml,
967
+ };
968
+ }
969
+
970
+ export function provideProductDetail(context) {
971
+ provide(PRODUCT_DETAIL_KEY, context);
972
+ return context;
973
+ }
974
+
975
+ /**
976
+ * Read the context MainProductSection provided. Returns null outside a product
977
+ * section rather than throwing, so a block dropped somewhere odd renders
978
+ * nothing instead of taking the page down.
979
+ */
980
+ export function useProductDetail() {
981
+ return inject(PRODUCT_DETAIL_KEY, null);
982
+ }