@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.
- package/.env.example +57 -0
- package/APPLE_PAY_QUICK_START.md +165 -0
- package/APPLE_PAY_SETUP.md +331 -0
- package/README.md +46 -0
- package/SEO_AUDIT_CHECKLIST_STATUS.md +244 -0
- package/SEO_AUDIT_REPORT.md +66 -0
- package/eslint.config.mjs +16 -0
- package/next-env.d.ts +5 -0
- package/next.config.ts +109 -0
- package/package.json +47 -0
- package/postcss.config.mjs +5 -0
- package/public/.well-known/apple-developer-merchantid-domain-association +1 -0
- package/public/Logo.png +0 -0
- package/public/brand-video.mp4 +0 -0
- package/public/favicon.ico +0 -0
- package/public/file.svg +1 -0
- package/public/footer/facebook.tsx +34 -0
- package/public/footer/instagram.tsx +27 -0
- package/public/footer/mail.tsx +5 -0
- package/public/footer/x.tsx +35 -0
- package/public/globe.svg +1 -0
- package/public/icons/Authorize.net.webp +0 -0
- package/public/icons/amex.gif +0 -0
- package/public/icons/appIcon.png +0 -0
- package/public/icons/discover.gif +0 -0
- package/public/icons/master.gif +0 -0
- package/public/icons/paypal.png +0 -0
- package/public/icons/stripe.png +0 -0
- package/public/icons/visa.gif +0 -0
- package/public/images/BackgroundNoise.png +0 -0
- package/public/images/footer-background.png +0 -0
- package/public/next.svg +1 -0
- package/public/no-image-avail-large.png +0 -0
- package/public/random-car-1.jpeg +0 -0
- package/public/random-car-2.png +0 -0
- package/public/random-car-3.jpg +0 -0
- package/public/random-car-4.jpg +0 -0
- package/public/random-car-5.jpg +0 -0
- package/public/star.svg +3 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/seo-audit/generate-checklist.mjs +156 -0
- package/src/app/(auth)/account/forgot-password/layout.tsx +16 -0
- package/src/app/(auth)/account/forgot-password/page.tsx +135 -0
- package/src/app/(auth)/account/login/layout.tsx +16 -0
- package/src/app/(auth)/account/login/page.tsx +288 -0
- package/src/app/(auth)/account/otp/layout.tsx +16 -0
- package/src/app/(auth)/account/otp/page.tsx +108 -0
- package/src/app/(auth)/account/register/layout.tsx +16 -0
- package/src/app/(auth)/account/register/page.tsx +431 -0
- package/src/app/(auth)/account/reset-password/layout.tsx +16 -0
- package/src/app/(auth)/account/reset-password/page.tsx +222 -0
- package/src/app/[slug]/page.tsx +43 -0
- package/src/app/about/loading.tsx +17 -0
- package/src/app/about/page.tsx +61 -0
- package/src/app/account/address/layout.tsx +15 -0
- package/src/app/account/address/page.tsx +166 -0
- package/src/app/account/head.tsx +4 -0
- package/src/app/account/layout.tsx +62 -0
- package/src/app/account/orders/[id]/layout.tsx +17 -0
- package/src/app/account/orders/[id]/page.tsx +115 -0
- package/src/app/account/orders/components/orderDetailsModal.tsx +410 -0
- package/src/app/account/orders/layout.tsx +15 -0
- package/src/app/account/orders/page.tsx +146 -0
- package/src/app/account/page.tsx +39 -0
- package/src/app/account/settings/components/editProfileSuccessModal.tsx +28 -0
- package/src/app/account/settings/layout.tsx +15 -0
- package/src/app/account/settings/page.tsx +260 -0
- package/src/app/api/affirm/check-status/route.ts +94 -0
- package/src/app/api/affirm/create-checkout/route.ts +109 -0
- package/src/app/api/affirm/get-config/route.ts +108 -0
- package/src/app/api/affirm/process-payment/route.ts +244 -0
- package/src/app/api/affirm/test-connection/route.ts +45 -0
- package/src/app/api/auth/clear/route.ts +16 -0
- package/src/app/api/auth/clear-cookies/route.ts +42 -0
- package/src/app/api/auth/set/route.ts +47 -0
- package/src/app/api/configuration/route.ts +18 -0
- package/src/app/api/dynamic-page/[slug]/route.ts +24 -0
- package/src/app/api/form-submission/route.ts +237 -0
- package/src/app/api/paypal/capture-order/route.ts +303 -0
- package/src/app/api/paypal/create-order/route.ts +211 -0
- package/src/app/api/paypal/get-config/route.ts +240 -0
- package/src/app/api/search-proxy/route.ts +52 -0
- package/src/app/authorize-net-success/layout.tsx +19 -0
- package/src/app/authorize-net-success/page.tsx +12 -0
- package/src/app/authorize-net-success/summary.tsx +486 -0
- package/src/app/blog/[slug]/blogContentRenderer.tsx +369 -0
- package/src/app/blog/[slug]/layout.tsx +17 -0
- package/src/app/blog/[slug]/page.tsx +151 -0
- package/src/app/blog/constant.tsx +147 -0
- package/src/app/blog/layout.tsx +31 -0
- package/src/app/blog/page.tsx +81 -0
- package/src/app/brand/[id]/BrandPageClient.tsx +188 -0
- package/src/app/brand/[id]/layout.tsx +17 -0
- package/src/app/brand/[id]/page.tsx +176 -0
- package/src/app/brands/components/brandsListingClient.tsx +97 -0
- package/src/app/brands/layout.tsx +31 -0
- package/src/app/brands/page.tsx +40 -0
- package/src/app/cancellation-policy/page.tsx +53 -0
- package/src/app/cart/layout.tsx +19 -0
- package/src/app/cart/page.tsx +752 -0
- package/src/app/category/[slug]/CategoryPageClient.tsx +377 -0
- package/src/app/category/[slug]/layout.tsx +17 -0
- package/src/app/category/[slug]/page.tsx +224 -0
- package/src/app/category/page.tsx +114 -0
- package/src/app/checkout/components/addNewAddressModal.tsx +474 -0
- package/src/app/checkout/layout.tsx +19 -0
- package/src/app/checkout/page.tsx +3312 -0
- package/src/app/components/account/AccountTabs.tsx +40 -0
- package/src/app/components/ads/GoogleAdSense.tsx +74 -0
- package/src/app/components/analytics/AnalyticsScripts.tsx +78 -0
- package/src/app/components/analytics/ConditionalGTMNoscript.tsx +24 -0
- package/src/app/components/analytics/ConditionalGoogleAnalytics.tsx +16 -0
- package/src/app/components/ancillary/AncillaryContent.tsx +7 -0
- package/src/app/components/auth/TokenExpirationHandler.tsx +8 -0
- package/src/app/components/blog/BlogList.tsx +112 -0
- package/src/app/components/checkout/AddressInformationSection.tsx +34 -0
- package/src/app/components/checkout/AddressManagement.tsx +571 -0
- package/src/app/components/checkout/CheckoutHeader.tsx +51 -0
- package/src/app/components/checkout/CheckoutQuestions.tsx +454 -0
- package/src/app/components/checkout/CheckoutTermsModal.tsx +81 -0
- package/src/app/components/checkout/ContactDetailsSection.tsx +52 -0
- package/src/app/components/checkout/DealerShippingSection.tsx +359 -0
- package/src/app/components/checkout/DeliveryMethodSection.tsx +249 -0
- package/src/app/components/checkout/OrderSummary.tsx +386 -0
- package/src/app/components/checkout/TermsContentRenderer.tsx +147 -0
- package/src/app/components/checkout/WillCallSection.tsx +133 -0
- package/src/app/components/checkout/affirmPayment.tsx +383 -0
- package/src/app/components/checkout/checkoutProcessingModal.tsx +96 -0
- package/src/app/components/checkout/googlePayButton.tsx +334 -0
- package/src/app/components/checkout/paymentStep.tsx +180 -0
- package/src/app/components/checkout/paypalPayment.tsx +1083 -0
- package/src/app/components/checkout/saleorNativePayment.tsx +1758 -0
- package/src/app/components/dynamicPage/DynamicPageRenderer.tsx +13 -0
- package/src/app/components/dynamicPage/HtmlWidgetRenderer.tsx +144 -0
- package/src/app/components/filtersCollapsible/index.tsx +365 -0
- package/src/app/components/globalSearch/index.tsx +423 -0
- package/src/app/components/layout/cartDropDown.tsx +628 -0
- package/src/app/components/layout/components/FooterNewsletter.tsx +21 -0
- package/src/app/components/layout/footer.tsx +283 -0
- package/src/app/components/layout/header/accountMenuDropdown.tsx +53 -0
- package/src/app/components/layout/header/components/CartBadge.tsx +18 -0
- package/src/app/components/layout/header/components/LoadingState.tsx +17 -0
- package/src/app/components/layout/header/components/MenuItemDropdown.tsx +124 -0
- package/src/app/components/layout/header/components/MobileNavbar.tsx +123 -0
- package/src/app/components/layout/header/components/NavbarActions.tsx +125 -0
- package/src/app/components/layout/header/components/NavbarBrand.tsx +29 -0
- package/src/app/components/layout/header/components/NavigationLinks.tsx +131 -0
- package/src/app/components/layout/header/hamMenuSlide.tsx +318 -0
- package/src/app/components/layout/header/header.tsx +44 -0
- package/src/app/components/layout/header/hooks/useDropdown.ts +45 -0
- package/src/app/components/layout/header/hooks/useNavbarData.ts +138 -0
- package/src/app/components/layout/header/hooks/useNavbarState.ts +66 -0
- package/src/app/components/layout/header/megaMenuDropdown.tsx +116 -0
- package/src/app/components/layout/header/navBar.tsx +121 -0
- package/src/app/components/layout/header/search.tsx +418 -0
- package/src/app/components/layout/header/styles/navbarStyles.ts +27 -0
- package/src/app/components/layout/header/topBar.tsx +214 -0
- package/src/app/components/layout/joinNewsletterForm/index.tsx +72 -0
- package/src/app/components/layout/mobileAccordian/index.tsx +92 -0
- package/src/app/components/layout/paymentMethods.tsx +75 -0
- package/src/app/components/layout/rootLayout.tsx +23 -0
- package/src/app/components/layout/siteInfo.tsx +103 -0
- package/src/app/components/layout/socialLinks.tsx +65 -0
- package/src/app/components/newsletterSection/emailListSection.tsx +224 -0
- package/src/app/components/newsletterSection/emailSectionServer.tsx +8 -0
- package/src/app/components/providers/ApolloWrapper.tsx +12 -0
- package/src/app/components/providers/AppConfigurationProvider.tsx +108 -0
- package/src/app/components/providers/GoogleAnalyticsProvider.tsx +149 -0
- package/src/app/components/providers/GoogleTagManagerProvider.tsx +31 -0
- package/src/app/components/providers/RecaptchaProvider.tsx +18 -0
- package/src/app/components/providers/ServerAppConfigurationProvider.tsx +133 -0
- package/src/app/components/providers/YMMStatusProvider.tsx +15 -0
- package/src/app/components/reuseableUI/AboutUs.tsx +115 -0
- package/src/app/components/reuseableUI/AddToCartClient.tsx +125 -0
- package/src/app/components/reuseableUI/EditorJsRenderer.tsx +219 -0
- package/src/app/components/reuseableUI/HeroSectionsearchByVehicle.tsx +188 -0
- package/src/app/components/reuseableUI/ImageWithFallback.tsx +41 -0
- package/src/app/components/reuseableUI/Toast.tsx +101 -0
- package/src/app/components/reuseableUI/blogCard.tsx +52 -0
- package/src/app/components/reuseableUI/brandCard.tsx +68 -0
- package/src/app/components/reuseableUI/breadcrumb.tsx +38 -0
- package/src/app/components/reuseableUI/categoryCard.tsx +37 -0
- package/src/app/components/reuseableUI/categorySkeleton.tsx +31 -0
- package/src/app/components/reuseableUI/commonButton.tsx +48 -0
- package/src/app/components/reuseableUI/defaultInputField/index.tsx +84 -0
- package/src/app/components/reuseableUI/emptyState.tsx +29 -0
- package/src/app/components/reuseableUI/errorTag.tsx +15 -0
- package/src/app/components/reuseableUI/heading/index.tsx +20 -0
- package/src/app/components/reuseableUI/input.tsx +117 -0
- package/src/app/components/reuseableUI/listCard.tsx +137 -0
- package/src/app/components/reuseableUI/loadingUI.tsx +12 -0
- package/src/app/components/reuseableUI/modalLayout.tsx +76 -0
- package/src/app/components/reuseableUI/newsletter/newsletterClient.tsx +622 -0
- package/src/app/components/reuseableUI/newsletter/newslettersHomeModal.tsx +68 -0
- package/src/app/components/reuseableUI/offerCard.tsx +42 -0
- package/src/app/components/reuseableUI/passwordRules/passwordRules.tsx +56 -0
- package/src/app/components/reuseableUI/primaryButton/index.tsx +34 -0
- package/src/app/components/reuseableUI/productCard.tsx +118 -0
- package/src/app/components/reuseableUI/productSkeleton.tsx +34 -0
- package/src/app/components/reuseableUI/searchByVehicle.tsx +187 -0
- package/src/app/components/reuseableUI/secondaryButton/index.tsx +34 -0
- package/src/app/components/reuseableUI/section.tsx +20 -0
- package/src/app/components/reuseableUI/select/index.tsx +98 -0
- package/src/app/components/reuseableUI/skeletonLoader.tsx +117 -0
- package/src/app/components/reuseableUI/statusTag.tsx +24 -0
- package/src/app/components/reuseableUI/tags/saleTag.tsx +19 -0
- package/src/app/components/reuseableUI/testimonialCard.tsx +93 -0
- package/src/app/components/richText/EditorRenderer.tsx +318 -0
- package/src/app/components/search/HierarchicalCategoryFilter.tsx +155 -0
- package/src/app/components/search/SearchFilters.tsx +155 -0
- package/src/app/components/search/YMMSearchSidebar.tsx +187 -0
- package/src/app/components/seo/ServerProductCard.tsx +91 -0
- package/src/app/components/seo/ServerProductGrid.tsx +45 -0
- package/src/app/components/shop/CategoryFilter.tsx +184 -0
- package/src/app/components/shop/ItemsPerPageSelect.tsx +69 -0
- package/src/app/components/shop/ItemsPerPageSelectClient.tsx +58 -0
- package/src/app/components/shop/MobileFilters.tsx +103 -0
- package/src/app/components/shop/ProductGridSkeleton.tsx +16 -0
- package/src/app/components/shop/ProductsGrid.tsx +230 -0
- package/src/app/components/shop/SearchFilter.tsx +218 -0
- package/src/app/components/shop/SearchFilterClient.tsx +122 -0
- package/src/app/components/shop/SearchLoadingOverlay.tsx +32 -0
- package/src/app/components/shop/ShopMobileFilters.tsx +205 -0
- package/src/app/components/showroom/VehicleSearchDropdowns.tsx +187 -0
- package/src/app/components/showroom/brandsSwiper.tsx +49 -0
- package/src/app/components/showroom/brandsSwiperClient copy.tsx +93 -0
- package/src/app/components/showroom/brandsSwiperClient.tsx +122 -0
- package/src/app/components/showroom/brandsSwiperServer.tsx +42 -0
- package/src/app/components/showroom/bundleProducts.tsx +120 -0
- package/src/app/components/showroom/categoryGrid.tsx +51 -0
- package/src/app/components/showroom/categoryGridServer.tsx +45 -0
- package/src/app/components/showroom/categorySwiper.tsx +115 -0
- package/src/app/components/showroom/featureStrip.tsx +139 -0
- package/src/app/components/showroom/offersSwiper.tsx +181 -0
- package/src/app/components/showroom/productGrid.tsx +56 -0
- package/src/app/components/showroom/productSwiper.tsx +119 -0
- package/src/app/components/showroom/promotion-slider.tsx +138 -0
- package/src/app/components/showroom/promotion.tsx +207 -0
- package/src/app/components/showroom/promotionsSwiper.tsx +174 -0
- package/src/app/components/showroom/showroomHeroCarousel.tsx +141 -0
- package/src/app/components/showroom/testimonialsGrid.tsx +106 -0
- package/src/app/components/skeletons/ContentSkeleton.tsx +14 -0
- package/src/app/components/sortDropdown/index.tsx +116 -0
- package/src/app/components/tertiaryButton/index.tsx +25 -0
- package/src/app/components/theme/theme-provider.tsx +82 -0
- package/src/app/contact/layout.tsx +32 -0
- package/src/app/contact/page.tsx +591 -0
- package/src/app/content/[slug]/layout.tsx +17 -0
- package/src/app/content/[slug]/page.tsx +159 -0
- package/src/app/content/layout.tsx +31 -0
- package/src/app/content/page.tsx +88 -0
- package/src/app/core-policies/page.tsx +55 -0
- package/src/app/discounts/page.tsx +54 -0
- package/src/app/frequently-asked-questions/page.tsx +57 -0
- package/src/app/globals.css +440 -0
- package/src/app/hooks/useDealerLocations.ts +259 -0
- package/src/app/hooks/useGTMEngagement.ts +71 -0
- package/src/app/hooks/useGoogleAnalytics.ts +145 -0
- package/src/app/layout.tsx +149 -0
- package/src/app/not-found.tsx +31 -0
- package/src/app/order-confirmation/layout.tsx +19 -0
- package/src/app/order-confirmation/page.tsx +12 -0
- package/src/app/order-confirmation/summary.tsx +1775 -0
- package/src/app/page.tsx +194 -0
- package/src/app/privacy-policy/loading.tsx +17 -0
- package/src/app/privacy-policy/page.tsx +56 -0
- package/src/app/product/[id]/ProductDetailClient.tsx +2448 -0
- package/src/app/product/[id]/components/itemInquiryModal.tsx +461 -0
- package/src/app/product/[id]/layout.tsx +116 -0
- package/src/app/product/[id]/page.tsx +200 -0
- package/src/app/product/layout.tsx +15 -0
- package/src/app/products/all/AllProductsClient.tsx +743 -0
- package/src/app/products/all/page.tsx +176 -0
- package/src/app/products/components/shopEmptyState.tsx +29 -0
- package/src/app/request-return/layout.tsx +36 -0
- package/src/app/request-return/page.tsx +597 -0
- package/src/app/robots.txt/route.ts +27 -0
- package/src/app/search/layout.tsx +16 -0
- package/src/app/search/page.tsx +736 -0
- package/src/app/shipping-returns/page.tsx +60 -0
- package/src/app/site-map/layout.tsx +33 -0
- package/src/app/site-map/page.tsx +113 -0
- package/src/app/sitemap-index.xml/route.ts +20 -0
- package/src/app/sitemap.ts +10 -0
- package/src/app/terms-and-conditions/loading.tsx +17 -0
- package/src/app/terms-and-conditions/page.tsx +56 -0
- package/src/app/utils/appConfiguration.ts +327 -0
- package/src/app/utils/branding.ts +52 -0
- package/src/app/utils/configurationService.ts +202 -0
- package/src/app/utils/constant.tsx +242 -0
- package/src/app/utils/editorJsUtils.tsx +249 -0
- package/src/app/utils/functions.ts +146 -0
- package/src/app/utils/googleAnalytics.ts +168 -0
- package/src/app/utils/googleTagManager.ts +475 -0
- package/src/app/utils/ipDetection.ts +270 -0
- package/src/app/utils/serverConfigurationService.ts +209 -0
- package/src/app/utils/svgs/GridIcon.tsx +45 -0
- package/src/app/utils/svgs/account/myAccount/listDotIcon.tsx +3 -0
- package/src/app/utils/svgs/account/myAccount/tickIcon.tsx +10 -0
- package/src/app/utils/svgs/account/orderHistory/InfoIcon.tsx +49 -0
- package/src/app/utils/svgs/arrowDownIcon.tsx +17 -0
- package/src/app/utils/svgs/arrowIcon.tsx +25 -0
- package/src/app/utils/svgs/arrowUpIcon.tsx +16 -0
- package/src/app/utils/svgs/brandsSearchIcon.tsx +25 -0
- package/src/app/utils/svgs/cart/cartIcon.tsx +31 -0
- package/src/app/utils/svgs/cart/plusIcon.tsx +13 -0
- package/src/app/utils/svgs/cart/subtractIcon.tsx +13 -0
- package/src/app/utils/svgs/cart/successTickIcon.tsx +14 -0
- package/src/app/utils/svgs/chevronDownIcon.tsx +21 -0
- package/src/app/utils/svgs/closeEyeIcon.tsx +47 -0
- package/src/app/utils/svgs/crossIcon.tsx +25 -0
- package/src/app/utils/svgs/eyeIcon.tsx +29 -0
- package/src/app/utils/svgs/featureTag.tsx +20 -0
- package/src/app/utils/svgs/filterIcon.tsx +3 -0
- package/src/app/utils/svgs/globleIcon.tsx +41 -0
- package/src/app/utils/svgs/infoIcon.tsx +34 -0
- package/src/app/utils/svgs/listIcon.tsx +50 -0
- package/src/app/utils/svgs/logOutIcon.tsx +35 -0
- package/src/app/utils/svgs/menuIcon.tsx +8 -0
- package/src/app/utils/svgs/minusIcon.tsx +18 -0
- package/src/app/utils/svgs/newsletterIcon.tsx +19 -0
- package/src/app/utils/svgs/noDataFoundIcon-.tsx +26 -0
- package/src/app/utils/svgs/noProductFoundIcon.tsx +43 -0
- package/src/app/utils/svgs/passwordIcons/errorIcon.tsx +31 -0
- package/src/app/utils/svgs/passwordIcons/successIcon.tsx +24 -0
- package/src/app/utils/svgs/paymentProcessingIcons/hourglassIcon.tsx +43 -0
- package/src/app/utils/svgs/paymentProcessingIcons/modalCrossIcon.tsx +23 -0
- package/src/app/utils/svgs/paymentProcessingIcons/paymentFailedIcon.tsx +47 -0
- package/src/app/utils/svgs/pencilIcon.tsx +11 -0
- package/src/app/utils/svgs/plusIcon.tsx +25 -0
- package/src/app/utils/svgs/productInquiryIcon.tsx +40 -0
- package/src/app/utils/svgs/searchIcon.tsx +31 -0
- package/src/app/utils/svgs/shoppingCart.tsx +32 -0
- package/src/app/utils/svgs/spinnerIcon.tsx +22 -0
- package/src/app/utils/svgs/spinnerLoadingIcon.tsx +26 -0
- package/src/app/utils/svgs/successTickIcon.tsx +40 -0
- package/src/app/utils/svgs/swiperArrowIconLeft.tsx +18 -0
- package/src/app/utils/svgs/swiperArrowIconRight.tsx +18 -0
- package/src/app/utils/svgs/userProfileIcon.tsx +31 -0
- package/src/app/utils/svgs/warningCircleIcon.tsx +15 -0
- package/src/app/warranty/constant.tsx +63 -0
- package/src/app/warranty/loading.tsx +17 -0
- package/src/app/warranty/page.tsx +56 -0
- package/src/graphql/client.ts +288 -0
- package/src/graphql/mutations/accountAddressCreate.ts +56 -0
- package/src/graphql/mutations/accountAddressDelete.ts +23 -0
- package/src/graphql/mutations/accountAddressUpdate.ts +55 -0
- package/src/graphql/mutations/accountSetDefaultAddress.ts +32 -0
- package/src/graphql/mutations/accountUpdate.ts +34 -0
- package/src/graphql/mutations/changePassword.ts +25 -0
- package/src/graphql/mutations/checkout.ts +117 -0
- package/src/graphql/mutations/checkoutAddVoucher.ts +63 -0
- package/src/graphql/mutations/checkoutComplete.ts +79 -0
- package/src/graphql/mutations/checkoutCreate.ts +131 -0
- package/src/graphql/mutations/checkoutCustomerAttach.ts +50 -0
- package/src/graphql/mutations/checkoutEmailUpdate.ts +15 -0
- package/src/graphql/mutations/checkoutLineMetadataUpdate.ts +52 -0
- package/src/graphql/mutations/checkoutPaymentCreate.ts +82 -0
- package/src/graphql/mutations/paymentGatewayInitialize.ts +58 -0
- package/src/graphql/mutations/registerAccount.ts +65 -0
- package/src/graphql/mutations/requestPasswordReset.ts +32 -0
- package/src/graphql/mutations/setPassword.ts +49 -0
- package/src/graphql/mutations/signIn.ts +50 -0
- package/src/graphql/mutations/tokenRefresh.ts +19 -0
- package/src/graphql/mutations/updateCheckoutMetadata.ts +49 -0
- package/src/graphql/mutations/updateProfile.ts +18 -0
- package/src/graphql/mutations/willCallDeliveryMethod.ts +81 -0
- package/src/graphql/queries/checkout.ts +168 -0
- package/src/graphql/queries/findProductByOldSlug.ts +58 -0
- package/src/graphql/queries/getAboutPage.ts +24 -0
- package/src/graphql/queries/getAboutPageId.ts +9 -0
- package/src/graphql/queries/getAboutUs.ts +38 -0
- package/src/graphql/queries/getAddressInformation.ts +38 -0
- package/src/graphql/queries/getAllCategories.ts +41 -0
- package/src/graphql/queries/getAllCategoriesTree.ts +67 -0
- package/src/graphql/queries/getAllCategoriesWithProducts.ts +29 -0
- package/src/graphql/queries/getAllCollectionsWithProducts.ts +16 -0
- package/src/graphql/queries/getBlogs.ts +222 -0
- package/src/graphql/queries/getBrands.ts +17 -0
- package/src/graphql/queries/getBundles.ts +43 -0
- package/src/graphql/queries/getCategories.ts +20 -0
- package/src/graphql/queries/getChannels.ts +77 -0
- package/src/graphql/queries/getCheckoutQuestions.ts +115 -0
- package/src/graphql/queries/getCheckoutTermsAndConditions.ts +37 -0
- package/src/graphql/queries/getContactPage.ts +117 -0
- package/src/graphql/queries/getContentPage.ts +191 -0
- package/src/graphql/queries/getDiscountOffers.ts +18 -0
- package/src/graphql/queries/getDynamicPageBySlug.ts +251 -0
- package/src/graphql/queries/getFeaturedProducts.ts +48 -0
- package/src/graphql/queries/getHeroMetadata.ts +23 -0
- package/src/graphql/queries/getMenuBySlug.ts +84 -0
- package/src/graphql/queries/getMyProfile.ts +23 -0
- package/src/graphql/queries/getNewsletter.ts +122 -0
- package/src/graphql/queries/getNewsletterPage.ts +111 -0
- package/src/graphql/queries/getPageBySlug.ts +52 -0
- package/src/graphql/queries/getPageTypeId.ts +27 -0
- package/src/graphql/queries/getPaymentMethods.ts +61 -0
- package/src/graphql/queries/getProducts.ts +78 -0
- package/src/graphql/queries/getPromotions.ts +24 -0
- package/src/graphql/queries/getRequestReturnPage.ts +121 -0
- package/src/graphql/queries/getSiteInfo.ts +54 -0
- package/src/graphql/queries/getSocialLinks.ts +52 -0
- package/src/graphql/queries/getTestimonials.ts +25 -0
- package/src/graphql/queries/getUserWithCheckout.ts +27 -0
- package/src/graphql/queries/getVehicleMakes.ts +21 -0
- package/src/graphql/queries/getVehicleModels.ts +21 -0
- package/src/graphql/queries/getVehicleYears.ts +21 -0
- package/src/graphql/queries/meAddresses.ts +56 -0
- package/src/graphql/queries/myOrders.ts +37 -0
- package/src/graphql/queries/orderDetail.ts +231 -0
- package/src/graphql/queries/productDetailsById.ts +197 -0
- package/src/graphql/queries/productInquiry.ts +115 -0
- package/src/graphql/queries/productsByCategoriesAndCollections.ts +39 -0
- package/src/graphql/queries/willCallCollectionPoints.ts +55 -0
- package/src/graphql/server-client.ts +54 -0
- package/src/graphql/types/categories.ts +9 -0
- package/src/graphql/types/checkout.ts +168 -0
- package/src/graphql/types/offer.ts +12 -0
- package/src/graphql/types/product.ts +44 -0
- package/src/hooks/scrollPageTop.ts +9 -0
- package/src/hooks/serverNavbarData.ts +79 -0
- package/src/hooks/useCartSync.ts +24 -0
- package/src/hooks/useRecaptcha.ts +33 -0
- package/src/hooks/useTokenExpiration.ts +81 -0
- package/src/hooks/useVehicleData.ts +346 -0
- package/src/lib/api/kount.ts +165 -0
- package/src/lib/api/shop.ts +1445 -0
- package/src/lib/saleor/getSaleorApiUrl.ts +25 -0
- package/src/lib/schema.ts +303 -0
- package/src/lib/seo/extractTextFromEditorJs.ts +58 -0
- package/src/lib/seo/site.ts +10 -0
- package/src/lib/urls/normalizeInternalUrl.ts +53 -0
- package/src/middleware.ts +134 -0
- package/src/sitemaps/README.md +105 -0
- package/src/sitemaps/dynamic-pages-sitemap.ts +247 -0
- package/src/sitemaps/sitemap-index.ts +21 -0
- package/src/sitemaps/static-pages-sitemap.ts +36 -0
- package/src/store/useGlobalStore.tsx +1656 -0
- package/src/types/global.d.ts +148 -0
- 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're looking for doesn'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
|
+
}
|