@alphasquad/saleor-template-advance 0.1.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 (441) hide show
  1. package/.env.example +57 -0
  2. package/APPLE_PAY_QUICK_START.md +165 -0
  3. package/APPLE_PAY_SETUP.md +331 -0
  4. package/README.md +46 -0
  5. package/SEO_AUDIT_CHECKLIST_STATUS.md +244 -0
  6. package/SEO_AUDIT_REPORT.md +66 -0
  7. package/eslint.config.mjs +16 -0
  8. package/next-env.d.ts +5 -0
  9. package/next.config.ts +109 -0
  10. package/package.json +47 -0
  11. package/postcss.config.mjs +5 -0
  12. package/public/.well-known/apple-developer-merchantid-domain-association +1 -0
  13. package/public/Logo.png +0 -0
  14. package/public/brand-video.mp4 +0 -0
  15. package/public/favicon.ico +0 -0
  16. package/public/file.svg +1 -0
  17. package/public/footer/facebook.tsx +34 -0
  18. package/public/footer/instagram.tsx +27 -0
  19. package/public/footer/mail.tsx +5 -0
  20. package/public/footer/x.tsx +35 -0
  21. package/public/globe.svg +1 -0
  22. package/public/icons/Authorize.net.webp +0 -0
  23. package/public/icons/amex.gif +0 -0
  24. package/public/icons/appIcon.png +0 -0
  25. package/public/icons/discover.gif +0 -0
  26. package/public/icons/master.gif +0 -0
  27. package/public/icons/paypal.png +0 -0
  28. package/public/icons/stripe.png +0 -0
  29. package/public/icons/visa.gif +0 -0
  30. package/public/images/BackgroundNoise.png +0 -0
  31. package/public/images/footer-background.png +0 -0
  32. package/public/next.svg +1 -0
  33. package/public/no-image-avail-large.png +0 -0
  34. package/public/random-car-1.jpeg +0 -0
  35. package/public/random-car-2.png +0 -0
  36. package/public/random-car-3.jpg +0 -0
  37. package/public/random-car-4.jpg +0 -0
  38. package/public/random-car-5.jpg +0 -0
  39. package/public/star.svg +3 -0
  40. package/public/vercel.svg +1 -0
  41. package/public/window.svg +1 -0
  42. package/scripts/seo-audit/generate-checklist.mjs +156 -0
  43. package/src/app/(auth)/account/forgot-password/layout.tsx +16 -0
  44. package/src/app/(auth)/account/forgot-password/page.tsx +135 -0
  45. package/src/app/(auth)/account/login/layout.tsx +16 -0
  46. package/src/app/(auth)/account/login/page.tsx +288 -0
  47. package/src/app/(auth)/account/otp/layout.tsx +16 -0
  48. package/src/app/(auth)/account/otp/page.tsx +108 -0
  49. package/src/app/(auth)/account/register/layout.tsx +16 -0
  50. package/src/app/(auth)/account/register/page.tsx +431 -0
  51. package/src/app/(auth)/account/reset-password/layout.tsx +16 -0
  52. package/src/app/(auth)/account/reset-password/page.tsx +222 -0
  53. package/src/app/[slug]/page.tsx +43 -0
  54. package/src/app/about/loading.tsx +17 -0
  55. package/src/app/about/page.tsx +61 -0
  56. package/src/app/account/address/layout.tsx +15 -0
  57. package/src/app/account/address/page.tsx +166 -0
  58. package/src/app/account/head.tsx +4 -0
  59. package/src/app/account/layout.tsx +62 -0
  60. package/src/app/account/orders/[id]/layout.tsx +17 -0
  61. package/src/app/account/orders/[id]/page.tsx +115 -0
  62. package/src/app/account/orders/components/orderDetailsModal.tsx +410 -0
  63. package/src/app/account/orders/layout.tsx +15 -0
  64. package/src/app/account/orders/page.tsx +146 -0
  65. package/src/app/account/page.tsx +39 -0
  66. package/src/app/account/settings/components/editProfileSuccessModal.tsx +28 -0
  67. package/src/app/account/settings/layout.tsx +15 -0
  68. package/src/app/account/settings/page.tsx +260 -0
  69. package/src/app/api/affirm/check-status/route.ts +94 -0
  70. package/src/app/api/affirm/create-checkout/route.ts +109 -0
  71. package/src/app/api/affirm/get-config/route.ts +108 -0
  72. package/src/app/api/affirm/process-payment/route.ts +244 -0
  73. package/src/app/api/affirm/test-connection/route.ts +45 -0
  74. package/src/app/api/auth/clear/route.ts +16 -0
  75. package/src/app/api/auth/clear-cookies/route.ts +42 -0
  76. package/src/app/api/auth/set/route.ts +47 -0
  77. package/src/app/api/configuration/route.ts +18 -0
  78. package/src/app/api/dynamic-page/[slug]/route.ts +24 -0
  79. package/src/app/api/form-submission/route.ts +237 -0
  80. package/src/app/api/paypal/capture-order/route.ts +303 -0
  81. package/src/app/api/paypal/create-order/route.ts +211 -0
  82. package/src/app/api/paypal/get-config/route.ts +240 -0
  83. package/src/app/api/search-proxy/route.ts +52 -0
  84. package/src/app/authorize-net-success/layout.tsx +19 -0
  85. package/src/app/authorize-net-success/page.tsx +12 -0
  86. package/src/app/authorize-net-success/summary.tsx +486 -0
  87. package/src/app/blog/[slug]/blogContentRenderer.tsx +369 -0
  88. package/src/app/blog/[slug]/layout.tsx +17 -0
  89. package/src/app/blog/[slug]/page.tsx +151 -0
  90. package/src/app/blog/constant.tsx +147 -0
  91. package/src/app/blog/layout.tsx +31 -0
  92. package/src/app/blog/page.tsx +81 -0
  93. package/src/app/brand/[id]/BrandPageClient.tsx +188 -0
  94. package/src/app/brand/[id]/layout.tsx +17 -0
  95. package/src/app/brand/[id]/page.tsx +176 -0
  96. package/src/app/brands/components/brandsListingClient.tsx +97 -0
  97. package/src/app/brands/layout.tsx +31 -0
  98. package/src/app/brands/page.tsx +40 -0
  99. package/src/app/cancellation-policy/page.tsx +53 -0
  100. package/src/app/cart/layout.tsx +19 -0
  101. package/src/app/cart/page.tsx +752 -0
  102. package/src/app/category/[slug]/CategoryPageClient.tsx +377 -0
  103. package/src/app/category/[slug]/layout.tsx +17 -0
  104. package/src/app/category/[slug]/page.tsx +224 -0
  105. package/src/app/category/page.tsx +114 -0
  106. package/src/app/checkout/components/addNewAddressModal.tsx +474 -0
  107. package/src/app/checkout/layout.tsx +19 -0
  108. package/src/app/checkout/page.tsx +3312 -0
  109. package/src/app/components/account/AccountTabs.tsx +40 -0
  110. package/src/app/components/ads/GoogleAdSense.tsx +74 -0
  111. package/src/app/components/analytics/AnalyticsScripts.tsx +78 -0
  112. package/src/app/components/analytics/ConditionalGTMNoscript.tsx +24 -0
  113. package/src/app/components/analytics/ConditionalGoogleAnalytics.tsx +16 -0
  114. package/src/app/components/ancillary/AncillaryContent.tsx +7 -0
  115. package/src/app/components/auth/TokenExpirationHandler.tsx +8 -0
  116. package/src/app/components/blog/BlogList.tsx +112 -0
  117. package/src/app/components/checkout/AddressInformationSection.tsx +34 -0
  118. package/src/app/components/checkout/AddressManagement.tsx +571 -0
  119. package/src/app/components/checkout/CheckoutHeader.tsx +51 -0
  120. package/src/app/components/checkout/CheckoutQuestions.tsx +454 -0
  121. package/src/app/components/checkout/CheckoutTermsModal.tsx +81 -0
  122. package/src/app/components/checkout/ContactDetailsSection.tsx +52 -0
  123. package/src/app/components/checkout/DealerShippingSection.tsx +359 -0
  124. package/src/app/components/checkout/DeliveryMethodSection.tsx +249 -0
  125. package/src/app/components/checkout/OrderSummary.tsx +386 -0
  126. package/src/app/components/checkout/TermsContentRenderer.tsx +147 -0
  127. package/src/app/components/checkout/WillCallSection.tsx +133 -0
  128. package/src/app/components/checkout/affirmPayment.tsx +383 -0
  129. package/src/app/components/checkout/checkoutProcessingModal.tsx +96 -0
  130. package/src/app/components/checkout/googlePayButton.tsx +334 -0
  131. package/src/app/components/checkout/paymentStep.tsx +180 -0
  132. package/src/app/components/checkout/paypalPayment.tsx +1083 -0
  133. package/src/app/components/checkout/saleorNativePayment.tsx +1758 -0
  134. package/src/app/components/dynamicPage/DynamicPageRenderer.tsx +13 -0
  135. package/src/app/components/dynamicPage/HtmlWidgetRenderer.tsx +144 -0
  136. package/src/app/components/filtersCollapsible/index.tsx +365 -0
  137. package/src/app/components/globalSearch/index.tsx +423 -0
  138. package/src/app/components/layout/cartDropDown.tsx +628 -0
  139. package/src/app/components/layout/components/FooterNewsletter.tsx +21 -0
  140. package/src/app/components/layout/footer.tsx +283 -0
  141. package/src/app/components/layout/header/accountMenuDropdown.tsx +53 -0
  142. package/src/app/components/layout/header/components/CartBadge.tsx +18 -0
  143. package/src/app/components/layout/header/components/LoadingState.tsx +17 -0
  144. package/src/app/components/layout/header/components/MenuItemDropdown.tsx +124 -0
  145. package/src/app/components/layout/header/components/MobileNavbar.tsx +123 -0
  146. package/src/app/components/layout/header/components/NavbarActions.tsx +125 -0
  147. package/src/app/components/layout/header/components/NavbarBrand.tsx +29 -0
  148. package/src/app/components/layout/header/components/NavigationLinks.tsx +131 -0
  149. package/src/app/components/layout/header/hamMenuSlide.tsx +318 -0
  150. package/src/app/components/layout/header/header.tsx +44 -0
  151. package/src/app/components/layout/header/hooks/useDropdown.ts +45 -0
  152. package/src/app/components/layout/header/hooks/useNavbarData.ts +138 -0
  153. package/src/app/components/layout/header/hooks/useNavbarState.ts +66 -0
  154. package/src/app/components/layout/header/megaMenuDropdown.tsx +116 -0
  155. package/src/app/components/layout/header/navBar.tsx +121 -0
  156. package/src/app/components/layout/header/search.tsx +418 -0
  157. package/src/app/components/layout/header/styles/navbarStyles.ts +27 -0
  158. package/src/app/components/layout/header/topBar.tsx +214 -0
  159. package/src/app/components/layout/joinNewsletterForm/index.tsx +72 -0
  160. package/src/app/components/layout/mobileAccordian/index.tsx +92 -0
  161. package/src/app/components/layout/paymentMethods.tsx +75 -0
  162. package/src/app/components/layout/rootLayout.tsx +23 -0
  163. package/src/app/components/layout/siteInfo.tsx +103 -0
  164. package/src/app/components/layout/socialLinks.tsx +65 -0
  165. package/src/app/components/newsletterSection/emailListSection.tsx +224 -0
  166. package/src/app/components/newsletterSection/emailSectionServer.tsx +8 -0
  167. package/src/app/components/providers/ApolloWrapper.tsx +12 -0
  168. package/src/app/components/providers/AppConfigurationProvider.tsx +108 -0
  169. package/src/app/components/providers/GoogleAnalyticsProvider.tsx +149 -0
  170. package/src/app/components/providers/GoogleTagManagerProvider.tsx +31 -0
  171. package/src/app/components/providers/RecaptchaProvider.tsx +18 -0
  172. package/src/app/components/providers/ServerAppConfigurationProvider.tsx +133 -0
  173. package/src/app/components/providers/YMMStatusProvider.tsx +15 -0
  174. package/src/app/components/reuseableUI/AboutUs.tsx +115 -0
  175. package/src/app/components/reuseableUI/AddToCartClient.tsx +125 -0
  176. package/src/app/components/reuseableUI/EditorJsRenderer.tsx +219 -0
  177. package/src/app/components/reuseableUI/HeroSectionsearchByVehicle.tsx +188 -0
  178. package/src/app/components/reuseableUI/ImageWithFallback.tsx +41 -0
  179. package/src/app/components/reuseableUI/Toast.tsx +101 -0
  180. package/src/app/components/reuseableUI/blogCard.tsx +52 -0
  181. package/src/app/components/reuseableUI/brandCard.tsx +68 -0
  182. package/src/app/components/reuseableUI/breadcrumb.tsx +38 -0
  183. package/src/app/components/reuseableUI/categoryCard.tsx +37 -0
  184. package/src/app/components/reuseableUI/categorySkeleton.tsx +31 -0
  185. package/src/app/components/reuseableUI/commonButton.tsx +48 -0
  186. package/src/app/components/reuseableUI/defaultInputField/index.tsx +84 -0
  187. package/src/app/components/reuseableUI/emptyState.tsx +29 -0
  188. package/src/app/components/reuseableUI/errorTag.tsx +15 -0
  189. package/src/app/components/reuseableUI/heading/index.tsx +20 -0
  190. package/src/app/components/reuseableUI/input.tsx +117 -0
  191. package/src/app/components/reuseableUI/listCard.tsx +137 -0
  192. package/src/app/components/reuseableUI/loadingUI.tsx +12 -0
  193. package/src/app/components/reuseableUI/modalLayout.tsx +76 -0
  194. package/src/app/components/reuseableUI/newsletter/newsletterClient.tsx +622 -0
  195. package/src/app/components/reuseableUI/newsletter/newslettersHomeModal.tsx +68 -0
  196. package/src/app/components/reuseableUI/offerCard.tsx +42 -0
  197. package/src/app/components/reuseableUI/passwordRules/passwordRules.tsx +56 -0
  198. package/src/app/components/reuseableUI/primaryButton/index.tsx +34 -0
  199. package/src/app/components/reuseableUI/productCard.tsx +118 -0
  200. package/src/app/components/reuseableUI/productSkeleton.tsx +34 -0
  201. package/src/app/components/reuseableUI/searchByVehicle.tsx +187 -0
  202. package/src/app/components/reuseableUI/secondaryButton/index.tsx +34 -0
  203. package/src/app/components/reuseableUI/section.tsx +20 -0
  204. package/src/app/components/reuseableUI/select/index.tsx +98 -0
  205. package/src/app/components/reuseableUI/skeletonLoader.tsx +117 -0
  206. package/src/app/components/reuseableUI/statusTag.tsx +24 -0
  207. package/src/app/components/reuseableUI/tags/saleTag.tsx +19 -0
  208. package/src/app/components/reuseableUI/testimonialCard.tsx +93 -0
  209. package/src/app/components/richText/EditorRenderer.tsx +318 -0
  210. package/src/app/components/search/HierarchicalCategoryFilter.tsx +155 -0
  211. package/src/app/components/search/SearchFilters.tsx +155 -0
  212. package/src/app/components/search/YMMSearchSidebar.tsx +187 -0
  213. package/src/app/components/seo/ServerProductCard.tsx +91 -0
  214. package/src/app/components/seo/ServerProductGrid.tsx +45 -0
  215. package/src/app/components/shop/CategoryFilter.tsx +184 -0
  216. package/src/app/components/shop/ItemsPerPageSelect.tsx +69 -0
  217. package/src/app/components/shop/ItemsPerPageSelectClient.tsx +58 -0
  218. package/src/app/components/shop/MobileFilters.tsx +103 -0
  219. package/src/app/components/shop/ProductGridSkeleton.tsx +16 -0
  220. package/src/app/components/shop/ProductsGrid.tsx +230 -0
  221. package/src/app/components/shop/SearchFilter.tsx +218 -0
  222. package/src/app/components/shop/SearchFilterClient.tsx +122 -0
  223. package/src/app/components/shop/SearchLoadingOverlay.tsx +32 -0
  224. package/src/app/components/shop/ShopMobileFilters.tsx +205 -0
  225. package/src/app/components/showroom/VehicleSearchDropdowns.tsx +187 -0
  226. package/src/app/components/showroom/brandsSwiper.tsx +49 -0
  227. package/src/app/components/showroom/brandsSwiperClient copy.tsx +93 -0
  228. package/src/app/components/showroom/brandsSwiperClient.tsx +122 -0
  229. package/src/app/components/showroom/brandsSwiperServer.tsx +42 -0
  230. package/src/app/components/showroom/bundleProducts.tsx +120 -0
  231. package/src/app/components/showroom/categoryGrid.tsx +51 -0
  232. package/src/app/components/showroom/categoryGridServer.tsx +45 -0
  233. package/src/app/components/showroom/categorySwiper.tsx +115 -0
  234. package/src/app/components/showroom/featureStrip.tsx +139 -0
  235. package/src/app/components/showroom/offersSwiper.tsx +181 -0
  236. package/src/app/components/showroom/productGrid.tsx +56 -0
  237. package/src/app/components/showroom/productSwiper.tsx +119 -0
  238. package/src/app/components/showroom/promotion-slider.tsx +138 -0
  239. package/src/app/components/showroom/promotion.tsx +207 -0
  240. package/src/app/components/showroom/promotionsSwiper.tsx +174 -0
  241. package/src/app/components/showroom/showroomHeroCarousel.tsx +141 -0
  242. package/src/app/components/showroom/testimonialsGrid.tsx +106 -0
  243. package/src/app/components/skeletons/ContentSkeleton.tsx +14 -0
  244. package/src/app/components/sortDropdown/index.tsx +116 -0
  245. package/src/app/components/tertiaryButton/index.tsx +25 -0
  246. package/src/app/components/theme/theme-provider.tsx +82 -0
  247. package/src/app/contact/layout.tsx +32 -0
  248. package/src/app/contact/page.tsx +591 -0
  249. package/src/app/content/[slug]/layout.tsx +17 -0
  250. package/src/app/content/[slug]/page.tsx +159 -0
  251. package/src/app/content/layout.tsx +31 -0
  252. package/src/app/content/page.tsx +88 -0
  253. package/src/app/core-policies/page.tsx +55 -0
  254. package/src/app/discounts/page.tsx +54 -0
  255. package/src/app/frequently-asked-questions/page.tsx +57 -0
  256. package/src/app/globals.css +440 -0
  257. package/src/app/hooks/useDealerLocations.ts +259 -0
  258. package/src/app/hooks/useGTMEngagement.ts +71 -0
  259. package/src/app/hooks/useGoogleAnalytics.ts +145 -0
  260. package/src/app/layout.tsx +149 -0
  261. package/src/app/not-found.tsx +31 -0
  262. package/src/app/order-confirmation/layout.tsx +19 -0
  263. package/src/app/order-confirmation/page.tsx +12 -0
  264. package/src/app/order-confirmation/summary.tsx +1775 -0
  265. package/src/app/page.tsx +194 -0
  266. package/src/app/privacy-policy/loading.tsx +17 -0
  267. package/src/app/privacy-policy/page.tsx +56 -0
  268. package/src/app/product/[id]/ProductDetailClient.tsx +2448 -0
  269. package/src/app/product/[id]/components/itemInquiryModal.tsx +461 -0
  270. package/src/app/product/[id]/layout.tsx +116 -0
  271. package/src/app/product/[id]/page.tsx +200 -0
  272. package/src/app/product/layout.tsx +15 -0
  273. package/src/app/products/all/AllProductsClient.tsx +743 -0
  274. package/src/app/products/all/page.tsx +176 -0
  275. package/src/app/products/components/shopEmptyState.tsx +29 -0
  276. package/src/app/request-return/layout.tsx +36 -0
  277. package/src/app/request-return/page.tsx +597 -0
  278. package/src/app/robots.txt/route.ts +27 -0
  279. package/src/app/search/layout.tsx +16 -0
  280. package/src/app/search/page.tsx +736 -0
  281. package/src/app/shipping-returns/page.tsx +60 -0
  282. package/src/app/site-map/layout.tsx +33 -0
  283. package/src/app/site-map/page.tsx +113 -0
  284. package/src/app/sitemap-index.xml/route.ts +20 -0
  285. package/src/app/sitemap.ts +10 -0
  286. package/src/app/terms-and-conditions/loading.tsx +17 -0
  287. package/src/app/terms-and-conditions/page.tsx +56 -0
  288. package/src/app/utils/appConfiguration.ts +327 -0
  289. package/src/app/utils/branding.ts +52 -0
  290. package/src/app/utils/configurationService.ts +202 -0
  291. package/src/app/utils/constant.tsx +242 -0
  292. package/src/app/utils/editorJsUtils.tsx +249 -0
  293. package/src/app/utils/functions.ts +146 -0
  294. package/src/app/utils/googleAnalytics.ts +168 -0
  295. package/src/app/utils/googleTagManager.ts +475 -0
  296. package/src/app/utils/ipDetection.ts +270 -0
  297. package/src/app/utils/serverConfigurationService.ts +209 -0
  298. package/src/app/utils/svgs/GridIcon.tsx +45 -0
  299. package/src/app/utils/svgs/account/myAccount/listDotIcon.tsx +3 -0
  300. package/src/app/utils/svgs/account/myAccount/tickIcon.tsx +10 -0
  301. package/src/app/utils/svgs/account/orderHistory/InfoIcon.tsx +49 -0
  302. package/src/app/utils/svgs/arrowDownIcon.tsx +17 -0
  303. package/src/app/utils/svgs/arrowIcon.tsx +25 -0
  304. package/src/app/utils/svgs/arrowUpIcon.tsx +16 -0
  305. package/src/app/utils/svgs/brandsSearchIcon.tsx +25 -0
  306. package/src/app/utils/svgs/cart/cartIcon.tsx +31 -0
  307. package/src/app/utils/svgs/cart/plusIcon.tsx +13 -0
  308. package/src/app/utils/svgs/cart/subtractIcon.tsx +13 -0
  309. package/src/app/utils/svgs/cart/successTickIcon.tsx +14 -0
  310. package/src/app/utils/svgs/chevronDownIcon.tsx +21 -0
  311. package/src/app/utils/svgs/closeEyeIcon.tsx +47 -0
  312. package/src/app/utils/svgs/crossIcon.tsx +25 -0
  313. package/src/app/utils/svgs/eyeIcon.tsx +29 -0
  314. package/src/app/utils/svgs/featureTag.tsx +20 -0
  315. package/src/app/utils/svgs/filterIcon.tsx +3 -0
  316. package/src/app/utils/svgs/globleIcon.tsx +41 -0
  317. package/src/app/utils/svgs/infoIcon.tsx +34 -0
  318. package/src/app/utils/svgs/listIcon.tsx +50 -0
  319. package/src/app/utils/svgs/logOutIcon.tsx +35 -0
  320. package/src/app/utils/svgs/menuIcon.tsx +8 -0
  321. package/src/app/utils/svgs/minusIcon.tsx +18 -0
  322. package/src/app/utils/svgs/newsletterIcon.tsx +19 -0
  323. package/src/app/utils/svgs/noDataFoundIcon-.tsx +26 -0
  324. package/src/app/utils/svgs/noProductFoundIcon.tsx +43 -0
  325. package/src/app/utils/svgs/passwordIcons/errorIcon.tsx +31 -0
  326. package/src/app/utils/svgs/passwordIcons/successIcon.tsx +24 -0
  327. package/src/app/utils/svgs/paymentProcessingIcons/hourglassIcon.tsx +43 -0
  328. package/src/app/utils/svgs/paymentProcessingIcons/modalCrossIcon.tsx +23 -0
  329. package/src/app/utils/svgs/paymentProcessingIcons/paymentFailedIcon.tsx +47 -0
  330. package/src/app/utils/svgs/pencilIcon.tsx +11 -0
  331. package/src/app/utils/svgs/plusIcon.tsx +25 -0
  332. package/src/app/utils/svgs/productInquiryIcon.tsx +40 -0
  333. package/src/app/utils/svgs/searchIcon.tsx +31 -0
  334. package/src/app/utils/svgs/shoppingCart.tsx +32 -0
  335. package/src/app/utils/svgs/spinnerIcon.tsx +22 -0
  336. package/src/app/utils/svgs/spinnerLoadingIcon.tsx +26 -0
  337. package/src/app/utils/svgs/successTickIcon.tsx +40 -0
  338. package/src/app/utils/svgs/swiperArrowIconLeft.tsx +18 -0
  339. package/src/app/utils/svgs/swiperArrowIconRight.tsx +18 -0
  340. package/src/app/utils/svgs/userProfileIcon.tsx +31 -0
  341. package/src/app/utils/svgs/warningCircleIcon.tsx +15 -0
  342. package/src/app/warranty/constant.tsx +63 -0
  343. package/src/app/warranty/loading.tsx +17 -0
  344. package/src/app/warranty/page.tsx +56 -0
  345. package/src/graphql/client.ts +288 -0
  346. package/src/graphql/mutations/accountAddressCreate.ts +56 -0
  347. package/src/graphql/mutations/accountAddressDelete.ts +23 -0
  348. package/src/graphql/mutations/accountAddressUpdate.ts +55 -0
  349. package/src/graphql/mutations/accountSetDefaultAddress.ts +32 -0
  350. package/src/graphql/mutations/accountUpdate.ts +34 -0
  351. package/src/graphql/mutations/changePassword.ts +25 -0
  352. package/src/graphql/mutations/checkout.ts +117 -0
  353. package/src/graphql/mutations/checkoutAddVoucher.ts +63 -0
  354. package/src/graphql/mutations/checkoutComplete.ts +79 -0
  355. package/src/graphql/mutations/checkoutCreate.ts +131 -0
  356. package/src/graphql/mutations/checkoutCustomerAttach.ts +50 -0
  357. package/src/graphql/mutations/checkoutEmailUpdate.ts +15 -0
  358. package/src/graphql/mutations/checkoutLineMetadataUpdate.ts +52 -0
  359. package/src/graphql/mutations/checkoutPaymentCreate.ts +82 -0
  360. package/src/graphql/mutations/paymentGatewayInitialize.ts +58 -0
  361. package/src/graphql/mutations/registerAccount.ts +65 -0
  362. package/src/graphql/mutations/requestPasswordReset.ts +32 -0
  363. package/src/graphql/mutations/setPassword.ts +49 -0
  364. package/src/graphql/mutations/signIn.ts +50 -0
  365. package/src/graphql/mutations/tokenRefresh.ts +19 -0
  366. package/src/graphql/mutations/updateCheckoutMetadata.ts +49 -0
  367. package/src/graphql/mutations/updateProfile.ts +18 -0
  368. package/src/graphql/mutations/willCallDeliveryMethod.ts +81 -0
  369. package/src/graphql/queries/checkout.ts +168 -0
  370. package/src/graphql/queries/findProductByOldSlug.ts +58 -0
  371. package/src/graphql/queries/getAboutPage.ts +24 -0
  372. package/src/graphql/queries/getAboutPageId.ts +9 -0
  373. package/src/graphql/queries/getAboutUs.ts +38 -0
  374. package/src/graphql/queries/getAddressInformation.ts +38 -0
  375. package/src/graphql/queries/getAllCategories.ts +41 -0
  376. package/src/graphql/queries/getAllCategoriesTree.ts +67 -0
  377. package/src/graphql/queries/getAllCategoriesWithProducts.ts +29 -0
  378. package/src/graphql/queries/getAllCollectionsWithProducts.ts +16 -0
  379. package/src/graphql/queries/getBlogs.ts +222 -0
  380. package/src/graphql/queries/getBrands.ts +17 -0
  381. package/src/graphql/queries/getBundles.ts +43 -0
  382. package/src/graphql/queries/getCategories.ts +20 -0
  383. package/src/graphql/queries/getChannels.ts +77 -0
  384. package/src/graphql/queries/getCheckoutQuestions.ts +115 -0
  385. package/src/graphql/queries/getCheckoutTermsAndConditions.ts +37 -0
  386. package/src/graphql/queries/getContactPage.ts +117 -0
  387. package/src/graphql/queries/getContentPage.ts +191 -0
  388. package/src/graphql/queries/getDiscountOffers.ts +18 -0
  389. package/src/graphql/queries/getDynamicPageBySlug.ts +251 -0
  390. package/src/graphql/queries/getFeaturedProducts.ts +48 -0
  391. package/src/graphql/queries/getHeroMetadata.ts +23 -0
  392. package/src/graphql/queries/getMenuBySlug.ts +84 -0
  393. package/src/graphql/queries/getMyProfile.ts +23 -0
  394. package/src/graphql/queries/getNewsletter.ts +122 -0
  395. package/src/graphql/queries/getNewsletterPage.ts +111 -0
  396. package/src/graphql/queries/getPageBySlug.ts +52 -0
  397. package/src/graphql/queries/getPageTypeId.ts +27 -0
  398. package/src/graphql/queries/getPaymentMethods.ts +61 -0
  399. package/src/graphql/queries/getProducts.ts +78 -0
  400. package/src/graphql/queries/getPromotions.ts +24 -0
  401. package/src/graphql/queries/getRequestReturnPage.ts +121 -0
  402. package/src/graphql/queries/getSiteInfo.ts +54 -0
  403. package/src/graphql/queries/getSocialLinks.ts +52 -0
  404. package/src/graphql/queries/getTestimonials.ts +25 -0
  405. package/src/graphql/queries/getUserWithCheckout.ts +27 -0
  406. package/src/graphql/queries/getVehicleMakes.ts +21 -0
  407. package/src/graphql/queries/getVehicleModels.ts +21 -0
  408. package/src/graphql/queries/getVehicleYears.ts +21 -0
  409. package/src/graphql/queries/meAddresses.ts +56 -0
  410. package/src/graphql/queries/myOrders.ts +37 -0
  411. package/src/graphql/queries/orderDetail.ts +231 -0
  412. package/src/graphql/queries/productDetailsById.ts +197 -0
  413. package/src/graphql/queries/productInquiry.ts +115 -0
  414. package/src/graphql/queries/productsByCategoriesAndCollections.ts +39 -0
  415. package/src/graphql/queries/willCallCollectionPoints.ts +55 -0
  416. package/src/graphql/server-client.ts +54 -0
  417. package/src/graphql/types/categories.ts +9 -0
  418. package/src/graphql/types/checkout.ts +168 -0
  419. package/src/graphql/types/offer.ts +12 -0
  420. package/src/graphql/types/product.ts +44 -0
  421. package/src/hooks/scrollPageTop.ts +9 -0
  422. package/src/hooks/serverNavbarData.ts +79 -0
  423. package/src/hooks/useCartSync.ts +24 -0
  424. package/src/hooks/useRecaptcha.ts +33 -0
  425. package/src/hooks/useTokenExpiration.ts +81 -0
  426. package/src/hooks/useVehicleData.ts +346 -0
  427. package/src/lib/api/kount.ts +165 -0
  428. package/src/lib/api/shop.ts +1445 -0
  429. package/src/lib/saleor/getSaleorApiUrl.ts +25 -0
  430. package/src/lib/schema.ts +303 -0
  431. package/src/lib/seo/extractTextFromEditorJs.ts +58 -0
  432. package/src/lib/seo/site.ts +10 -0
  433. package/src/lib/urls/normalizeInternalUrl.ts +53 -0
  434. package/src/middleware.ts +134 -0
  435. package/src/sitemaps/README.md +105 -0
  436. package/src/sitemaps/dynamic-pages-sitemap.ts +247 -0
  437. package/src/sitemaps/sitemap-index.ts +21 -0
  438. package/src/sitemaps/static-pages-sitemap.ts +36 -0
  439. package/src/store/useGlobalStore.tsx +1656 -0
  440. package/src/types/global.d.ts +148 -0
  441. package/tsconfig.json +27 -0
@@ -0,0 +1,2448 @@
1
+ "use client";
2
+
3
+ import Breadcrumb from "@/app/components/reuseableUI/breadcrumb";
4
+ import CommonButton from "@/app/components/reuseableUI/commonButton";
5
+ import PrimaryButton from "@/app/components/reuseableUI/primaryButton";
6
+ import { SkeletonLoader } from "@/app/components/reuseableUI/skeletonLoader";
7
+ import Toast from "@/app/components/reuseableUI/Toast";
8
+ import { MinusIcon } from "@/app/utils/svgs/minusIcon";
9
+ import { PlusIcon } from "@/app/utils/svgs/plusIcon";
10
+ import { SpinnerIcon } from "@/app/utils/svgs/spinnerIcon";
11
+ import { CHECKOUT_CREATE } from "@/graphql/mutations/checkoutCreate";
12
+ import {
13
+ ME_ADDRESSES_QUERY,
14
+ type MeAddressesData,
15
+ } from "@/graphql/queries/meAddresses";
16
+ import {
17
+ PRODUCT_DETAILS_BY_ID,
18
+ type ProductDetailsByIdData,
19
+ type ProductDetailsByIdVars,
20
+ type ProductVariant,
21
+ } from "@/graphql/queries/productDetailsById";
22
+ import {
23
+ UPDATE_CHECKOUT_LINE_METADATA,
24
+ type MetadataInput,
25
+ } from "@/graphql/mutations/checkoutLineMetadataUpdate";
26
+ import {
27
+ FIND_PRODUCT_BY_OLD_SLUG,
28
+ type FindProductByOldSlugData,
29
+ type FindProductByOldSlugVars,
30
+ } from "@/graphql/queries/findProductByOldSlug";
31
+ import { PRODUCTS_BY_CATEGORIES_AND_COLLECTIONS } from "@/graphql/queries/productsByCategoriesAndCollections";
32
+ import { ProductCard } from "@/app/components/reuseableUI/productCard";
33
+ import { useGlobalStore, type CartItemOption } from "@/store/useGlobalStore";
34
+ import { useQuery } from "@apollo/client";
35
+ import Image from "next/image";
36
+ import {
37
+ useParams,
38
+ useRouter,
39
+ useSearchParams,
40
+ usePathname,
41
+ } from "next/navigation";
42
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
43
+ import {
44
+ gtmViewItem,
45
+ gtmAddToCart,
46
+ Product,
47
+ } from "../../utils/googleTagManager";
48
+ import { useAppConfiguration } from "../../components/providers/ServerAppConfigurationProvider";
49
+ import { getSaleorApiUrl } from "@/lib/saleor/getSaleorApiUrl";
50
+ import {
51
+ AncillaryPage,
52
+ fetchPageBySlug,
53
+ } from "@/graphql/queries/getPageBySlug";
54
+ import EditorRenderer from "@/app/components/richText/EditorRenderer";
55
+ import ItemInquiryModal from "./components/itemInquiryModal";
56
+ import { ProductInquiryIcon } from "@/app/utils/svgs/productInquiryIcon";
57
+ import { SwiperArrowIconLeft } from "@/app/utils/svgs/swiperArrowIconLeft";
58
+ import { SwiperArrowIconRight } from "@/app/utils/svgs/swiperArrowIconRight";
59
+ /* ---------------- helpers (local) ---------------- */
60
+ type AddressInputTS = {
61
+ firstName: string;
62
+ lastName: string;
63
+ streetAddress1: string;
64
+ city: string;
65
+ postalCode: string;
66
+ country: string;
67
+ countryArea?: string;
68
+ phone?: string;
69
+ };
70
+
71
+ type CheckoutLineInputTS = { variantId: string; quantity: number };
72
+
73
+ // Option Sets Types
74
+ interface VariantOptionMetadata {
75
+ name: string;
76
+ label: string;
77
+ hidden: boolean;
78
+ type: "enum" | "multi-enum";
79
+ deselect: string;
80
+ required: boolean;
81
+ base_product_required?: boolean;
82
+ }
83
+
84
+ interface ProductOptionMetadata {
85
+ name: string;
86
+ label: string;
87
+ hidden: boolean;
88
+ type: "text" | "date" | "datetime";
89
+ required: boolean;
90
+ }
91
+
92
+ interface OptionSet {
93
+ name: string;
94
+ label: string;
95
+ hidden: boolean;
96
+ type: "enum" | "multi-enum";
97
+ deselect: string;
98
+ required: boolean;
99
+ variants: ProductVariant[];
100
+ }
101
+
102
+ // Helper to parse variant option metadata
103
+ function parseVariantOptionMetadata(
104
+ variant: ProductVariant
105
+ ): VariantOptionMetadata | null {
106
+ const optionsMeta = variant.metadata?.find((m) => m.key === "option_set");
107
+ if (!optionsMeta?.value) return null;
108
+ try {
109
+ return JSON.parse(optionsMeta.value) as VariantOptionMetadata;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // Helper to parse product-level options metadata
116
+ function parseProductOptionsMetadata(
117
+ metadata: Array<{ key: string; value: string }>
118
+ ): ProductOptionMetadata[] {
119
+ const optionsMeta = metadata?.find((m) => m.key === "options");
120
+ if (!optionsMeta?.value) return [];
121
+ try {
122
+ const parsed = JSON.parse(optionsMeta.value);
123
+ return Array.isArray(parsed) ? parsed : [];
124
+ } catch {
125
+ return [];
126
+ }
127
+ }
128
+
129
+ async function createCheckout(input: {
130
+ channel: string;
131
+ email: string;
132
+ lines: CheckoutLineInputTS[];
133
+ shippingAddress?: AddressInputTS;
134
+ billingAddress?: AddressInputTS;
135
+ }) {
136
+ const res = await fetch(getSaleorApiUrl(), {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/json" },
139
+ body: JSON.stringify({ query: CHECKOUT_CREATE, variables: { input } }),
140
+ });
141
+ if (!res.ok) throw new Error("Failed to create checkout");
142
+ const json = await res.json();
143
+ const errs = json?.data?.checkoutCreate?.errors;
144
+ if (Array.isArray(errs) && errs.length)
145
+ throw new Error(errs[0]?.message || "Checkout creation error");
146
+ const id: string | undefined = json?.data?.checkoutCreate?.checkout?.id;
147
+ const token: string | undefined = json?.data?.checkoutCreate?.checkout?.token;
148
+ if (!id) throw new Error("No checkout id returned");
149
+ return { checkoutId: id, checkoutToken: token };
150
+ }
151
+
152
+ function clearStoredCheckout() {
153
+ try {
154
+ localStorage.removeItem("checkoutId");
155
+ localStorage.removeItem("checkoutToken");
156
+ } catch {}
157
+ }
158
+ /* ------------------------------------------------ */
159
+
160
+ type EditorBlock =
161
+ | { id: string; type: "paragraph"; data: { text: string } }
162
+ | { id: string; type: "header"; data: { text: string; level?: number } }
163
+ | {
164
+ id: string;
165
+ type: "list";
166
+ data: { items: string[]; style?: "ordered" | "unordered" };
167
+ }
168
+ | {
169
+ id: string;
170
+ type: "quote";
171
+ data: {
172
+ text: string;
173
+ caption?: string;
174
+ alignment?: "left" | "center" | "right";
175
+ };
176
+ };
177
+
178
+ interface RelatedProduct {
179
+ id: string;
180
+ name: string;
181
+ slug: string;
182
+ category: { id: string; name: string } | null;
183
+ media: { id: string; url: string; alt: string | null }[];
184
+ pricing: {
185
+ priceRange: {
186
+ start: { gross: { amount: number; currency: string } } | null;
187
+ stop: { gross: { amount: number; currency: string } } | null;
188
+ } | null;
189
+ } | null;
190
+ }
191
+
192
+ interface RelatedProductsResponse {
193
+ products: {
194
+ edges: { node: RelatedProduct }[];
195
+ };
196
+ }
197
+
198
+ export default function ProductDetailPage() {
199
+ const params = useParams<{ id: string }>();
200
+ const [pdpContent, setPDPContent] = useState<AncillaryPage | null>(null);
201
+ // The URL param contains the normalized slug (with single dashes)
202
+ // We need to pass the original Saleor slug for the API query
203
+ // Since we can't perfectly reconstruct it, we just use the normalized version
204
+ // and rely on Saleor's flexible slug matching
205
+ const slug = params?.id ? decodeURIComponent(params.id as string) : "";
206
+
207
+ const channel = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
208
+ const router = useRouter();
209
+ const searchParams = useSearchParams();
210
+ const pathname = usePathname();
211
+
212
+ const {
213
+ addToCart,
214
+ isLoggedIn,
215
+ user,
216
+ guestEmail,
217
+ guestShippingInfo,
218
+ setCheckoutId,
219
+ setCheckoutToken,
220
+ } = useGlobalStore();
221
+
222
+ const { getGoogleTagManagerConfig } = useAppConfiguration();
223
+ const gtmConfig = getGoogleTagManagerConfig();
224
+
225
+ const { data, loading, error } = useQuery<
226
+ ProductDetailsByIdData,
227
+ ProductDetailsByIdVars
228
+ >(PRODUCT_DETAILS_BY_ID, {
229
+ variables: { slug, channel },
230
+ skip: !slug,
231
+ fetchPolicy: "network-only",
232
+ });
233
+
234
+ // Query to find product by old slug if the main query returns no product
235
+ const shouldSkipOldSlugQuery = !slug || loading || !!data?.product;
236
+
237
+ const {
238
+ data: oldSlugData,
239
+ loading: oldSlugLoading,
240
+ fetchMore,
241
+ } = useQuery<FindProductByOldSlugData, FindProductByOldSlugVars>(
242
+ FIND_PRODUCT_BY_OLD_SLUG,
243
+ {
244
+ variables: { channel, first: 100 }, // Changed from 250 to 100 to match API limit
245
+ skip: shouldSkipOldSlugQuery, // Only run if main query completed and found no product
246
+ fetchPolicy: "network-only",
247
+ }
248
+ );
249
+ useEffect(() => {
250
+ const fetchPageContent = async () => {
251
+ if (
252
+ product?.metadata.find((item) => item?.key === "availability")
253
+ ?.value === "Please Call" ||
254
+ product?.metadata.find((item) => item?.key === "availability")
255
+ ?.value === "Available"
256
+ )
257
+ return;
258
+ try {
259
+ const pdpContentRenderer = await fetchPageBySlug(
260
+ "call-for-availability"
261
+ );
262
+ setPDPContent(pdpContentRenderer);
263
+ } catch (error) {
264
+ console.error("Error fetching page content:", error);
265
+ }
266
+ };
267
+
268
+ fetchPageContent();
269
+ }, []);
270
+
271
+ // State for tracking pagination
272
+ const [isFetchingMore, setIsFetchingMore] = useState(false);
273
+ const [allProductsChecked, setAllProductsChecked] = useState(false);
274
+
275
+ // Find product with matching old_slug or redirects in metadata (client-side filtering)
276
+ const productWithOldSlug = useMemo(() => {
277
+ if (!oldSlugData?.products?.edges || !slug) {
278
+ return null;
279
+ }
280
+
281
+ const foundProduct =
282
+ oldSlugData.products.edges.find((edge) => {
283
+ // Check for "old_slug" metadata key
284
+ const oldSlugMeta = edge.node.metadata?.find(
285
+ (meta) => meta.key === "old_slug" && meta.value === slug
286
+ );
287
+
288
+ if (oldSlugMeta) {
289
+ return true;
290
+ }
291
+
292
+ // Check for "redirects" metadata key (which can contain JSON array or comma-separated string)
293
+ const redirectsMeta = edge.node.metadata?.find(
294
+ (meta) => meta.key === "redirects"
295
+ );
296
+
297
+ if (redirectsMeta) {
298
+ try {
299
+ let redirects: string[] = [];
300
+ let redirectsValue = redirectsMeta.value.trim();
301
+
302
+ // Try to parse as JSON array first
303
+ if (redirectsValue.startsWith("[")) {
304
+ try {
305
+ // Fix common JSON formatting issues
306
+ redirectsValue = redirectsValue.replace(/\[([^"[])/g, '["$1'); // Add missing opening quote after [
307
+ redirectsValue = redirectsValue.replace(/([^"\]])\]/g, '$1"]'); // Add missing closing quote before ]
308
+
309
+ redirects = JSON.parse(redirectsValue);
310
+ } catch (jsonError) {
311
+ // If JSON parse fails, try comma-separated format
312
+ redirects = redirectsValue
313
+ .replace(/^\[|\]$/g, "") // Remove [ and ]
314
+ .split(",")
315
+ .map((s) => s.trim().replace(/^["']|["']$/g, "")); // Remove quotes and trim
316
+ }
317
+ } else {
318
+ // Handle comma-separated string format (no brackets)
319
+ redirects = redirectsValue
320
+ .split(",")
321
+ .map((s) => s.trim().replace(/^["']|["']$/g, "")); // Remove quotes and trim
322
+ }
323
+
324
+ // Check if current slug matches any redirect
325
+ const hasMatch =
326
+ Array.isArray(redirects) &&
327
+ redirects.some((redirect) => redirect === slug);
328
+
329
+ if (hasMatch) {
330
+ return true;
331
+ }
332
+ } catch (parseError) {
333
+ // Silent fail - continue to next product
334
+ }
335
+ }
336
+
337
+ return false;
338
+ })?.node || null;
339
+
340
+ return foundProduct;
341
+ }, [oldSlugData, slug]);
342
+
343
+ // Auto-fetch more products if not found in current batch and more pages exist
344
+ useEffect(() => {
345
+ const shouldFetchMore =
346
+ !loading &&
347
+ !data?.product &&
348
+ !oldSlugLoading &&
349
+ !productWithOldSlug &&
350
+ !isFetchingMore &&
351
+ !allProductsChecked &&
352
+ oldSlugData?.products?.pageInfo?.hasNextPage &&
353
+ oldSlugData?.products?.pageInfo?.endCursor;
354
+
355
+ if (shouldFetchMore && fetchMore) {
356
+ setIsFetchingMore(true);
357
+
358
+ fetchMore({
359
+ variables: {
360
+ after: oldSlugData.products.pageInfo.endCursor,
361
+ },
362
+ updateQuery: (prev, { fetchMoreResult }) => {
363
+ setIsFetchingMore(false);
364
+
365
+ if (!fetchMoreResult) {
366
+ setAllProductsChecked(true);
367
+ return prev;
368
+ }
369
+
370
+ // Check if there are no more pages
371
+ if (!fetchMoreResult.products.pageInfo.hasNextPage) {
372
+ setAllProductsChecked(true);
373
+ }
374
+
375
+ // Merge the results
376
+ return {
377
+ products: {
378
+ ...fetchMoreResult.products,
379
+ edges: [
380
+ ...prev.products.edges,
381
+ ...fetchMoreResult.products.edges,
382
+ ],
383
+ },
384
+ };
385
+ },
386
+ }).catch((error) => {
387
+ setIsFetchingMore(false);
388
+ setAllProductsChecked(true);
389
+ });
390
+ }
391
+ }, [
392
+ loading,
393
+ data?.product,
394
+ oldSlugLoading,
395
+ productWithOldSlug,
396
+ isFetchingMore,
397
+ allProductsChecked,
398
+ oldSlugData,
399
+ fetchMore,
400
+ ]);
401
+
402
+ // Prefill addresses for logged-in users (optional, non-blocking)
403
+ const { data: meData } = useQuery<MeAddressesData>(ME_ADDRESSES_QUERY, {
404
+ skip: !isLoggedIn,
405
+ });
406
+
407
+ const accountShipping = useMemo(() => {
408
+ const me = meData?.me;
409
+ if (!me || !me.addresses?.length) return null;
410
+ const defId = me.defaultShippingAddress?.id;
411
+ return (
412
+ (defId ? me.addresses.find((a) => a.id === defId) : me.addresses[0]) ||
413
+ null
414
+ );
415
+ }, [meData]);
416
+
417
+ const accountBilling = useMemo(() => {
418
+ const me = meData?.me;
419
+ if (!me || !me.addresses?.length) return null;
420
+ const defId = me.defaultBillingAddress?.id;
421
+ return (
422
+ (defId
423
+ ? me.addresses.find((a) => a.id === defId)
424
+ : accountShipping || me.addresses[0]) || null
425
+ );
426
+ }, [meData, accountShipping]);
427
+
428
+ const product = data?.product ?? null;
429
+
430
+ // Related products state
431
+ const [relatedProducts, setRelatedProducts] = useState<RelatedProduct[]>([]);
432
+
433
+ // Shuffle array using Fisher-Yates algorithm
434
+ const shuffleArray = <T,>(array: T[]): T[] => {
435
+ const shuffled = [...array];
436
+ for (let i = shuffled.length - 1; i > 0; i--) {
437
+ const j = Math.floor(Math.random() * (i + 1));
438
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
439
+ }
440
+ return shuffled;
441
+ };
442
+
443
+ // Fetch related products when product loads
444
+ useEffect(() => {
445
+ const fetchRelatedProducts = async () => {
446
+ if (!product?.category?.id) return;
447
+
448
+ try {
449
+ const res = await fetch(getSaleorApiUrl(), {
450
+ method: "POST",
451
+ headers: { "Content-Type": "application/json" },
452
+ body: JSON.stringify({
453
+ query: PRODUCTS_BY_CATEGORIES_AND_COLLECTIONS,
454
+ variables: {
455
+ categoryIds: [product.category.id],
456
+ channel,
457
+ first: 30,
458
+ sortField: "DATE",
459
+ sortDirection: "DESC",
460
+ },
461
+ }),
462
+ });
463
+
464
+ if (res.ok) {
465
+ const json = (await res.json()) as { data?: RelatedProductsResponse };
466
+ const products = shuffleArray(
467
+ json?.data?.products?.edges
468
+ ?.map((edge) => edge.node)
469
+ ?.filter((p) => p.id !== product.id) || []
470
+ ).slice(0, 4);
471
+ setRelatedProducts(products);
472
+ }
473
+ } catch (error) {
474
+ console.error("Error fetching related products:", error);
475
+ }
476
+ };
477
+
478
+ fetchRelatedProducts();
479
+ }, [product?.category?.id, product?.id, channel]);
480
+
481
+ const [isComingFromRedirect, setIsComingFromRedirect] = useState(() => {
482
+ if (typeof window !== "undefined") {
483
+ return sessionStorage.getItem("productRedirecting") === "true";
484
+ }
485
+ return false;
486
+ });
487
+
488
+ // Clear redirect flag when product loads successfully
489
+ useEffect(() => {
490
+ if (product && isComingFromRedirect) {
491
+ sessionStorage.removeItem("productRedirecting");
492
+ setIsComingFromRedirect(false);
493
+ }
494
+ }, [product, isComingFromRedirect]);
495
+
496
+ // Handle redirect if old slug is found
497
+ useEffect(() => {
498
+ if (!loading && !data?.product && !oldSlugLoading && productWithOldSlug) {
499
+ const newSlug = productWithOldSlug.slug;
500
+
501
+ // Mark that we're redirecting so the next page load doesn't show loading skeleton
502
+ sessionStorage.setItem("productRedirecting", "true");
503
+
504
+ // Redirect to the new slug, preserving any query parameters
505
+ const currentParams = searchParams.toString();
506
+ const newUrl = `/product/${newSlug}${
507
+ currentParams ? `?${currentParams}` : ""
508
+ }`;
509
+
510
+ router.replace(newUrl);
511
+ }
512
+ }, [
513
+ loading,
514
+ data?.product,
515
+ oldSlugLoading,
516
+ productWithOldSlug,
517
+ router,
518
+ searchParams,
519
+ slug,
520
+ ]);
521
+ const images = product?.media ?? [];
522
+ const firstImageUrl = images[0]?.url ?? "";
523
+
524
+ // Track product view when product data is loaded
525
+ useEffect(() => {
526
+ if (product && !loading) {
527
+ const productData: Product = {
528
+ item_id: product.id,
529
+ item_name: product.name,
530
+ item_category: product.category?.name || "Products",
531
+ price: product.pricing?.priceRange?.start?.gross?.amount || 0,
532
+ currency: product.pricing?.priceRange?.start?.gross?.currency || "USD",
533
+ item_brand: product.category?.name || undefined,
534
+ };
535
+
536
+ gtmViewItem(
537
+ [productData],
538
+ productData.currency,
539
+ productData.price,
540
+ gtmConfig?.container_id
541
+ );
542
+ }
543
+ }, [product, loading]);
544
+ const [selectedImage, setSelectedImage] = useState<string>(firstImageUrl);
545
+ const [selectedVariantId, setSelectedVariantId] = useState<string | null>(
546
+ null
547
+ );
548
+ const [showInquiryModal, setShowInquiryModal] = useState(false);
549
+ const [quantity, setQuantity] = useState<number>(1);
550
+ const [isAdding, setIsAdding] = useState(false);
551
+ const [buying, setBuying] = useState(false);
552
+ const [isInitialized, setIsInitialized] = useState(false);
553
+
554
+ // Option Sets State
555
+ const [optionSetSelections, setOptionSetSelections] = useState<
556
+ Record<string, string[]>
557
+ >({});
558
+ const [nonSkuInputs, setNonSkuInputs] = useState<Record<string, string>>({});
559
+ const [validationErrors, setValidationErrors] = useState<
560
+ Record<string, string>
561
+ >({});
562
+
563
+ // Process SKU-based option sets from variant metadata
564
+ const optionSets = useMemo<OptionSet[]>(() => {
565
+ if (!product?.variants) return [];
566
+
567
+ const setsMap = new Map<string, OptionSet>();
568
+
569
+ for (const variant of product.variants) {
570
+ const optionMeta = parseVariantOptionMetadata(variant);
571
+ if (!optionMeta || !optionMeta.name) continue;
572
+
573
+ const existing = setsMap.get(optionMeta.name);
574
+ if (existing) {
575
+ existing.variants.push(variant);
576
+ } else {
577
+ setsMap.set(optionMeta.name, {
578
+ name: optionMeta.name,
579
+ label: optionMeta.label,
580
+ hidden: optionMeta.hidden,
581
+ type: optionMeta.type,
582
+ deselect: optionMeta.deselect,
583
+ required: optionMeta.required,
584
+ variants: [variant],
585
+ });
586
+ }
587
+ }
588
+
589
+ return Array.from(setsMap.values());
590
+ }, [product?.variants]);
591
+
592
+ // Process Non-SKU options from product metadata
593
+ const nonSkuOptions = useMemo<ProductOptionMetadata[]>(() => {
594
+ if (!product?.metadata) return [];
595
+ return parseProductOptionsMetadata(product.metadata);
596
+ }, [product?.metadata]);
597
+
598
+ // Visible option sets (filter out hidden ones)
599
+ const visibleOptionSets = useMemo(
600
+ () => optionSets.filter((os) => !os.hidden),
601
+ [optionSets]
602
+ );
603
+
604
+ // Visible non-SKU options
605
+ const visibleNonSkuOptions = useMemo(
606
+ () => nonSkuOptions.filter((opt) => !opt.hidden),
607
+ [nonSkuOptions]
608
+ );
609
+
610
+ // Get variants that are NOT part of any option set (for regular variant selection)
611
+ const regularVariants = useMemo(() => {
612
+ if (!product?.variants) return [];
613
+ const optionSetVariantIds = new Set(
614
+ optionSets.flatMap((os) => os.variants.map((v) => v.id))
615
+ );
616
+ return product.variants.filter((v) => !optionSetVariantIds.has(v.id));
617
+ }, [product?.variants, optionSets]);
618
+
619
+ // Get the base variant (the one without option_set metadata) for default selection
620
+ const baseVariant = useMemo(() => {
621
+ if (!product?.variants?.length) return null;
622
+ // Find the first variant that doesn't have option_set metadata
623
+ const variantWithoutOptionSet = product.variants.find(
624
+ (v) => !parseVariantOptionMetadata(v)
625
+ );
626
+ // Fall back to first variant if all have option_set (shouldn't happen, but safety)
627
+ return variantWithoutOptionSet ?? product.variants[0];
628
+ }, [product?.variants]);
629
+
630
+ // Validation function
631
+ const validateOptionsAndInputs = useCallback((): boolean => {
632
+ const errors: Record<string, string> = {};
633
+
634
+ // Validate required option sets
635
+ for (const optionSet of visibleOptionSets) {
636
+ if (optionSet.required) {
637
+ const selections = optionSetSelections[optionSet.name] || [];
638
+ if (selections.length === 0) {
639
+ errors[`optionSet_${optionSet.name}`] = `${optionSet.label} is required`;
640
+ }
641
+ }
642
+ }
643
+
644
+ // Validate required non-SKU inputs
645
+ for (const option of visibleNonSkuOptions) {
646
+ if (option.required) {
647
+ const value = nonSkuInputs[option.name] || "";
648
+ if (!value.trim()) {
649
+ errors[`nonSku_${option.name}`] = `${option.label} is required`;
650
+ }
651
+ }
652
+ }
653
+
654
+ setValidationErrors(errors);
655
+ return Object.keys(errors).length === 0;
656
+ }, [visibleOptionSets, visibleNonSkuOptions, optionSetSelections, nonSkuInputs]);
657
+
658
+ // Handle option set selection
659
+ const handleOptionSetChange = useCallback(
660
+ (optionSetName: string, variantId: string, isMulti: boolean) => {
661
+ setOptionSetSelections((prev) => {
662
+ if (isMulti) {
663
+ const current = prev[optionSetName] || [];
664
+ if (current.includes(variantId)) {
665
+ return {
666
+ ...prev,
667
+ [optionSetName]: current.filter((id) => id !== variantId),
668
+ };
669
+ } else {
670
+ return {
671
+ ...prev,
672
+ [optionSetName]: [...current, variantId],
673
+ };
674
+ }
675
+ } else {
676
+ // For single select (enum), if selecting empty string (deselect), remove selection
677
+ if (variantId === "") {
678
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
679
+ const { [optionSetName]: _removed, ...rest } = prev;
680
+ return rest;
681
+ }
682
+ return {
683
+ ...prev,
684
+ [optionSetName]: [variantId],
685
+ };
686
+ }
687
+ });
688
+ // Clear validation error for this option set
689
+ setValidationErrors((prev) => {
690
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
691
+ const { [`optionSet_${optionSetName}`]: _removed, ...rest } = prev;
692
+ return rest;
693
+ });
694
+ },
695
+ []
696
+ );
697
+
698
+ // Handle non-SKU input change
699
+ const handleNonSkuInputChange = useCallback(
700
+ (optionName: string, value: string) => {
701
+ setNonSkuInputs((prev) => ({
702
+ ...prev,
703
+ [optionName]: value,
704
+ }));
705
+ // Clear validation error for this input
706
+ setValidationErrors((prev) => {
707
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
708
+ const { [`nonSku_${optionName}`]: _removed, ...rest } = prev;
709
+ return rest;
710
+ });
711
+ },
712
+ []
713
+ );
714
+
715
+ // Resolve a variant ID to a URL-safe identifier (prefer SKU, fall back to name)
716
+ // No manual encoding — URLSearchParams handles spaces/special chars natively
717
+ const variantToURLValue = useCallback(
718
+ (variantId: string): string | null => {
719
+ for (const os of optionSets) {
720
+ const variant = os.variants.find((v) => v.id === variantId);
721
+ if (variant) {
722
+ return variant.sku || variant.name || null;
723
+ }
724
+ }
725
+ return null;
726
+ },
727
+ [optionSets]
728
+ );
729
+
730
+ // Resolve a URL value back to a variant ID (direct match on SKU or name)
731
+ const urlValueToVariantId = useCallback(
732
+ (value: string, optionSet: OptionSet): string | undefined => {
733
+ return optionSet.variants.find(
734
+ (v) => v.sku === value || v.name === value
735
+ )?.id;
736
+ },
737
+ []
738
+ );
739
+
740
+ // Function to update URL with SKU, option set, and custom input params
741
+ const updateURL = useCallback(
742
+ (
743
+ sku: string | null,
744
+ optionSelections: Record<string, string[]>,
745
+ customInputs: Record<string, string>
746
+ ) => {
747
+ const params = new URLSearchParams();
748
+
749
+ // Set SKU param
750
+ if (sku) {
751
+ params.set("sku", sku.replace(/\s+/g, "-"));
752
+ }
753
+
754
+ // Set option set params (os_<name>=value1,value2)
755
+ for (const [optionSetName, variantIds] of Object.entries(
756
+ optionSelections
757
+ )) {
758
+ if (variantIds.length === 0) continue;
759
+ const values = variantIds
760
+ .map((id) => variantToURLValue(id))
761
+ .filter(Boolean);
762
+ if (values.length > 0) {
763
+ params.set(`os_${optionSetName}`, values.join(","));
764
+ }
765
+ }
766
+
767
+ // Set custom input params (ci_<name>=value)
768
+ for (const [inputName, value] of Object.entries(customInputs)) {
769
+ if (value) {
770
+ params.set(`ci_${inputName}`, value);
771
+ }
772
+ }
773
+
774
+ const query = params.toString();
775
+ router.replace(query ? `${pathname}?${query}` : pathname, {
776
+ scroll: false,
777
+ });
778
+ },
779
+ [pathname, router, variantToURLValue]
780
+ );
781
+
782
+ // Toast with unmount cleanup
783
+ const [toast, setToast] = useState<{
784
+ message: string;
785
+ subParagraph?: string;
786
+ type: "success" | "error" | "info";
787
+ } | null>(null);
788
+ const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
789
+ const showToast = useCallback(
790
+ (
791
+ message: string,
792
+ subParagraph?: string,
793
+ type: "success" | "error" | "info" = "info"
794
+ ) => {
795
+ setToast({ message, subParagraph, type });
796
+ if (toastTimer.current) clearTimeout(toastTimer.current);
797
+ toastTimer.current = setTimeout(() => setToast(null), 2500);
798
+ },
799
+ []
800
+ );
801
+ const raw = product?.description || "";
802
+ const lineHeight = 28; // px
803
+ const maxLines = 10;
804
+ const maxHeight = lineHeight * maxLines;
805
+ const [showFull, setShowFull] = useState(false);
806
+ const [isOverflow, setIsOverflow] = useState(false);
807
+ const descriptionRef = useRef<HTMLDivElement>(null);
808
+
809
+ useLayoutEffect(() => {
810
+ if (!descriptionRef.current || !raw) return;
811
+
812
+ const frame = requestAnimationFrame(() => {
813
+ const el = descriptionRef.current;
814
+ if (!el) return;
815
+ const isTruncated = el.scrollHeight > el.clientHeight;
816
+
817
+ setIsOverflow(isTruncated);
818
+ // Avoid noisy logs in templates/production.
819
+ });
820
+
821
+ return () => cancelAnimationFrame(frame);
822
+ }, [maxHeight, raw, product]);
823
+
824
+ const toggleShow = () => setShowFull(!showFull);
825
+ useEffect(
826
+ () => () => {
827
+ if (toastTimer.current) clearTimeout(toastTimer.current);
828
+ },
829
+ []
830
+ );
831
+
832
+ // Keep selected image in sync with first loaded image (simpler, no useMemo)
833
+ useEffect(() => {
834
+ if (firstImageUrl) setSelectedImage(firstImageUrl);
835
+ }, [firstImageUrl]);
836
+
837
+ // Initialize variant selection from URL or default to first variant
838
+ useEffect(() => {
839
+ if (!product?.variants?.length || isInitialized) return;
840
+
841
+ const skuFromURL = searchParams.get("sku");
842
+
843
+ if (skuFromURL) {
844
+ // Convert URL-friendly SKU back to original format (replace hyphens with spaces)
845
+ const originalSKU = skuFromURL.replace(/-/g, " ");
846
+
847
+ // Try to find variant by SKU from URL (check both formats for compatibility)
848
+ const variantFromURL = product.variants.find(
849
+ (v) => v.sku === originalSKU || v.sku === skuFromURL
850
+ );
851
+
852
+ if (variantFromURL) {
853
+ setSelectedVariantId(variantFromURL.id);
854
+ } else if (baseVariant?.id) {
855
+ setSelectedVariantId(baseVariant.id);
856
+ }
857
+ } else if (baseVariant?.id) {
858
+ // Default to base variant (without option_set metadata) if no SKU in URL
859
+ setSelectedVariantId(baseVariant.id);
860
+ }
861
+
862
+ // Restore option set selections from URL
863
+ if (optionSets.length > 0) {
864
+ const restoredSelections: Record<string, string[]> = {};
865
+ for (const os of optionSets) {
866
+ const paramValue = searchParams.get(`os_${os.name}`);
867
+ if (!paramValue) continue;
868
+ const values = paramValue.split(",");
869
+ const variantIds = values
870
+ .map((val) => urlValueToVariantId(val, os))
871
+ .filter(Boolean) as string[];
872
+ if (variantIds.length > 0) {
873
+ restoredSelections[os.name] = variantIds;
874
+ }
875
+ }
876
+ if (Object.keys(restoredSelections).length > 0) {
877
+ setOptionSetSelections(restoredSelections);
878
+ }
879
+ }
880
+
881
+ // Restore non-SKU custom inputs from URL
882
+ if (nonSkuOptions.length > 0) {
883
+ const restoredInputs: Record<string, string> = {};
884
+ for (const opt of nonSkuOptions) {
885
+ const paramValue = searchParams.get(`ci_${opt.name}`);
886
+ if (paramValue) {
887
+ restoredInputs[opt.name] = paramValue;
888
+ }
889
+ }
890
+ if (Object.keys(restoredInputs).length > 0) {
891
+ setNonSkuInputs(restoredInputs);
892
+ }
893
+ }
894
+
895
+ setIsInitialized(true);
896
+ }, [product?.variants, searchParams, isInitialized, baseVariant, optionSets, nonSkuOptions, urlValueToVariantId]);
897
+
898
+ const selectedVariant = useMemo(() => {
899
+ if (!product?.variants?.length) return null;
900
+ return (
901
+ product.variants.find((v) => v.id === (selectedVariantId ?? "")) ??
902
+ baseVariant ??
903
+ product.variants[0]
904
+ );
905
+ }, [product, selectedVariantId, baseVariant]);
906
+
907
+ // Update URL with SKU, option set selections, and custom inputs when they change (after initialization)
908
+ useEffect(() => {
909
+ if (isInitialized) {
910
+ updateURL(selectedVariant?.sku ?? null, optionSetSelections, nonSkuInputs);
911
+ }
912
+ }, [isInitialized, selectedVariant?.sku, optionSetSelections, nonSkuInputs, updateURL]);
913
+
914
+ // ---------- PRICING (variant-first) ----------
915
+ const variantPrice = selectedVariant?.pricing?.price?.gross ?? null;
916
+ const variantOriginal =
917
+ selectedVariant?.pricing?.priceUndiscounted?.gross ?? null;
918
+
919
+ const rawCurrentPrice =
920
+ variantPrice?.amount ??
921
+ product?.pricing?.priceRange?.start?.gross?.amount ??
922
+ 0;
923
+
924
+ const currency =
925
+ variantPrice?.currency ??
926
+ variantOriginal?.currency ??
927
+ product?.pricing?.priceRange?.start?.gross?.currency ??
928
+ "USD";
929
+
930
+ // ✅ Calculate original price correctly: discounted price + discount amount
931
+ const discountAmount = product?.pricing?.discount?.gross?.amount ?? 0;
932
+ const rawOriginalPrice =
933
+ discountAmount > 0
934
+ ? rawCurrentPrice + discountAmount // Original = Current + Discount
935
+ : variantOriginal?.amount ??
936
+ product?.pricing?.priceRange?.stop?.gross?.amount ??
937
+ null;
938
+
939
+ // Format prices properly (convert from cents if needed)
940
+ const currentPrice = rawCurrentPrice;
941
+ const originalPrice = rawOriginalPrice;
942
+
943
+ // ✅ Use Saleor's discount info for more accurate detection
944
+ const hasDiscount =
945
+ discountAmount > 0 ||
946
+ (typeof originalPrice === "number" && originalPrice > currentPrice);
947
+ const compareAt = hasDiscount ? originalPrice : null;
948
+
949
+ // Memoized formatter
950
+ const moneyFmt = useMemo(
951
+ () => new Intl.NumberFormat(undefined, { style: "currency", currency }),
952
+ [currency]
953
+ );
954
+
955
+ // Calculate total price including selected option set variants
956
+ const optionSetsTotalPrice = useMemo(() => {
957
+ let total = 0;
958
+ for (const optionSet of optionSets) {
959
+ const selectedIds = optionSetSelections[optionSet.name] || [];
960
+ for (const variantId of selectedIds) {
961
+ const variant = optionSet.variants.find((v) => v.id === variantId);
962
+ if (variant) {
963
+ total += variant.pricing?.price?.gross?.amount ?? 0;
964
+ }
965
+ }
966
+ }
967
+ return total;
968
+ }, [optionSets, optionSetSelections]);
969
+
970
+ // Check if any selected option has base_product_required=false
971
+ const shouldIncludeBaseProductInPrice = useMemo(() => {
972
+ for (const optionSet of optionSets) {
973
+ const selectedIds = optionSetSelections[optionSet.name] || [];
974
+ for (const variantId of selectedIds) {
975
+ const variant = optionSet.variants.find((v) => v.id === variantId);
976
+ if (variant) {
977
+ const optionMeta = parseVariantOptionMetadata(variant);
978
+ if (optionMeta?.base_product_required === false) {
979
+ return false;
980
+ }
981
+ }
982
+ }
983
+ }
984
+ return true;
985
+ }, [optionSets, optionSetSelections]);
986
+
987
+ // Total display price (base + options, excluding base if base_product_required=false)
988
+ const displayPrice = (shouldIncludeBaseProductInPrice ? currentPrice : 0) + optionSetsTotalPrice;
989
+ const displayCompareAt = compareAt !== null ? (shouldIncludeBaseProductInPrice ? compareAt : 0) + optionSetsTotalPrice : null;
990
+ // --------------------------------------------
991
+
992
+ // Helper to read attribute value by slug from selected variant
993
+ const getAttrVal = useCallback(
994
+ (slug: string) => {
995
+ const attr = selectedVariant?.attributes?.find(
996
+ (a) => a.attribute?.slug === slug
997
+ );
998
+ return attr?.values?.[0]?.name ?? null;
999
+ },
1000
+ [selectedVariant]
1001
+ );
1002
+ const lengthVal = getAttrVal("length_in") || getAttrVal("length");
1003
+ const heightVal = getAttrVal("height_in") || getAttrVal("height");
1004
+ const widthVal = getAttrVal("width_in") || getAttrVal("width");
1005
+
1006
+ // Cap quantity by available stock when present
1007
+ const maxQty = selectedVariant?.quantityAvailable ?? undefined;
1008
+ const decQty = () => setQuantity((q) => Math.max(1, q - 1));
1009
+ const incQty = () => setQuantity((q) => Math.min(q + 1));
1010
+
1011
+ const onQtyInput = (val: string) => {
1012
+ const n = Number.parseInt(val, 10);
1013
+ const safe = Number.isFinite(n) ? Math.max(1, n) : 1;
1014
+ setQuantity(maxQty ? Math.min(safe, maxQty) : safe);
1015
+ };
1016
+ // Helper to update checkout line metadata for non-SKU options
1017
+ const updateCheckoutLineMetadata = useCallback(
1018
+ async (checkoutLineId: string, metadata: MetadataInput[]) => {
1019
+ if (!metadata.length) return;
1020
+
1021
+ try {
1022
+ const token = localStorage.getItem("token");
1023
+ const res = await fetch(getSaleorApiUrl(), {
1024
+ method: "POST",
1025
+ headers: {
1026
+ "Content-Type": "application/json",
1027
+ ...(token && { Authorization: `Bearer ${token}` }),
1028
+ },
1029
+ body: JSON.stringify({
1030
+ query: UPDATE_CHECKOUT_LINE_METADATA,
1031
+ variables: {
1032
+ id: checkoutLineId,
1033
+ input: metadata,
1034
+ },
1035
+ }),
1036
+ });
1037
+
1038
+ if (!res.ok) {
1039
+ console.error("Failed to update checkout line metadata");
1040
+ }
1041
+ } catch (err) {
1042
+ console.error("Error updating checkout line metadata:", err);
1043
+ }
1044
+ },
1045
+ []
1046
+ );
1047
+
1048
+ const handleAddToCart = async () => {
1049
+ if (!product) return;
1050
+
1051
+ // Validate option sets and non-SKU inputs
1052
+ if (!validateOptionsAndInputs()) {
1053
+ showToast(
1054
+ "Required fields missing",
1055
+ "Please fill in all required fields before adding to cart.",
1056
+ "error"
1057
+ );
1058
+ return;
1059
+ }
1060
+
1061
+ try {
1062
+ setIsAdding(true);
1063
+
1064
+ // Collect all selected option set variants as CartItemOptions
1065
+ const selectedOptions: CartItemOption[] = [];
1066
+ let shouldIncludeBaseProduct = true;
1067
+
1068
+ for (const optionSet of optionSets) {
1069
+ const selections = optionSetSelections[optionSet.name] || [];
1070
+ for (const variantId of selections) {
1071
+ const variant = optionSet.variants.find((v) => v.id === variantId);
1072
+ if (variant) {
1073
+ const optionMeta = parseVariantOptionMetadata(variant);
1074
+
1075
+ // Check if any variant has base_product_required=false
1076
+ if (optionMeta?.base_product_required === false) {
1077
+ shouldIncludeBaseProduct = false;
1078
+ }
1079
+
1080
+ // Add to selected options
1081
+ selectedOptions.push({
1082
+ variantId: variant.id,
1083
+ name: variant.name,
1084
+ price: variant.pricing?.price?.gross?.amount ?? 0,
1085
+ optionSetName: optionSet.name,
1086
+ optionSetLabel: optionSet.label,
1087
+ });
1088
+ }
1089
+ }
1090
+ }
1091
+
1092
+ // If we don't include base product, we need at least one option selected
1093
+ if (!shouldIncludeBaseProduct && selectedOptions.length === 0) {
1094
+ showToast(
1095
+ "Please select an option",
1096
+ "At least one option must be selected.",
1097
+ "error"
1098
+ );
1099
+ return;
1100
+ }
1101
+
1102
+ // Create a single consolidated cart item
1103
+ const cartItem = {
1104
+ id: selectedVariant?.id ?? baseVariant?.id ?? product.id,
1105
+ name: product.name,
1106
+ price: shouldIncludeBaseProduct ? currentPrice : 0,
1107
+ image: images[0]?.url ?? "",
1108
+ category: product?.category?.name ?? "N/A",
1109
+ quantity,
1110
+ selectedOptions: selectedOptions.length > 0 ? selectedOptions : undefined,
1111
+ customInputs: Object.keys(nonSkuInputs).length > 0 ? nonSkuInputs : undefined,
1112
+ skipBaseProduct: !shouldIncludeBaseProduct,
1113
+ };
1114
+
1115
+ // Add consolidated item to cart (store handles adding all variants to Saleor)
1116
+ await addToCart(cartItem);
1117
+
1118
+ // If there are non-SKU options, update checkout line metadata
1119
+ if (visibleNonSkuOptions.length > 0 && Object.keys(nonSkuInputs).length > 0) {
1120
+ const state = useGlobalStore.getState();
1121
+ const checkoutId = state.checkoutId;
1122
+
1123
+ if (checkoutId) {
1124
+ try {
1125
+ const token = localStorage.getItem("token");
1126
+ const checkoutRes = await fetch(getSaleorApiUrl(), {
1127
+ method: "POST",
1128
+ headers: {
1129
+ "Content-Type": "application/json",
1130
+ ...(token && { Authorization: `Bearer ${token}` }),
1131
+ },
1132
+ body: JSON.stringify({
1133
+ query: `
1134
+ query GetCheckoutLines($id: ID!) {
1135
+ checkout(id: $id) {
1136
+ lines {
1137
+ id
1138
+ variant { id }
1139
+ }
1140
+ }
1141
+ }
1142
+ `,
1143
+ variables: { id: checkoutId },
1144
+ }),
1145
+ });
1146
+
1147
+ if (checkoutRes.ok) {
1148
+ const checkoutData = await checkoutRes.json();
1149
+ const lines = checkoutData?.data?.checkout?.lines || [];
1150
+
1151
+ // Find the line for the base product
1152
+ const targetLine = lines.find(
1153
+ (line: { id: string; variant: { id: string } }) =>
1154
+ line.variant.id === cartItem.id
1155
+ );
1156
+
1157
+ if (targetLine) {
1158
+ const metadata: MetadataInput[] = Object.entries(nonSkuInputs)
1159
+ .filter(([, value]) => value.trim())
1160
+ .map(([key, value]) => ({ key, value }));
1161
+
1162
+ await updateCheckoutLineMetadata(targetLine.id, metadata);
1163
+ }
1164
+ }
1165
+ } catch (err) {
1166
+ console.error("Error updating checkout line metadata:", err);
1167
+ }
1168
+ }
1169
+ }
1170
+
1171
+ // Track add to cart event in GTM
1172
+ const productData: Product = {
1173
+ item_id: selectedVariant?.id ?? product.id,
1174
+ item_name: product.name,
1175
+ item_category: product?.category?.name || "Products",
1176
+ price: displayPrice,
1177
+ quantity: quantity,
1178
+ currency: "USD",
1179
+ item_brand: product?.category?.name || undefined,
1180
+ };
1181
+
1182
+ gtmAddToCart(
1183
+ [productData],
1184
+ "USD",
1185
+ displayPrice * quantity,
1186
+ gtmConfig?.container_id
1187
+ );
1188
+
1189
+ showToast(
1190
+ "ITEM ADDED TO CART",
1191
+ "Your item has been added. You can continue shopping or proceed to checkout.",
1192
+ "success"
1193
+ );
1194
+ } catch {
1195
+ showToast("Failed to add to cart", "Please try again later.", "error");
1196
+ } finally {
1197
+ setTimeout(() => setIsAdding(false), 400);
1198
+ }
1199
+ };
1200
+
1201
+ // Build Address from account node
1202
+ const buildAddressFromAccount = useCallback(
1203
+ (
1204
+ acc?: {
1205
+ firstName?: string | null;
1206
+ lastName?: string | null;
1207
+ streetAddress1?: string | null;
1208
+ city?: string | null;
1209
+ postalCode?: string | null;
1210
+ country?: { code?: string | null } | null;
1211
+ countryArea?: string | null;
1212
+ phone?: string | null;
1213
+ companyName?: string | null;
1214
+ } | null
1215
+ ): AddressInputTS | undefined => {
1216
+ if (!acc) return undefined;
1217
+ return {
1218
+ firstName: acc.firstName || "Guest",
1219
+ lastName: acc.lastName || "User",
1220
+ streetAddress1: acc.streetAddress1 || "N/A",
1221
+ city: acc.city || "Karachi",
1222
+ postalCode: acc.postalCode || "00000",
1223
+ country: acc.country?.code || "US",
1224
+ countryArea: acc.countryArea || undefined,
1225
+ phone: acc.phone || undefined,
1226
+ };
1227
+ },
1228
+ []
1229
+ );
1230
+
1231
+ // BUY NOW (add to cart first, then create checkout and redirect)
1232
+ const handleBuyNow = useCallback(async () => {
1233
+ if (!product) {
1234
+ showToast(
1235
+ "Product not found",
1236
+ "Please try again later.",
1237
+ "error"
1238
+ );
1239
+ return;
1240
+ }
1241
+
1242
+ // Validate option sets and non-SKU inputs
1243
+ if (!validateOptionsAndInputs()) {
1244
+ showToast(
1245
+ "Required fields missing",
1246
+ "Please fill in all required fields before buying.",
1247
+ "error"
1248
+ );
1249
+ return;
1250
+ }
1251
+
1252
+ // Collect all selected option set variants as CartItemOptions
1253
+ const selectedOptions: CartItemOption[] = [];
1254
+ let shouldIncludeBaseProduct = true;
1255
+
1256
+ for (const optionSet of optionSets) {
1257
+ const selections = optionSetSelections[optionSet.name] || [];
1258
+ for (const variantId of selections) {
1259
+ const variant = optionSet.variants.find((v) => v.id === variantId);
1260
+ if (variant) {
1261
+ const optionMeta = parseVariantOptionMetadata(variant);
1262
+
1263
+ if (optionMeta?.base_product_required === false) {
1264
+ shouldIncludeBaseProduct = false;
1265
+ }
1266
+
1267
+ selectedOptions.push({
1268
+ variantId: variant.id,
1269
+ name: variant.name,
1270
+ price: variant.pricing?.price?.gross?.amount ?? 0,
1271
+ optionSetName: optionSet.name,
1272
+ optionSetLabel: optionSet.label,
1273
+ });
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ // Validate that we have something to buy
1279
+ if (!shouldIncludeBaseProduct && selectedOptions.length === 0) {
1280
+ showToast(
1281
+ "Please select an option",
1282
+ "Please select at least one option before buying.",
1283
+ "error"
1284
+ );
1285
+ return;
1286
+ }
1287
+
1288
+ if (shouldIncludeBaseProduct && !selectedVariant?.id) {
1289
+ showToast(
1290
+ "Please select a variant",
1291
+ "Please select a variant before buying.",
1292
+ "error"
1293
+ );
1294
+ return;
1295
+ }
1296
+
1297
+ if (quantity < 1) {
1298
+ showToast(
1299
+ "Quantity must be at least 1",
1300
+ "Please enter a quantity of at least 1.",
1301
+ "error"
1302
+ );
1303
+ return;
1304
+ }
1305
+
1306
+ try {
1307
+ setBuying(true);
1308
+
1309
+ // Create consolidated cart item
1310
+ const baseVariantIdForCart = selectedVariant?.id ?? baseVariant?.id ?? product.id;
1311
+ const cartItem = {
1312
+ id: baseVariantIdForCart,
1313
+ name: product.name,
1314
+ price: shouldIncludeBaseProduct ? currentPrice : 0,
1315
+ image: images[0]?.url ?? "",
1316
+ category: product?.category?.name ?? "N/A",
1317
+ quantity,
1318
+ selectedOptions: selectedOptions.length > 0 ? selectedOptions : undefined,
1319
+ customInputs: Object.keys(nonSkuInputs).length > 0 ? nonSkuInputs : undefined,
1320
+ skipBaseProduct: !shouldIncludeBaseProduct,
1321
+ };
1322
+
1323
+ // Add consolidated item to cart
1324
+ await addToCart(cartItem);
1325
+
1326
+ // Clear any stale checkout in store + localStorage
1327
+ clearStoredCheckout();
1328
+ try {
1329
+ useGlobalStore.getState().setCheckoutId(null);
1330
+ const setTok = useGlobalStore.getState().setCheckoutToken as
1331
+ | ((v: string | null) => void)
1332
+ | undefined;
1333
+ setTok?.(null);
1334
+ } catch {}
1335
+
1336
+ // Build lines for checkout (base + options)
1337
+ const lines: CheckoutLineInputTS[] = [];
1338
+ // Only add base product if shouldIncludeBaseProduct is true
1339
+ if (shouldIncludeBaseProduct) {
1340
+ lines.push({ variantId: baseVariantIdForCart, quantity });
1341
+ }
1342
+ for (const opt of selectedOptions) {
1343
+ lines.push({ variantId: opt.variantId, quantity });
1344
+ }
1345
+
1346
+ // Email
1347
+ const email =
1348
+ (isLoggedIn
1349
+ ? user?.email || meData?.me?.email || ""
1350
+ : guestEmail || "guest@example.com") || "guest@example.com";
1351
+
1352
+ // Create checkout without addresses to avoid validation errors
1353
+ const { checkoutId, checkoutToken } = await createCheckout({
1354
+ channel,
1355
+ email,
1356
+ lines,
1357
+ });
1358
+
1359
+ // If there are non-SKU options, update checkout line metadata
1360
+ if (visibleNonSkuOptions.length > 0 && Object.keys(nonSkuInputs).length > 0) {
1361
+ try {
1362
+ const token = localStorage.getItem("token");
1363
+ const checkoutRes = await fetch(getSaleorApiUrl(), {
1364
+ method: "POST",
1365
+ headers: {
1366
+ "Content-Type": "application/json",
1367
+ ...(token && { Authorization: `Bearer ${token}` }),
1368
+ },
1369
+ body: JSON.stringify({
1370
+ query: `
1371
+ query GetCheckoutLines($id: ID!) {
1372
+ checkout(id: $id) {
1373
+ lines {
1374
+ id
1375
+ variant { id }
1376
+ }
1377
+ }
1378
+ }
1379
+ `,
1380
+ variables: { id: checkoutId },
1381
+ }),
1382
+ });
1383
+
1384
+ if (checkoutRes.ok) {
1385
+ const checkoutData = await checkoutRes.json();
1386
+ const checkoutLines = checkoutData?.data?.checkout?.lines || [];
1387
+
1388
+ const targetLine = checkoutLines.find(
1389
+ (line: { id: string; variant: { id: string } }) =>
1390
+ line.variant.id === baseVariantIdForCart
1391
+ );
1392
+
1393
+ if (targetLine) {
1394
+ const metadata: MetadataInput[] = Object.entries(nonSkuInputs)
1395
+ .filter(([, value]) => value.trim())
1396
+ .map(([key, value]) => ({ key, value }));
1397
+
1398
+ await updateCheckoutLineMetadata(targetLine.id, metadata);
1399
+ }
1400
+ }
1401
+ } catch (err) {
1402
+ console.error("Error updating checkout line metadata:", err);
1403
+ }
1404
+ }
1405
+
1406
+ // Persist in store + localStorage
1407
+ setCheckoutId(checkoutId);
1408
+ if (checkoutToken) setCheckoutToken(checkoutToken);
1409
+ try {
1410
+ localStorage.setItem("checkoutId", checkoutId);
1411
+ if (checkoutToken) localStorage.setItem("checkoutToken", checkoutToken);
1412
+ } catch {}
1413
+
1414
+ // Go
1415
+ router.push(`/checkout?checkoutId=${encodeURIComponent(checkoutId)}`);
1416
+ } catch (e) {
1417
+ console.error("[BuyNow] error:", e);
1418
+ showToast(
1419
+ e instanceof Error ? e.message : "Unable to start checkout",
1420
+ "error"
1421
+ );
1422
+ } finally {
1423
+ setBuying(false);
1424
+ }
1425
+ }, [
1426
+ product,
1427
+ selectedVariant,
1428
+ quantity,
1429
+ addToCart,
1430
+ currentPrice,
1431
+ images,
1432
+ isLoggedIn,
1433
+ user?.email,
1434
+ meData?.me?.email,
1435
+ guestEmail,
1436
+ guestShippingInfo,
1437
+ accountShipping,
1438
+ accountBilling,
1439
+ buildAddressFromAccount,
1440
+ channel,
1441
+ setCheckoutId,
1442
+ setCheckoutToken,
1443
+ router,
1444
+ showToast,
1445
+ validateOptionsAndInputs,
1446
+ optionSets,
1447
+ optionSetSelections,
1448
+ visibleNonSkuOptions,
1449
+ nonSkuInputs,
1450
+ updateCheckoutLineMetadata,
1451
+ ]);
1452
+ const productBreadcrumbItems = [
1453
+ { text: "HOME", link: "/" },
1454
+ { text: "PRODUCT", link: "/products/all" },
1455
+ { text: product?.name ?? "" },
1456
+ ];
1457
+
1458
+ const baseText =
1459
+ "text-[var(--color-secondary-800)] font-secondary -tracking-[0.045px]";
1460
+ type HeadingTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
1461
+
1462
+ // NOTE: Assumes product.description (Editor.js JSON) is already sanitized server-side.
1463
+ const renderBlock = (b: EditorBlock) => {
1464
+ switch (b.type) {
1465
+ case "quote": {
1466
+ const align =
1467
+ b.data.alignment === "center"
1468
+ ? "text-center"
1469
+ : b.data.alignment === "right"
1470
+ ? "text-right"
1471
+ : "text-left";
1472
+ return (
1473
+ <figure key={b.id} className={`not-prose ${align}`}>
1474
+ <blockquote
1475
+ className="border-l-4 pl-4 py-2 italic bg-[var(--color-secondary-200)] text-[var(--color-secondary-800)]"
1476
+ dangerouslySetInnerHTML={{ __html: b.data.text || "" }}
1477
+ />
1478
+ {b.data.caption && (
1479
+ <figcaption
1480
+ className="mt-1 text-sm text-[var(--color-secondary-600)]"
1481
+ dangerouslySetInnerHTML={{ __html: b.data.caption }}
1482
+ />
1483
+ )}
1484
+ </figure>
1485
+ );
1486
+ }
1487
+ case "header": {
1488
+ const level = Math.min(Math.max(b.data.level ?? 1, 1), 6);
1489
+ const Tag = `h${level}` as HeadingTag;
1490
+ return (
1491
+ <Tag
1492
+ key={b.id}
1493
+ className={`${baseText} ${
1494
+ level === 1 ? "text-2xl font-semibold" : ""
1495
+ }`}
1496
+ dangerouslySetInnerHTML={{ __html: b.data.text || "" }}
1497
+ />
1498
+ );
1499
+ }
1500
+ case "list": {
1501
+ const ordered = (b.data.style || "unordered") === "ordered";
1502
+ const ListTag = (ordered ? "ol" : "ul") as "ol" | "ul";
1503
+ return (
1504
+ <ListTag
1505
+ key={b.id}
1506
+ className={`${baseText} pl-5 space-y-2 ${
1507
+ ordered ? "list-decimal" : "list-disc"
1508
+ } marker:text-[var(--color-primary-600)] text-sm lg:text-base`}
1509
+ >
1510
+ {b.data.items.map((it, i) => (
1511
+ <li
1512
+ key={`${b.id}-${i}`}
1513
+ dangerouslySetInnerHTML={{ __html: it }}
1514
+ />
1515
+ ))}
1516
+ </ListTag>
1517
+ );
1518
+ }
1519
+ case "paragraph":
1520
+ default: {
1521
+ const html = (b.data.text || "").replace(/\n/g, "<br/>");
1522
+
1523
+ if (html.includes("<dt>") && html.includes("<dd>")) {
1524
+ const parser = new DOMParser();
1525
+ const doc = parser.parseFromString(html, "text/html");
1526
+
1527
+ const allDts = Array.from(doc.querySelectorAll("dt"));
1528
+ const allDds = Array.from(doc.querySelectorAll("dd"));
1529
+
1530
+ const pairs: Array<{ term: string; description: string }> = [];
1531
+
1532
+ allDts.forEach((dt, i) => {
1533
+ const term = dt.textContent?.trim() || "";
1534
+ const description = allDds[i]?.textContent?.trim() || "";
1535
+
1536
+ if (term) {
1537
+ pairs.push({ term, description });
1538
+ }
1539
+ });
1540
+
1541
+ const categoryHideDiv = doc.querySelector(".category-hide");
1542
+ let remainingText = "";
1543
+
1544
+ if (categoryHideDiv) {
1545
+ const bodyContent = doc.body.textContent || "";
1546
+ const categoryShowDiv = doc.querySelector(".category-show");
1547
+
1548
+ if (categoryShowDiv) {
1549
+ const clone = doc.body.cloneNode(true) as HTMLElement;
1550
+ const showDivClone = clone.querySelector(".category-show");
1551
+ if (showDivClone) {
1552
+ showDivClone.remove();
1553
+ }
1554
+ remainingText = clone.textContent?.trim() || "";
1555
+ }
1556
+ } else {
1557
+ const dlElement = doc.querySelector("dl");
1558
+ if (dlElement) {
1559
+ const parent = dlElement.parentElement;
1560
+ if (parent) {
1561
+ let nextSibling = parent.nextSibling;
1562
+ const textParts: string[] = [];
1563
+
1564
+ while (nextSibling) {
1565
+ if (nextSibling.nodeType === Node.TEXT_NODE) {
1566
+ const text = nextSibling.textContent?.trim();
1567
+ if (text) textParts.push(text);
1568
+ } else if (nextSibling.nodeType === Node.ELEMENT_NODE) {
1569
+ const text = (nextSibling as Element).textContent?.trim();
1570
+ if (text) textParts.push(text);
1571
+ }
1572
+ nextSibling = nextSibling.nextSibling;
1573
+ }
1574
+
1575
+ remainingText = textParts.join(" ");
1576
+ }
1577
+ }
1578
+ }
1579
+
1580
+ if (pairs.length > 0) {
1581
+ return (
1582
+ <div key={b.id}>
1583
+ <div className="w-full my-4">
1584
+ <table className="w-full border-collapse border border-[var(--color-secondary-300)]">
1585
+ <tbody>
1586
+ {pairs.map((item, i) => (
1587
+ <tr
1588
+ key={i}
1589
+ className="border-b border-[var(--color-secondary-200)] hover:bg-[var(--color-secondary-50)] transition-colors"
1590
+ >
1591
+ <td
1592
+ className={`px-3 py-2 font-semibold ${baseText} text-sm lg:text-base bg-gray-200 w-1/3 align-top border-r border-[var(--color-secondary-200)]`}
1593
+ >
1594
+ {item.term}
1595
+ </td>
1596
+ <td
1597
+ className={`px-3 py-2 ${baseText} text-sm lg:text-base align-top`}
1598
+ >
1599
+ {item.description}
1600
+ </td>
1601
+ </tr>
1602
+ ))}
1603
+ </tbody>
1604
+ </table>
1605
+ </div>
1606
+ {remainingText && (
1607
+ <div className={`${baseText} text-sm lg:text-base mt-4`}>
1608
+ {remainingText}
1609
+ </div>
1610
+ )}
1611
+ </div>
1612
+ );
1613
+ }
1614
+ }
1615
+ return (
1616
+ <div
1617
+ key={b.id}
1618
+ className={`${baseText} text-sm lg:text-base`}
1619
+ dangerouslySetInnerHTML={{ __html: html }}
1620
+ />
1621
+ );
1622
+ }
1623
+ }
1624
+ };
1625
+
1626
+ const renderDescription = () => {
1627
+ try {
1628
+ const parsed = JSON.parse(raw) as { blocks?: EditorBlock[] };
1629
+ if (parsed?.blocks?.length) {
1630
+ return (
1631
+ <div>
1632
+ <div
1633
+ ref={descriptionRef}
1634
+ className={`space-y-2 [&_ul]:pl-5 [&_ol]:pl-5 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:marker:text-[var(--color-primary-600)] [&_a]:underline [&_a]:text-[var(--color-primary-600)] hover:[&_a]:text-[var(--color-primary-700)] overflow-hidden transition-all duration-300
1635
+ ${!showFull ? "line-clamp-[10]" : ""}`}
1636
+ style={{ maxHeight: showFull ? "none" : `${maxHeight}px` }}
1637
+ >
1638
+ {parsed.blocks.map(renderBlock)}
1639
+ </div>
1640
+
1641
+ {isOverflow && (
1642
+ <CommonButton
1643
+ onClick={toggleShow}
1644
+ className={`px-0 mt-3 underline text-sm md:text-base hover:underline-offset-4 hover:text-[var(--color-primary)]`}
1645
+ >
1646
+ {showFull ? "View Less" : "View More"}
1647
+ </CommonButton>
1648
+ )}
1649
+ </div>
1650
+ );
1651
+ }
1652
+ } catch {
1653
+ // Fallback to plain text
1654
+ return <p className={`${baseText} text-lg`}>{raw}</p>;
1655
+ }
1656
+ };
1657
+ const btnSecondary =
1658
+ "border border-gray-300 text-gray-700 hover:bg-gray-50 font-semibold transition-colors";
1659
+
1660
+ const isLoading =
1661
+ (loading || oldSlugLoading || isFetchingMore) && !isComingFromRedirect;
1662
+
1663
+ const [isZoomed, setIsZoomed] = useState(false);
1664
+ const [mousePosition, setMousePosition] = useState({ x: 50, y: 50 });
1665
+ const thumbnailContainerRef = useRef<HTMLDivElement>(null);
1666
+
1667
+ const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
1668
+ if (!isZoomed) return;
1669
+
1670
+ const rect = e.currentTarget.getBoundingClientRect();
1671
+ const x = ((e.clientX - rect.left) / rect.width) * 100;
1672
+ const y = ((e.clientY - rect.top) / rect.height) * 100;
1673
+
1674
+ setMousePosition({ x, y });
1675
+ };
1676
+
1677
+ const handleMouseEnter = () => setIsZoomed(true);
1678
+ const handleMouseLeave = () => {
1679
+ setIsZoomed(false);
1680
+ setMousePosition({ x: 50, y: 50 });
1681
+ };
1682
+
1683
+ const scrollToSelectedThumbnail = useCallback(
1684
+ (imageUrl: string) => {
1685
+ if (!thumbnailContainerRef.current) return;
1686
+
1687
+ const container = thumbnailContainerRef.current;
1688
+ const selectedIndex = images.findIndex((img) => img.url === imageUrl);
1689
+
1690
+ if (selectedIndex === -1) return;
1691
+
1692
+ const thumbnailWidth = 80;
1693
+ const scrollPosition = selectedIndex * (thumbnailWidth + 8);
1694
+
1695
+ container.scrollTo({
1696
+ left: scrollPosition - container.clientWidth / 2 + thumbnailWidth / 2,
1697
+ behavior: "smooth",
1698
+ });
1699
+ },
1700
+ [images]
1701
+ );
1702
+
1703
+ useEffect(() => {
1704
+ if (selectedImage) {
1705
+ scrollToSelectedThumbnail(selectedImage);
1706
+ }
1707
+ }, [selectedImage, scrollToSelectedThumbnail]);
1708
+
1709
+ return (
1710
+ <>
1711
+ <div className="lg:container lg:mx-auto px-4 py-6 md:px-6 md:py-8 lg:px-4 lg:py-10">
1712
+ {isLoading && (
1713
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
1714
+ <div>
1715
+ <div className="relative w-full aspect-square bg-gray-100 overflow-hidden">
1716
+ <div className="absolute inset-0 flex items-center justify-center">
1717
+ <div className="animate-pulse w-3/4 h-3/4 bg-gray-200 " />
1718
+ </div>
1719
+ </div>
1720
+ <div className="flex gap-2 mt-2 md:mt-5">
1721
+ <SkeletonLoader
1722
+ type="product"
1723
+ count={4}
1724
+ className="w-20 h-20"
1725
+ />
1726
+ </div>
1727
+ </div>
1728
+ <div>
1729
+ <div className="space-y-3">
1730
+ <div className="h-8 bg-gray-200 rounded w-2/3 animate-pulse" />
1731
+ <div className="h-5 bg-gray-200 rounded w-1/3 animate-pulse" />
1732
+ <div className="h-24 bg-gray-100 rounded animate-pulse" />
1733
+ <div className="h-10 bg-gray-200 rounded w-1/2 animate-pulse" />
1734
+ </div>
1735
+ </div>
1736
+ </div>
1737
+ )}
1738
+
1739
+ {error && <div className="text-red-600">Failed to load product.</div>}
1740
+ {!isLoading &&
1741
+ !product &&
1742
+ !productWithOldSlug &&
1743
+ !error &&
1744
+ !isComingFromRedirect && (
1745
+ <div className="text-center py-12">
1746
+ <h2 className="text-2xl font-semibold text-[var(--color-secondary-800)] mb-2">
1747
+ Product Not Found
1748
+ </h2>
1749
+ <p className="text-[var(--color-secondary-600)] mb-6">
1750
+ The product you&apos;re looking for doesn&apos;t exist or has
1751
+ been removed.
1752
+ </p>
1753
+ <CommonButton
1754
+ onClick={() => router.push("/products/all")}
1755
+ variant="primary"
1756
+ className="mx-auto"
1757
+ >
1758
+ Browse All Products
1759
+ </CommonButton>
1760
+ </div>
1761
+ )}
1762
+
1763
+ {product && (
1764
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16">
1765
+ {/* Image Gallery */}
1766
+ <div>
1767
+ <Breadcrumb items={productBreadcrumbItems} />
1768
+ <div className="lg:sticky lg:top-36 lg:self-start">
1769
+ <div
1770
+ className="relative w-full aspect-square max-h-[400px] md:max-h-[500px] lg:max-h-[450px] mt-3 bg-[#F7F7F7] border border-[var(--color-secondary-200)] overflow-hidden cursor-zoom-in"
1771
+ onMouseMove={handleMouseMove}
1772
+ onMouseEnter={handleMouseEnter}
1773
+ onMouseLeave={handleMouseLeave}
1774
+ >
1775
+ {hasDiscount && (
1776
+ <span className="absolute top-3 right-3 z-10 inline-flex items-center bg-[var(--color-primary-600)] px-3 py-1 text-base uppercase text-white font-secondary -tracking-[0.04px]">
1777
+ Sale
1778
+ </span>
1779
+ )}
1780
+ {selectedImage ? (
1781
+ <Image
1782
+ src={selectedImage}
1783
+ alt={product.name || "Product image"}
1784
+ fill
1785
+ className="object-contain transition-transform duration-200 ease-out"
1786
+ style={{
1787
+ transform: isZoomed ? "scale(2.5)" : "scale(1)",
1788
+ transformOrigin: `${mousePosition.x}% ${mousePosition.y}%`,
1789
+ }}
1790
+ />
1791
+ ) : (
1792
+ <Image
1793
+ src={"/no-image-avail-large.png"}
1794
+ alt={"no-image-avail-large"}
1795
+ fill
1796
+ quality={90}
1797
+ sizes="100vw"
1798
+ className="object-contain transition-transform duration-200 ease-out"
1799
+ style={{
1800
+ transform: isZoomed ? "scale(2.5)" : "scale(1)",
1801
+ transformOrigin: `${mousePosition.x}% ${mousePosition.y}%`,
1802
+ }}
1803
+ />
1804
+ )}
1805
+ </div>
1806
+ {images.length > 0 && (
1807
+ <div className="relative flex items-center justify-between gap-2 mt-3">
1808
+ {/* Previous Arrow */}
1809
+ <button
1810
+ type="button"
1811
+ className="cursor-pointer size-fit"
1812
+ onClick={() => {
1813
+ const currentIndex = images.findIndex(
1814
+ (img) => img.url === selectedImage
1815
+ );
1816
+ const prevIndex =
1817
+ currentIndex > 0
1818
+ ? currentIndex - 1
1819
+ : images.length - 1;
1820
+ setSelectedImage(images[prevIndex].url);
1821
+ }}
1822
+ disabled={images.length <= 1}
1823
+ >
1824
+ <span
1825
+ style={{
1826
+ color: "var(--color-secondary-800)",
1827
+ }}
1828
+ className="size-8 md:size-10 block p-2 rounded-full bg-[var(--color-secondary-200)] disabled:opacity-50 hover:bg-[var(--color-secondary-300)]"
1829
+ >
1830
+ {SwiperArrowIconLeft}
1831
+ </span>
1832
+ </button>
1833
+
1834
+ {/* Thumbnails */}
1835
+ <div
1836
+ ref={thumbnailContainerRef}
1837
+ className="flex gap-2 overflow-auto hideScrollbar scroll-smooth"
1838
+ >
1839
+ {images.map((img) => {
1840
+ const isActive = selectedImage === img.url;
1841
+ return (
1842
+ <button
1843
+ key={img.id}
1844
+ type="button"
1845
+ className={`relative size-16 md:size-20 flex-shrink-0 lg:size-24 bg-[#F7F7F7] border cursor-pointer overflow-hidden transition-all duration-200 ${
1846
+ isActive
1847
+ ? "border-[var(--color-primary-600)] border-2 opacity-100 scale-105"
1848
+ : "opacity-50 border-[var(--color-secondary-200)] hover:opacity-75"
1849
+ }`}
1850
+ aria-pressed={isActive}
1851
+ onClick={() => setSelectedImage(img.url)}
1852
+ >
1853
+ <Image
1854
+ src={img.url}
1855
+ alt={img.alt || "thumb"}
1856
+ fill
1857
+ className="object-contain"
1858
+ />
1859
+ </button>
1860
+ );
1861
+ })}
1862
+ </div>
1863
+
1864
+ {/* Next Arrow */}
1865
+ <button
1866
+ type="button"
1867
+ className="cursor-pointer size-fit"
1868
+ onClick={() => {
1869
+ const currentIndex = images.findIndex(
1870
+ (img) => img.url === selectedImage
1871
+ );
1872
+ const nextIndex =
1873
+ currentIndex < images.length - 1
1874
+ ? currentIndex + 1
1875
+ : 0;
1876
+ setSelectedImage(images[nextIndex].url);
1877
+ }}
1878
+ disabled={images.length <= 1}
1879
+ >
1880
+ <span
1881
+ style={{
1882
+ color: "var(--color-secondary-800)",
1883
+ }}
1884
+ className="size-8 md:size-10 block p-2 rounded-full bg-[var(--color-secondary-200)] disabled:opacity-50 hover:bg-[var(--color-secondary-300)]"
1885
+ >
1886
+ {SwiperArrowIconRight}
1887
+ </span>
1888
+ </button>
1889
+ </div>
1890
+ )}
1891
+ </div>
1892
+ </div>
1893
+
1894
+ {/* Product Info */}
1895
+ <div>
1896
+ {/* Brand/Collection */}
1897
+ {/* {!!product.collections?.length && (
1898
+ <div className="font-secondary text-xl -tracking-[0.05px] text-[var(--color-primary-700)] font-bold flex gap-1">
1899
+ <span className="text-[var(--color-secondary-600)] font-normal">
1900
+ BRAND
1901
+ </span>
1902
+ {product.collections.map((c) => c.name).join(", ")}
1903
+ </div>
1904
+ )} */}
1905
+ <h2 className="text-xl lg:text-3xl font-primary uppercase -tracking-[0.09px] my-3">
1906
+ {product.name}
1907
+ </h2>
1908
+
1909
+ {/* Meta: SKU and stock */}
1910
+ {selectedVariant && (
1911
+ <div className="text-sm lg:text-base flex items-center gap-3 font-secondary -tracking-[0.045px] text-[var(--color-secondary-600)]">
1912
+ <span>
1913
+ SKU:{" "}
1914
+ <span className="font-semibold text-[var(--color-secondary-800)]">
1915
+ {selectedVariant.sku}
1916
+ </span>
1917
+ </span>
1918
+ {typeof selectedVariant.quantityAvailable === "number" && (
1919
+ <p className="font-semibold bg-[var(--color-secondary-100)] px-2 py-[2px] text-white">
1920
+ IN STOCK:{" "}
1921
+ <span className="font-medium">
1922
+ {selectedVariant.quantityAvailable}
1923
+ </span>
1924
+ </p>
1925
+ )}
1926
+ </div>
1927
+ )}
1928
+
1929
+ {product?.metadata.find((item) => item?.key === "availability")
1930
+ ?.value === "Please Call" && (
1931
+ <div className="border border-[var(--color-secondary-600)] px-4 mt-8 bg-[var(--color-secondary-200)] [&>div>p:nth-child(1)]:text-xl [&>div>p:nth-child(1)]:text-[var(--color-primary-500)] [&>div>p:nth-child(1)]:font-semibold">
1932
+ <EditorRenderer content={pdpContent?.content ?? null} />
1933
+ </div>
1934
+ )}
1935
+ {/* Price */}
1936
+ {product?.metadata.find((item) => item?.key === "availability")
1937
+ ?.value === "Limited Supply" && (
1938
+ <p className="font-semibold bg-[var(--color-secondary-100)] px-2 py-[2px] text-[var(--color-secondary-50)] mt-4 w-fit">
1939
+ LIMITED SUPPLY
1940
+ </p>
1941
+ )}
1942
+ {product?.metadata.find((item) => item?.key === "availability")
1943
+ ?.value !== "Please Call" && (
1944
+ <div className="my-5 flex items-center gap-2 font-secondary">
1945
+ {currentPrice === 0 ? (
1946
+ <div className="w-full border border-[var(--color-secondary-600)] px-4 mt-8 bg-[var(--color-secondary-200)] [&>div>p:nth-child(1)]:text-xl [&>div>p:nth-child(1)]:text-[var(--color-primary-500)] [&>div>p:nth-child(1)]:font-semibold">
1947
+ <EditorRenderer content={pdpContent?.content ?? null} />
1948
+ </div>
1949
+ ) : (
1950
+ <span className="text-2xl lg:text-3xl text-[var(--color-primary-700)] font-semibold -tracking-[0.075px]">
1951
+ {moneyFmt.format(displayPrice)}
1952
+ </span>
1953
+ )}
1954
+ {displayCompareAt !== null && (
1955
+ <span className="text-base lg:text-lg text-[var(--color-secondary-400)] line-through font-medium -tracking-[0.045px]">
1956
+ {moneyFmt.format(displayCompareAt)}
1957
+ </span>
1958
+ )}
1959
+ </div>
1960
+ )}
1961
+
1962
+ {/* Product Message from Metadata */}
1963
+ {(() => {
1964
+ const productMessage = product?.metadata?.find(
1965
+ (item) => item.key === "product_message"
1966
+ )?.value;
1967
+
1968
+ const shippingIsActive =
1969
+ product?.metadata
1970
+ ?.find((item) => item.key === "shipping_isactive")
1971
+ ?.value?.toLowerCase() === "true";
1972
+
1973
+ // Only show the product message if shipping_isactive is true
1974
+ if (productMessage && shippingIsActive) {
1975
+ return (
1976
+ <div className="my-5 p-4 bg-[var(--color-secondary-100)] border-l-4 border-[var(--color-primary-600)] rounded-r">
1977
+ <p className="text-sm lg:text-base text-[var(--color-secondary-800)] font-secondary -tracking-[0.045px]">
1978
+ {productMessage}
1979
+ </p>
1980
+ </div>
1981
+ );
1982
+ }
1983
+ return null;
1984
+ })()}
1985
+
1986
+ {/* Regular Variants (not part of option sets) */}
1987
+ {product?.metadata.find((item) => item?.key === "availability")
1988
+ ?.value !== "Please Call" && (
1989
+ <>
1990
+ {regularVariants.length > 1 && (
1991
+ <div className="mb-10">
1992
+ <label className="block font-secondary text-lg font-semibold text-[var(--color-secondary-800)] uppercase mb-4 -tracking-[0.045px]">
1993
+ Variant
1994
+ </label>
1995
+ <div
1996
+ className="grid grid-cols-1 md:grid-cols-2 gap-3"
1997
+ role="radiogroup"
1998
+ aria-label="Variants"
1999
+ >
2000
+ {regularVariants.map((v) => {
2001
+ const selected =
2002
+ (selectedVariant?.id ?? regularVariants[0]?.id) ===
2003
+ v.id;
2004
+ return (
2005
+ <div
2006
+ key={v.id}
2007
+ role="radio"
2008
+ aria-checked={selected}
2009
+ onClick={() => setSelectedVariantId(v.id)}
2010
+ className={`border flex justify-between font-secondary w-full items-center px-4 py-5 cursor-pointer transition-colors ${
2011
+ selected
2012
+ ? "border-[var(--color-primary-100)] bg-[var(--color-primary-50)] text-[var(--color-primary-700)]"
2013
+ : "border-[var(--color-secondary-200)] hover:bg-gray-50"
2014
+ }`}
2015
+ >
2016
+ <div className="flex items-center gap-3 text-sm md:text-base">
2017
+ <input
2018
+ type="radio"
2019
+ name="variant"
2020
+ className="accent-[var(--color-primary-600)]"
2021
+ checked={selected}
2022
+ onChange={() => setSelectedVariantId(v.id)}
2023
+ />
2024
+ <p
2025
+ title={v.name}
2026
+ className="font-medium -tracking-[0.04px]"
2027
+ >
2028
+ {v.name}
2029
+ </p>
2030
+ </div>
2031
+ </div>
2032
+ );
2033
+ })}
2034
+ </div>
2035
+ </div>
2036
+ )}
2037
+ </>
2038
+ )}
2039
+
2040
+ {/* Option Sets (SKU-based) */}
2041
+ {product?.metadata.find((item) => item?.key === "availability")
2042
+ ?.value !== "Please Call" &&
2043
+ visibleOptionSets.length > 0 && (
2044
+ <div className="mb-10 space-y-6">
2045
+ {visibleOptionSets.map((optionSet) => {
2046
+ const selectedIds =
2047
+ optionSetSelections[optionSet.name] || [];
2048
+ const isMulti = optionSet.type === "multi-enum";
2049
+ const errorKey = `optionSet_${optionSet.name}`;
2050
+ const hasError = !!validationErrors[errorKey];
2051
+
2052
+ return (
2053
+ <div key={optionSet.name}>
2054
+ <label className="block font-secondary text-lg font-semibold text-[var(--color-secondary-800)] uppercase mb-4 -tracking-[0.045px]">
2055
+ {optionSet.label}
2056
+ {optionSet.required && (
2057
+ <span className="text-red-500 ml-1">*</span>
2058
+ )}
2059
+ </label>
2060
+
2061
+ {isMulti ? (
2062
+ // Multi-select checkboxes
2063
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
2064
+ {optionSet.variants.map((variant) => {
2065
+ const isSelected = selectedIds.includes(
2066
+ variant.id
2067
+ );
2068
+ const price =
2069
+ variant.pricing?.price?.gross?.amount ?? 0;
2070
+ return (
2071
+ <div
2072
+ key={variant.id}
2073
+ onClick={() =>
2074
+ handleOptionSetChange(
2075
+ optionSet.name,
2076
+ variant.id,
2077
+ true
2078
+ )
2079
+ }
2080
+ className={`border flex justify-between font-secondary w-full items-center px-4 py-5 cursor-pointer transition-colors ${
2081
+ isSelected
2082
+ ? "border-[var(--color-primary-100)] bg-[var(--color-primary-50)] text-[var(--color-primary-700)]"
2083
+ : "border-[var(--color-secondary-200)] hover:bg-gray-50"
2084
+ }`}
2085
+ >
2086
+ <div className="flex items-center gap-3 text-sm md:text-base">
2087
+ <input
2088
+ type="checkbox"
2089
+ className="accent-[var(--color-primary-600)]"
2090
+ checked={isSelected}
2091
+ onChange={() =>
2092
+ handleOptionSetChange(
2093
+ optionSet.name,
2094
+ variant.id,
2095
+ true
2096
+ )
2097
+ }
2098
+ />
2099
+ <p
2100
+ title={variant.name}
2101
+ className="font-medium -tracking-[0.04px]"
2102
+ >
2103
+ {variant.name}
2104
+ </p>
2105
+ </div>
2106
+ {price > 0 && (
2107
+ <span className="text-sm text-[var(--color-secondary-600)]">
2108
+ +{moneyFmt.format(price)}
2109
+ </span>
2110
+ )}
2111
+ </div>
2112
+ );
2113
+ })}
2114
+ </div>
2115
+ ) : (
2116
+ // Single-select dropdown
2117
+ <select
2118
+ className={`w-full border px-4 py-3 font-secondary text-sm md:text-base -tracking-[0.04px] bg-white cursor-pointer focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-600)] ${
2119
+ hasError
2120
+ ? "border-red-500"
2121
+ : "border-[var(--color-secondary-200)]"
2122
+ }`}
2123
+ value={selectedIds[0] || ""}
2124
+ onChange={(e) =>
2125
+ handleOptionSetChange(
2126
+ optionSet.name,
2127
+ e.target.value,
2128
+ false
2129
+ )
2130
+ }
2131
+ >
2132
+ <option
2133
+ value=""
2134
+ disabled={optionSet.required}
2135
+ >
2136
+ {optionSet.deselect?.trim() || `Select ${optionSet.label}`}
2137
+ </option>
2138
+ {optionSet.variants.map((variant) => {
2139
+ const price =
2140
+ variant.pricing?.price?.gross?.amount ?? 0;
2141
+ return (
2142
+ <option key={variant.id} value={variant.id}>
2143
+ {variant.name}
2144
+ {price > 0
2145
+ ? ` (+${moneyFmt.format(price)})`
2146
+ : ""}
2147
+ </option>
2148
+ );
2149
+ })}
2150
+ </select>
2151
+ )}
2152
+
2153
+ {hasError && (
2154
+ <p className="text-red-500 text-sm mt-1">
2155
+ {validationErrors[errorKey]}
2156
+ </p>
2157
+ )}
2158
+ </div>
2159
+ );
2160
+ })}
2161
+ </div>
2162
+ )}
2163
+
2164
+ {/* Non-SKU Options (text, date, datetime inputs) */}
2165
+ {product?.metadata.find((item) => item?.key === "availability")
2166
+ ?.value !== "Please Call" &&
2167
+ visibleNonSkuOptions.length > 0 && (
2168
+ <div className="mb-10 space-y-6">
2169
+ {visibleNonSkuOptions.map((option) => {
2170
+ const errorKey = `nonSku_${option.name}`;
2171
+ const hasError = !!validationErrors[errorKey];
2172
+ const value = nonSkuInputs[option.name] || "";
2173
+
2174
+ return (
2175
+ <div key={option.name}>
2176
+ <label className="block font-secondary text-lg font-semibold text-[var(--color-secondary-800)] uppercase mb-4 -tracking-[0.045px]">
2177
+ {option.label}
2178
+ {option.required && (
2179
+ <span className="text-red-500 ml-1">*</span>
2180
+ )}
2181
+ </label>
2182
+
2183
+ {option.type === "text" && (
2184
+ <input
2185
+ type="text"
2186
+ className={`w-full border px-4 py-3 font-secondary text-sm md:text-base -tracking-[0.04px] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-600)] ${
2187
+ hasError
2188
+ ? "border-red-500"
2189
+ : "border-[var(--color-secondary-200)]"
2190
+ }`}
2191
+ value={value}
2192
+ onChange={(e) =>
2193
+ handleNonSkuInputChange(
2194
+ option.name,
2195
+ e.target.value
2196
+ )
2197
+ }
2198
+ placeholder={`Enter ${option.label.toLowerCase()}`}
2199
+ />
2200
+ )}
2201
+
2202
+ {option.type === "date" && (
2203
+ <input
2204
+ type="date"
2205
+ className={`w-full border px-4 py-3 font-secondary text-sm md:text-base -tracking-[0.04px] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-600)] ${
2206
+ hasError
2207
+ ? "border-red-500"
2208
+ : "border-[var(--color-secondary-200)]"
2209
+ }`}
2210
+ value={value}
2211
+ onChange={(e) =>
2212
+ handleNonSkuInputChange(
2213
+ option.name,
2214
+ e.target.value
2215
+ )
2216
+ }
2217
+ />
2218
+ )}
2219
+
2220
+ {option.type === "datetime" && (
2221
+ <input
2222
+ type="datetime-local"
2223
+ className={`w-full border px-4 py-3 font-secondary text-sm md:text-base -tracking-[0.04px] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-600)] ${
2224
+ hasError
2225
+ ? "border-red-500"
2226
+ : "border-[var(--color-secondary-200)]"
2227
+ }`}
2228
+ value={value}
2229
+ onChange={(e) =>
2230
+ handleNonSkuInputChange(
2231
+ option.name,
2232
+ e.target.value
2233
+ )
2234
+ }
2235
+ />
2236
+ )}
2237
+
2238
+ {hasError && (
2239
+ <p className="text-red-500 text-sm mt-1">
2240
+ {validationErrors[errorKey]}
2241
+ </p>
2242
+ )}
2243
+ </div>
2244
+ );
2245
+ })}
2246
+ </div>
2247
+ )}
2248
+ {/* Add to Cart + Buy Now */}
2249
+ {product?.metadata.find((item) => item?.key === "availability")
2250
+ ?.value !== "Please Call" && (
2251
+ <div className="space-y-3">
2252
+ <div
2253
+ className={`flex items-center group border border-[var(--color-secondary-200)] w-full ${
2254
+ currentPrice === 0 ? "opacity-50 pointer-events-none" : ""
2255
+ }`}
2256
+ >
2257
+ <button
2258
+ type="button"
2259
+ className={`${btnSecondary} px-2 py-3 !border-0 hover:!bg-[var(--color-secondary-200)] transition-all ease-in-out duration-300 h-full cursor-pointer w-full flex items-center justify-center`}
2260
+ onClick={decQty}
2261
+ >
2262
+ <span className="size-4 block">{MinusIcon}</span>
2263
+ </button>
2264
+ <input
2265
+ type="number"
2266
+ min={1}
2267
+ max={maxQty ?? undefined}
2268
+ value={quantity}
2269
+ inputMode="numeric"
2270
+ className="text-center group-hover:border-x-0 outline-none border-x border-[var(--color-secondary-200)] w-full pointer-events-none"
2271
+ onChange={(e) => onQtyInput(e.target.value)}
2272
+ />
2273
+ <button
2274
+ type="button"
2275
+ className={`${btnSecondary} px-2 py-3 !border-0 w-full cursor-pointer hover:!bg-[var(--color-secondary-200)] transition-all ease-in-out duration-300 flex items-center justify-center`}
2276
+ onClick={incQty}
2277
+ >
2278
+ <span className="size-4 block">{PlusIcon}</span>
2279
+ </button>
2280
+ </div>
2281
+
2282
+ <CommonButton
2283
+ className="w-full"
2284
+ onClick={handleAddToCart}
2285
+ disabled={!product || isAdding || currentPrice === 0}
2286
+ variant="secondary"
2287
+ >
2288
+ {isAdding ? (
2289
+ <span className="flex size-6 items-center text-black justify-center w-full">
2290
+ {SpinnerIcon}
2291
+ </span>
2292
+ ) : (
2293
+ "Add to Cart"
2294
+ )}
2295
+ </CommonButton>
2296
+
2297
+ <PrimaryButton
2298
+ content={buying ? "Processing..." : "Buy Now"}
2299
+ className="w-full text-base font-semibold leading-[24px] tracking-[-0.04px] py-3 px-4"
2300
+ onClick={handleBuyNow}
2301
+ disabled={
2302
+ buying ||
2303
+ currentPrice === 0 ||
2304
+ // Disable if no regular variant AND no option set selections
2305
+ (!selectedVariant &&
2306
+ Object.keys(optionSetSelections).length === 0)
2307
+ }
2308
+ />
2309
+ </div>
2310
+ )}
2311
+ <div
2312
+ onClick={() => setShowInquiryModal(true)}
2313
+ className="mt-3 flex items-center gap-1 cursor-pointer hover:text-[var(--color-primary)] transition-all ease-in-out duration-300"
2314
+ >
2315
+ {ProductInquiryIcon} <p>Item Inquiry</p>{" "}
2316
+ </div>
2317
+
2318
+
2319
+
2320
+ {/* Description */}
2321
+ <div className="mt-6">
2322
+ <h3 className="text-base md:text-lg lg:text-xl font-semibold text-[var(--color-secondary-800)] font-secondary uppercase -tracking-[0.06px] mb-3">
2323
+ PRODUCT DESCRIPTION
2324
+ </h3>
2325
+ <div className="prose max-w-none mb-6">
2326
+ {renderDescription()}
2327
+ </div>
2328
+ </div>
2329
+ {/* Extra details (Dimensions/Weight) - Only show if there are actual non-zero values */}
2330
+ {(() => {
2331
+ const hasLength =
2332
+ lengthVal && lengthVal !== "0" && parseFloat(lengthVal) !== 0;
2333
+ const hasWidth =
2334
+ widthVal && widthVal !== "0" && parseFloat(widthVal) !== 0;
2335
+ const hasHeight =
2336
+ heightVal && heightVal !== "0" && parseFloat(heightVal) !== 0;
2337
+ const hasWeight =
2338
+ selectedVariant?.weight && selectedVariant.weight.value !== 0;
2339
+
2340
+ const hasAnyDimensions =
2341
+ hasLength || hasWidth || hasHeight || hasWeight;
2342
+
2343
+ if (!hasAnyDimensions) return null;
2344
+
2345
+ return (
2346
+ <div className="mt-6">
2347
+ <h3 className="text-base md:text-lg lg:text-xl font-semibold text-[var(--color-secondary-800)] font-secondary uppercase -tracking-[0.06px] mb-3">
2348
+ Product Dimensions
2349
+ </h3>
2350
+ <ul className="text-sm lg:text-base -tracking-[0.045px] font-semibold font-secondary text-[var(--color-secondary-800)] list-disc marker:text-[var(--color-primary-600)] pl-5 space-y-2">
2351
+ {hasLength && (
2352
+ <li>
2353
+ Length:{" "}
2354
+ <span className="font-normal">
2355
+ {lengthVal} Inches
2356
+ </span>
2357
+ </li>
2358
+ )}
2359
+ {hasWidth && (
2360
+ <li>
2361
+ Width:{" "}
2362
+ <span className="font-normal">{widthVal} Inches</span>
2363
+ </li>
2364
+ )}
2365
+ {hasHeight && (
2366
+ <li>
2367
+ Height:{" "}
2368
+ <span className="font-normal">
2369
+ {heightVal} Inches
2370
+ </span>
2371
+ </li>
2372
+ )}
2373
+ {hasWeight && (
2374
+ <li>
2375
+ Weight:{" "}
2376
+ <span className="font-normal">
2377
+ {selectedVariant?.weight?.value}{" "}
2378
+ {selectedVariant?.weight?.unit}
2379
+ </span>
2380
+ </li>
2381
+ )}
2382
+ </ul>
2383
+ </div>
2384
+ );
2385
+ })()}
2386
+ </div>
2387
+ </div>
2388
+ )}
2389
+
2390
+ {/* Suggested Products */}
2391
+ {relatedProducts.length > 0 && (
2392
+ <div className="mt-12 lg:mt-16">
2393
+ <h2 className="text-xl lg:text-2xl font-primary uppercase -tracking-[0.06px] mb-6">
2394
+ Products We May Suggest
2395
+ </h2>
2396
+ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
2397
+ {relatedProducts.map((p) => (
2398
+ <ProductCard
2399
+ key={p.id}
2400
+ id={p.id}
2401
+ name={p.name}
2402
+ image={p.media?.[0]?.url || "/no-image-avail-large.png"}
2403
+ href={`/product/${encodeURIComponent(p.slug)}`}
2404
+ price={p.pricing?.priceRange?.start?.gross?.amount || 0}
2405
+ category_id={p.category?.id || ""}
2406
+ category={p.category?.name || "Uncategorized"}
2407
+ onSale={false}
2408
+ />
2409
+ ))}
2410
+ </div>
2411
+ </div>
2412
+ )}
2413
+
2414
+ {/* Toast */}
2415
+ {toast && (
2416
+ <div
2417
+ className="fixed top-4 right-4 z-50 space-y-3 animate-[slidein_.25s_ease-out]"
2418
+ aria-live="polite"
2419
+ >
2420
+ <Toast
2421
+ message={toast.message}
2422
+ type={toast.type}
2423
+ subParagraph={toast.subParagraph}
2424
+ duration={2500}
2425
+ onClose={() => setToast(null)}
2426
+ />
2427
+ <style jsx>{`
2428
+ @keyframes slidein {
2429
+ from {
2430
+ opacity: 0;
2431
+ transform: translateY(-6px);
2432
+ }
2433
+ to {
2434
+ opacity: 1;
2435
+ transform: translateY(0);
2436
+ }
2437
+ }
2438
+ `}</style>
2439
+ </div>
2440
+ )}
2441
+ </div>
2442
+ <ItemInquiryModal
2443
+ isModalOpen={showInquiryModal}
2444
+ onClose={() => setShowInquiryModal(false)}
2445
+ />
2446
+ </>
2447
+ );
2448
+ }