@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,3312 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import AddressManagement from "@/app/components/checkout/AddressManagement";
|
|
3
|
+
import AddressInformationSection from "@/app/components/checkout/AddressInformationSection";
|
|
4
|
+
import CheckoutHeader from "@/app/components/checkout/CheckoutHeader";
|
|
5
|
+
import ContactDetailsSection from "@/app/components/checkout/ContactDetailsSection";
|
|
6
|
+
import DeliveryMethodSection from "@/app/components/checkout/DeliveryMethodSection";
|
|
7
|
+
import DealerShippingSection from "@/app/components/checkout/DealerShippingSection";
|
|
8
|
+
import WillCallSection from "@/app/components/checkout/WillCallSection";
|
|
9
|
+
import OrderSummary from "@/app/components/checkout/OrderSummary";
|
|
10
|
+
import PaymentStep from "@/app/components/checkout/paymentStep";
|
|
11
|
+
import CheckoutQuestions from "@/app/components/checkout/CheckoutQuestions";
|
|
12
|
+
import CheckoutTermsModal from "@/app/components/checkout/CheckoutTermsModal";
|
|
13
|
+
import {
|
|
14
|
+
GET_CHECKOUT_TERMS_AND_CONDITIONS,
|
|
15
|
+
type CheckoutTermsAndConditionsResponse,
|
|
16
|
+
} from "@/graphql/queries/getCheckoutTermsAndConditions";
|
|
17
|
+
import {
|
|
18
|
+
ACCOUNT_SET_DEFAULT_ADDRESS,
|
|
19
|
+
type AccountSetDefaultAddressData,
|
|
20
|
+
type AccountSetDefaultAddressVars,
|
|
21
|
+
} from "@/graphql/mutations/accountSetDefaultAddress";
|
|
22
|
+
import {
|
|
23
|
+
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
|
24
|
+
CHECKOUT_DELIVERY_METHOD_UPDATE,
|
|
25
|
+
} from "@/graphql/mutations/checkout";
|
|
26
|
+
import {
|
|
27
|
+
ADD_VOUCHER_TO_CHECKOUT,
|
|
28
|
+
REMOVE_VOUCHER_FROM_CHECKOUT,
|
|
29
|
+
} from "@/graphql/mutations/checkoutAddVoucher";
|
|
30
|
+
import { CHECKOUT_EMAIL_UPDATE } from "@/graphql/mutations/checkoutEmailUpdate";
|
|
31
|
+
import {
|
|
32
|
+
GET_CHECKOUT_DETAILS,
|
|
33
|
+
GET_CHECKOUT_SHIPPING_METHODS,
|
|
34
|
+
GET_PAYMENT_GATEWAYS,
|
|
35
|
+
} from "@/graphql/queries/checkout";
|
|
36
|
+
import {
|
|
37
|
+
GET_CHECKOUT_COLLECTION_POINTS,
|
|
38
|
+
type GetCheckoutCollectionPointsData,
|
|
39
|
+
type GetCheckoutCollectionPointsVars,
|
|
40
|
+
type CollectionPoint,
|
|
41
|
+
} from "@/graphql/queries/willCallCollectionPoints";
|
|
42
|
+
import {
|
|
43
|
+
CHECKOUT_DELIVERY_METHOD_UPDATE_WILL_CALL,
|
|
44
|
+
type WillCallDeliveryMethodUpdateData,
|
|
45
|
+
type WillCallDeliveryMethodUpdateVars,
|
|
46
|
+
} from "@/graphql/mutations/willCallDeliveryMethod";
|
|
47
|
+
import {
|
|
48
|
+
ME_ADDRESSES_QUERY,
|
|
49
|
+
type MeAddressesData,
|
|
50
|
+
} from "@/graphql/queries/meAddresses";
|
|
51
|
+
import {
|
|
52
|
+
AddressForm,
|
|
53
|
+
type PaymentProcessingState,
|
|
54
|
+
type KountConfigResponse,
|
|
55
|
+
} from "@/graphql/types/checkout";
|
|
56
|
+
import { useGlobalStore } from "@/store/useGlobalStore";
|
|
57
|
+
import { getSaleorApiUrl } from "@/lib/saleor/getSaleorApiUrl";
|
|
58
|
+
import { useMutation, useQuery, useApolloClient, gql } from "@apollo/client";
|
|
59
|
+
import { useRouter } from "next/navigation";
|
|
60
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
61
|
+
import EmptyState from "../components/reuseableUI/emptyState";
|
|
62
|
+
import LoadingUI from "../components/reuseableUI/loadingUI";
|
|
63
|
+
import { gtmAddShippingInfo, Product } from "../utils/googleTagManager";
|
|
64
|
+
import { useAppConfiguration } from "../components/providers/ServerAppConfigurationProvider";
|
|
65
|
+
import { kountApi } from "@/lib/api/kount";
|
|
66
|
+
|
|
67
|
+
/* ----------------- small helpers ----------------- */
|
|
68
|
+
const shallowEq = (
|
|
69
|
+
a: Record<string, unknown> | null,
|
|
70
|
+
b: Record<string, unknown> | null
|
|
71
|
+
) => {
|
|
72
|
+
if (a === b) return true;
|
|
73
|
+
if (!a || !b) return false;
|
|
74
|
+
const ka = Object.keys(a),
|
|
75
|
+
kb = Object.keys(b);
|
|
76
|
+
if (ka.length !== kb.length) return false;
|
|
77
|
+
for (const k of ka) if (a[k] !== b[k]) return false;
|
|
78
|
+
return true;
|
|
79
|
+
};
|
|
80
|
+
const isMethodAvailable = (id: string | null, list: ShippingMethod[]) =>
|
|
81
|
+
!!(id && list.some((m) => m.id === id));
|
|
82
|
+
|
|
83
|
+
// Product restriction validation helpers
|
|
84
|
+
interface MetadataItem {
|
|
85
|
+
key: string;
|
|
86
|
+
value: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ProductData {
|
|
90
|
+
name: string;
|
|
91
|
+
metadata: MetadataItem[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface VariantData {
|
|
95
|
+
product: ProductData;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface CheckoutLineData {
|
|
99
|
+
variant: VariantData;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface CheckoutData {
|
|
103
|
+
lines: CheckoutLineData[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const checkProductRestrictions = (
|
|
107
|
+
checkoutData: CheckoutData,
|
|
108
|
+
userState: string,
|
|
109
|
+
userZipCode: string
|
|
110
|
+
): Array<{ productName: string; checkoutMessage: string }> => {
|
|
111
|
+
const restrictions: Array<{ productName: string; checkoutMessage: string }> =
|
|
112
|
+
[];
|
|
113
|
+
|
|
114
|
+
if (!checkoutData?.lines) return restrictions;
|
|
115
|
+
|
|
116
|
+
checkoutData.lines.forEach((line: CheckoutLineData) => {
|
|
117
|
+
const product = line.variant?.product;
|
|
118
|
+
if (!product?.metadata) return;
|
|
119
|
+
|
|
120
|
+
const metadata = product.metadata;
|
|
121
|
+
let restrictedStates: string[] = [];
|
|
122
|
+
let restrictedZipCodes: (string | number)[] = [];
|
|
123
|
+
let checkoutMessage = "";
|
|
124
|
+
let shippingIsActive = false;
|
|
125
|
+
|
|
126
|
+
// Parse metadata
|
|
127
|
+
metadata.forEach((meta: MetadataItem) => {
|
|
128
|
+
if (meta.key === "restricted_states" && meta.value) {
|
|
129
|
+
try {
|
|
130
|
+
restrictedStates = JSON.parse(meta.value);
|
|
131
|
+
} catch {
|
|
132
|
+
console.warn("Failed to parse restricted_states:", meta.value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (meta.key === "restricted_zip_codes" && meta.value) {
|
|
136
|
+
try {
|
|
137
|
+
restrictedZipCodes = JSON.parse(meta.value);
|
|
138
|
+
} catch {
|
|
139
|
+
console.warn("Failed to parse restricted_zip_codes:", meta.value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (meta.key === "checkout_message" && meta.value) {
|
|
143
|
+
checkoutMessage = meta.value;
|
|
144
|
+
}
|
|
145
|
+
if (meta.key === "shipping_isactive" && meta.value) {
|
|
146
|
+
shippingIsActive = meta.value.toLowerCase() === "true";
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Only apply restrictions if shipping_isactive is true
|
|
151
|
+
if (!shippingIsActive) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check if user's state or zip code matches restrictions
|
|
156
|
+
const stateMatches =
|
|
157
|
+
userState && restrictedStates.includes(userState.toUpperCase());
|
|
158
|
+
const zipMatches =
|
|
159
|
+
userZipCode &&
|
|
160
|
+
restrictedZipCodes.some(
|
|
161
|
+
(zip) =>
|
|
162
|
+
String(zip) === userZipCode ||
|
|
163
|
+
String(zip) === userZipCode.split("-")[0]
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if ((stateMatches || zipMatches) && checkoutMessage) {
|
|
167
|
+
restrictions.push({
|
|
168
|
+
productName: product.name,
|
|
169
|
+
checkoutMessage,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return restrictions;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** NEW: timing + retry helpers */
|
|
178
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
179
|
+
async function withRetry<T>(
|
|
180
|
+
fn: () => Promise<T>,
|
|
181
|
+
attempts = 4,
|
|
182
|
+
baseDelayMs = 250
|
|
183
|
+
): Promise<T> {
|
|
184
|
+
let lastErr: unknown;
|
|
185
|
+
for (let i = 0; i < attempts; i++) {
|
|
186
|
+
try {
|
|
187
|
+
return await fn();
|
|
188
|
+
} catch (e) {
|
|
189
|
+
lastErr = e;
|
|
190
|
+
// exp backoff + small jitter
|
|
191
|
+
const delay =
|
|
192
|
+
baseDelayMs * Math.pow(2, i) + Math.floor(Math.random() * 50);
|
|
193
|
+
await sleep(delay);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
throw lastErr instanceof Error
|
|
197
|
+
? lastErr
|
|
198
|
+
: new Error("Operation failed after retries");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ----------------- types ----------------- */
|
|
202
|
+
|
|
203
|
+
interface DeliveryMethodError extends Error {
|
|
204
|
+
isDeliveryMethodError: boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function createDeliveryMethodError(message: string): DeliveryMethodError {
|
|
208
|
+
const error = new Error(message) as DeliveryMethodError;
|
|
209
|
+
error.isDeliveryMethodError = true;
|
|
210
|
+
return error;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
type AddressInputTS = {
|
|
214
|
+
firstName: string;
|
|
215
|
+
lastName: string;
|
|
216
|
+
streetAddress1: string;
|
|
217
|
+
city: string;
|
|
218
|
+
postalCode: string;
|
|
219
|
+
country: string;
|
|
220
|
+
countryArea?: string;
|
|
221
|
+
phone?: string | null;
|
|
222
|
+
};
|
|
223
|
+
type AccountAddressLite = {
|
|
224
|
+
id: string;
|
|
225
|
+
firstName?: string | null;
|
|
226
|
+
lastName?: string | null;
|
|
227
|
+
streetAddress1?: string | null;
|
|
228
|
+
city?: string | null;
|
|
229
|
+
postalCode?: string | null;
|
|
230
|
+
country?: { code?: string | null; country?: string | null } | null;
|
|
231
|
+
countryArea?: string | null;
|
|
232
|
+
phone?: string | null;
|
|
233
|
+
companyName?: string | null;
|
|
234
|
+
};
|
|
235
|
+
type ShippingMethod = {
|
|
236
|
+
id: string;
|
|
237
|
+
name: string;
|
|
238
|
+
price: { amount: number; currency: string };
|
|
239
|
+
minimumDeliveryDays?: number | null;
|
|
240
|
+
maximumDeliveryDays?: number | null;
|
|
241
|
+
};
|
|
242
|
+
interface GraphQLShippingMethod {
|
|
243
|
+
id: string;
|
|
244
|
+
name: string;
|
|
245
|
+
price: { amount: number; currency: string };
|
|
246
|
+
minimumDeliveryDays?: number | null;
|
|
247
|
+
maximumDeliveryDays?: number | null;
|
|
248
|
+
}
|
|
249
|
+
interface GraphQLShippingMethodsResponse {
|
|
250
|
+
data?: { checkout?: { availableShippingMethods?: GraphQLShippingMethod[] } };
|
|
251
|
+
errors?: Array<{ message: string }>;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ----------------- Checkout Page ----------------- */
|
|
255
|
+
const DEBUG_HALT_AFTER_PAYMENT = false;
|
|
256
|
+
|
|
257
|
+
export default function CheckoutPage() {
|
|
258
|
+
const {
|
|
259
|
+
cartItems: items,
|
|
260
|
+
totalAmount,
|
|
261
|
+
isLoggedIn,
|
|
262
|
+
guestEmail,
|
|
263
|
+
guestShippingInfo,
|
|
264
|
+
checkoutId,
|
|
265
|
+
setCheckoutId,
|
|
266
|
+
checkoutToken,
|
|
267
|
+
setCheckoutToken,
|
|
268
|
+
selectedShippingMethodId: globalSelectedShippingId,
|
|
269
|
+
setSelectedShippingMethodId,
|
|
270
|
+
} = useGlobalStore();
|
|
271
|
+
const user = useGlobalStore((state) => state.user);
|
|
272
|
+
const { getGoogleTagManagerConfig, isWillCallEnabled } =
|
|
273
|
+
useAppConfiguration();
|
|
274
|
+
const gtmConfig = getGoogleTagManagerConfig();
|
|
275
|
+
const route = useRouter();
|
|
276
|
+
const apolloClient = useApolloClient();
|
|
277
|
+
const [isClient, setIsClient] = useState(false);
|
|
278
|
+
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
|
279
|
+
const [shippingLoading, setShippingLoading] = useState(false);
|
|
280
|
+
const [shippingError, setShippingError] = useState<string | null>(null);
|
|
281
|
+
const [emailError, setEmailError] = useState<string | null>(null);
|
|
282
|
+
const [selectedShippingId, setSelectedShippingId] = useState<string | null>(
|
|
283
|
+
null
|
|
284
|
+
);
|
|
285
|
+
const [userHasSelectedDelivery, setUserHasSelectedDelivery] =
|
|
286
|
+
useState<boolean>(false);
|
|
287
|
+
const [isProcessingSelection, setIsProcessingSelection] = useState(false);
|
|
288
|
+
const [useShippingAsBilling, setUseShippingAsBilling] =
|
|
289
|
+
useState<boolean>(true);
|
|
290
|
+
const [isProcessingPayment, setIsProcessingPayment] =
|
|
291
|
+
useState<PaymentProcessingState>({
|
|
292
|
+
isModalOpen: false,
|
|
293
|
+
paymentProcessingLoading: false,
|
|
294
|
+
error: false,
|
|
295
|
+
success: false,
|
|
296
|
+
});
|
|
297
|
+
const [saleorTotal, setSaleorTotal] = useState<number | null>(null);
|
|
298
|
+
const [taxInfo, setTaxInfo] = useState<{
|
|
299
|
+
totalTax: number;
|
|
300
|
+
shippingTax: number;
|
|
301
|
+
subtotalNet: number;
|
|
302
|
+
shippingNet: number;
|
|
303
|
+
currency: string;
|
|
304
|
+
} | null>(null);
|
|
305
|
+
|
|
306
|
+
// NEW: fine-grained UX flags + race control
|
|
307
|
+
const [isUpdatingDelivery, setIsUpdatingDelivery] = useState(false);
|
|
308
|
+
const [isCalculatingTotal, setIsCalculatingTotal] = useState(false);
|
|
309
|
+
const [isRecoveringDelivery, setIsRecoveringDelivery] = useState(false);
|
|
310
|
+
const [isCalculatingTax, setIsCalculatingTax] = useState(false);
|
|
311
|
+
const [paymentTriggerFn, setPaymentTriggerFn] = useState<{
|
|
312
|
+
fn: (() => Promise<void>) | null;
|
|
313
|
+
}>({ fn: null });
|
|
314
|
+
|
|
315
|
+
// Dealer shipping state
|
|
316
|
+
const [isShipToDealer, setIsShipToDealer] = useState(false);
|
|
317
|
+
const [selectedDealer, setSelectedDealer] = useState<{
|
|
318
|
+
id: string;
|
|
319
|
+
name: string;
|
|
320
|
+
address: {
|
|
321
|
+
streetAddress1?: string;
|
|
322
|
+
city?: string;
|
|
323
|
+
postalCode?: string;
|
|
324
|
+
countryArea?: string;
|
|
325
|
+
country?: { country?: string; code?: string };
|
|
326
|
+
};
|
|
327
|
+
phone?: string;
|
|
328
|
+
distance?: string;
|
|
329
|
+
hours?: string;
|
|
330
|
+
comments?: string;
|
|
331
|
+
state?: string;
|
|
332
|
+
} | null>(null);
|
|
333
|
+
|
|
334
|
+
// Checkout questions state
|
|
335
|
+
const [, setCheckoutQuestionAnswers] = useState<Record<string, string>>({});
|
|
336
|
+
const [areCheckoutQuestionsValid, setAreCheckoutQuestionsValid] =
|
|
337
|
+
useState(true);
|
|
338
|
+
const [saveCheckoutQuestions, setSaveCheckoutQuestions] = useState<
|
|
339
|
+
(() => Promise<void>) | null
|
|
340
|
+
>(null);
|
|
341
|
+
|
|
342
|
+
// Terms and conditions state
|
|
343
|
+
const [termsAccepted, setTermsAccepted] = useState(false);
|
|
344
|
+
const [isTermsModalOpen, setIsTermsModalOpen] = useState(false);
|
|
345
|
+
|
|
346
|
+
// Product restriction state
|
|
347
|
+
const [productRestrictions, setProductRestrictions] = useState<
|
|
348
|
+
Array<{
|
|
349
|
+
productName: string;
|
|
350
|
+
checkoutMessage: string;
|
|
351
|
+
}>
|
|
352
|
+
>([]);
|
|
353
|
+
const [hasRestrictionViolations, setHasRestrictionViolations] =
|
|
354
|
+
useState(false);
|
|
355
|
+
|
|
356
|
+
// Voucher state
|
|
357
|
+
const [voucherInfo, setVoucherInfo] = useState<{
|
|
358
|
+
voucherCode: string | null;
|
|
359
|
+
discount: { amount: number; currency: string } | null;
|
|
360
|
+
} | null>(null);
|
|
361
|
+
const [isApplyingVoucher, setIsApplyingVoucher] = useState(false);
|
|
362
|
+
const [voucherError, setVoucherError] = useState<string | null>(null);
|
|
363
|
+
|
|
364
|
+
// Kount fraud detection state
|
|
365
|
+
const [kountConfig, setKountConfig] = useState<KountConfigResponse | null>(
|
|
366
|
+
null
|
|
367
|
+
);
|
|
368
|
+
const [_isKountConfigLoading, setIsKountConfigLoading] = useState(false);
|
|
369
|
+
const [_kountConfigError, setKountConfigError] = useState<string | null>(
|
|
370
|
+
null
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
// Will Call state
|
|
374
|
+
const [collectionPoints, setCollectionPoints] = useState<CollectionPoint[]>(
|
|
375
|
+
[]
|
|
376
|
+
);
|
|
377
|
+
const [selectedCollectionPointId, setSelectedCollectionPointId] = useState<
|
|
378
|
+
string | null
|
|
379
|
+
>(null);
|
|
380
|
+
const [isWillCallSelected, setIsWillCallSelected] = useState(false);
|
|
381
|
+
const [willCallLoading, setWillCallLoading] = useState(false);
|
|
382
|
+
const [willCallError, setWillCallError] = useState<string | null>(null);
|
|
383
|
+
|
|
384
|
+
// Query for terms and conditions page
|
|
385
|
+
const { data: termsData } = useQuery<CheckoutTermsAndConditionsResponse>(
|
|
386
|
+
GET_CHECKOUT_TERMS_AND_CONDITIONS,
|
|
387
|
+
{
|
|
388
|
+
variables: { slug: "checkout-terms-and-conditions" },
|
|
389
|
+
fetchPolicy: "cache-first",
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Request deduplication refs
|
|
394
|
+
const fetchingMethodsRef = useRef(false);
|
|
395
|
+
const updatingDeliveryRef = useRef(false);
|
|
396
|
+
const totalsAbortRef = useRef<AbortController | null>(null);
|
|
397
|
+
const lastAddressHashRef = useRef<string>("");
|
|
398
|
+
const lastFetchedAtRef = useRef<number>(0); // NEW: throttle repeated fetches
|
|
399
|
+
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null); // NEW: debounce validation
|
|
400
|
+
const addressHashAttemptedRef = useRef<Set<string>>(new Set()); // Track addresses we've attempted
|
|
401
|
+
|
|
402
|
+
const [shippingInfo, setShippingInfo] = useState({
|
|
403
|
+
firstName: "",
|
|
404
|
+
lastName: "",
|
|
405
|
+
address: "",
|
|
406
|
+
city: "",
|
|
407
|
+
state: "",
|
|
408
|
+
zipCode: "",
|
|
409
|
+
email: "",
|
|
410
|
+
phone: "",
|
|
411
|
+
country: "US",
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// Email validation function
|
|
415
|
+
const validateEmail = useCallback((email: string) => {
|
|
416
|
+
if (!email || email.trim() === "") {
|
|
417
|
+
return "Email address is required.";
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
421
|
+
if (!emailRegex.test(email)) {
|
|
422
|
+
return "Please enter a valid email address.";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return null; // No error
|
|
426
|
+
}, []);
|
|
427
|
+
|
|
428
|
+
// Helper function to handle GraphQL errors and checkout session issues
|
|
429
|
+
const handleGraphQLError = useCallback(
|
|
430
|
+
(error: Error | unknown, operation: string = "operation") => {
|
|
431
|
+
const errorMessage =
|
|
432
|
+
error instanceof Error ? error.message : String(error);
|
|
433
|
+
|
|
434
|
+
// Check for "Couldn't resolve to a node" error which indicates expired/invalid checkout session
|
|
435
|
+
if (errorMessage.includes("Couldn't resolve to a node")) {
|
|
436
|
+
const userFriendlyMessage = `Your checkout session has expired. Please refresh the page to start a new checkout session.`;
|
|
437
|
+
console.error(`${operation} failed: Checkout session expired`, error);
|
|
438
|
+
return new Error(userFriendlyMessage);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Check for other common session/auth errors
|
|
442
|
+
if (
|
|
443
|
+
errorMessage.includes("session") ||
|
|
444
|
+
errorMessage.includes("expired") ||
|
|
445
|
+
errorMessage.includes("401") ||
|
|
446
|
+
errorMessage.includes("403")
|
|
447
|
+
) {
|
|
448
|
+
const userFriendlyMessage = `Your session has expired. Please refresh the page and try again.`;
|
|
449
|
+
console.error(`${operation} failed: Session expired`, error);
|
|
450
|
+
return new Error(userFriendlyMessage);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Check for network errors
|
|
454
|
+
if (
|
|
455
|
+
errorMessage.includes("network") ||
|
|
456
|
+
errorMessage.includes("Failed to fetch") ||
|
|
457
|
+
errorMessage.includes("NetworkError")
|
|
458
|
+
) {
|
|
459
|
+
const userFriendlyMessage = `Network connection issue. Please check your connection and try again.`;
|
|
460
|
+
console.error(`${operation} failed: Network error`, error);
|
|
461
|
+
return new Error(userFriendlyMessage);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Default error handling
|
|
465
|
+
console.error(`${operation} failed:`, error);
|
|
466
|
+
return new Error(
|
|
467
|
+
errorMessage || `${operation} failed. Please try again.`
|
|
468
|
+
);
|
|
469
|
+
},
|
|
470
|
+
[]
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// NEW: Add postal code validation
|
|
474
|
+
const isValidPostalCode = useCallback(
|
|
475
|
+
(postalCode: string, country: string) => {
|
|
476
|
+
if (!postalCode || !country) return false;
|
|
477
|
+
|
|
478
|
+
const trimmedCode = postalCode.trim();
|
|
479
|
+
if (trimmedCode.length === 0) return false;
|
|
480
|
+
|
|
481
|
+
// Common postal code patterns
|
|
482
|
+
const patterns = {
|
|
483
|
+
US: /^\d{5}(-\d{4})?$/, // 12345 or 12345-6789
|
|
484
|
+
CA: /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/, // A1A 1A1 or A1A1A1
|
|
485
|
+
UK: /^[A-Za-z]{1,2}\d[A-Za-z\d]?\s?\d[A-Za-z]{2}$/, // Various UK formats
|
|
486
|
+
AU: /^\d{4}$/, // 1234
|
|
487
|
+
DE: /^\d{5}$/, // 12345
|
|
488
|
+
FR: /^\d{5}$/, // 12345
|
|
489
|
+
JP: /^\d{3}-?\d{4}$/, // 123-4567 or 1234567
|
|
490
|
+
PK: /^\d{5}$/, // 12345
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const pattern = patterns[country as keyof typeof patterns];
|
|
494
|
+
if (pattern) {
|
|
495
|
+
return pattern.test(trimmedCode);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Generic fallback: allow alphanumeric postal codes 3-10 characters
|
|
499
|
+
return /^[A-Za-z0-9\s-]{3,10}$/.test(trimmedCode);
|
|
500
|
+
},
|
|
501
|
+
[]
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const missingForDelivery = useMemo(() => {
|
|
505
|
+
const miss: string[] = [];
|
|
506
|
+
if (!shippingInfo.firstName) miss.push("First name");
|
|
507
|
+
if (!shippingInfo.lastName) miss.push("Last name");
|
|
508
|
+
if (!shippingInfo.address) miss.push("Street address");
|
|
509
|
+
if (!shippingInfo.city) miss.push("City");
|
|
510
|
+
if (!shippingInfo.zipCode) miss.push("Postal code");
|
|
511
|
+
else if (!isValidPostalCode(shippingInfo.zipCode, shippingInfo.country)) {
|
|
512
|
+
miss.push("Valid postal code");
|
|
513
|
+
}
|
|
514
|
+
if (!shippingInfo.country) miss.push("Country");
|
|
515
|
+
return miss;
|
|
516
|
+
}, [shippingInfo, isValidPostalCode]);
|
|
517
|
+
|
|
518
|
+
const [billingInfo, setBillingInfo] = useState({
|
|
519
|
+
firstName: "",
|
|
520
|
+
lastName: "",
|
|
521
|
+
address: "",
|
|
522
|
+
city: "",
|
|
523
|
+
state: "",
|
|
524
|
+
zipCode: "",
|
|
525
|
+
country: "US",
|
|
526
|
+
phone: "",
|
|
527
|
+
email: "",
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const [formData, setFormData] = useState<AddressForm>({
|
|
531
|
+
firstName: "",
|
|
532
|
+
lastName: "",
|
|
533
|
+
phone: "",
|
|
534
|
+
companyName: "",
|
|
535
|
+
streetAddress1: "",
|
|
536
|
+
streetAddress2: "",
|
|
537
|
+
city: "",
|
|
538
|
+
postalCode: "",
|
|
539
|
+
country: "US",
|
|
540
|
+
countryArea: "",
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
useEffect(() => setIsClient(true), []);
|
|
544
|
+
|
|
545
|
+
// Fetch Kount configuration on component mount
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
if (!isClient) return;
|
|
548
|
+
|
|
549
|
+
const fetchKountConfig = async () => {
|
|
550
|
+
setIsKountConfigLoading(true);
|
|
551
|
+
setKountConfigError(null);
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
const config = await kountApi.getKountConfig();
|
|
555
|
+
setKountConfig(config);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
// Silently handle Kount config errors as it's optional in some environments
|
|
558
|
+
const errorMessage =
|
|
559
|
+
error instanceof Error
|
|
560
|
+
? error.message
|
|
561
|
+
: "Failed to fetch fraud detection configuration";
|
|
562
|
+
setKountConfigError(errorMessage);
|
|
563
|
+
// Only log if it's not a 404 (which is expected when Kount is not configured)
|
|
564
|
+
if (!errorMessage.includes("not configured")) {
|
|
565
|
+
console.error("Failed to fetch Kount configuration:", error);
|
|
566
|
+
}
|
|
567
|
+
} finally {
|
|
568
|
+
setIsKountConfigLoading(false);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
fetchKountConfig();
|
|
573
|
+
}, [isClient]);
|
|
574
|
+
|
|
575
|
+
const resetCheckoutState = useCallback(() => {
|
|
576
|
+
setShippingMethods([]);
|
|
577
|
+
setShippingLoading(false);
|
|
578
|
+
setShippingError(null);
|
|
579
|
+
setSelectedShippingId(null);
|
|
580
|
+
setUserHasSelectedDelivery(false);
|
|
581
|
+
setIsUpdatingDelivery(false);
|
|
582
|
+
setIsCalculatingTotal(false);
|
|
583
|
+
setIsRecoveringDelivery(false);
|
|
584
|
+
setIsCalculatingTax(false);
|
|
585
|
+
setSaleorTotal(null);
|
|
586
|
+
setTaxInfo(null); // Reset tax information
|
|
587
|
+
|
|
588
|
+
fetchingMethodsRef.current = false;
|
|
589
|
+
updatingDeliveryRef.current = false;
|
|
590
|
+
lastAddressHashRef.current = "";
|
|
591
|
+
lastDeliveryRef.current = null;
|
|
592
|
+
lastShippingRef.current = null;
|
|
593
|
+
lastBillingRef.current = null;
|
|
594
|
+
lastFetchedAtRef.current = 0; // Reset fetch timing
|
|
595
|
+
addressHashAttemptedRef.current.clear(); // Clear attempted addresses
|
|
596
|
+
|
|
597
|
+
setSelectedShippingMethodId(null);
|
|
598
|
+
|
|
599
|
+
if (totalsAbortRef.current) {
|
|
600
|
+
totalsAbortRef.current.abort();
|
|
601
|
+
totalsAbortRef.current = null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Clear any pending validation timeouts
|
|
605
|
+
if (validationTimeoutRef.current) {
|
|
606
|
+
clearTimeout(validationTimeoutRef.current);
|
|
607
|
+
validationTimeoutRef.current = null;
|
|
608
|
+
}
|
|
609
|
+
}, [setSelectedShippingMethodId]);
|
|
610
|
+
|
|
611
|
+
useEffect(() => {
|
|
612
|
+
if (!isClient) return;
|
|
613
|
+
try {
|
|
614
|
+
const url = new URL(window.location.href);
|
|
615
|
+
const idFromUrl = url.searchParams.get("checkoutId");
|
|
616
|
+
const idFromStorage = localStorage.getItem("checkoutId");
|
|
617
|
+
const tokenFromStorage = localStorage.getItem("checkoutToken");
|
|
618
|
+
const effective = idFromUrl || idFromStorage || null;
|
|
619
|
+
|
|
620
|
+
// If checkout ID is changing, clear shipping method selection
|
|
621
|
+
if (effective && effective !== checkoutId) {
|
|
622
|
+
setCheckoutId(effective);
|
|
623
|
+
setSelectedShippingMethodId(null);
|
|
624
|
+
|
|
625
|
+
// begin_checkout event is now tracked on cart page when user clicks "Proceed to Checkout"
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (tokenFromStorage && tokenFromStorage !== checkoutToken)
|
|
629
|
+
setCheckoutToken?.(tokenFromStorage);
|
|
630
|
+
if (idFromUrl && idFromUrl !== idFromStorage)
|
|
631
|
+
localStorage.setItem("checkoutId", idFromUrl);
|
|
632
|
+
} catch {}
|
|
633
|
+
}, [
|
|
634
|
+
isClient,
|
|
635
|
+
checkoutId,
|
|
636
|
+
checkoutToken,
|
|
637
|
+
setCheckoutId,
|
|
638
|
+
setCheckoutToken,
|
|
639
|
+
setSelectedShippingMethodId,
|
|
640
|
+
]);
|
|
641
|
+
|
|
642
|
+
const endpoint = getSaleorApiUrl();
|
|
643
|
+
|
|
644
|
+
const updateShippingAddress = useCallback(
|
|
645
|
+
async (id: string, addr: AddressInputTS) => {
|
|
646
|
+
const variables = { checkoutId: id, shippingAddress: addr };
|
|
647
|
+
|
|
648
|
+
// Enhanced query to include tax information
|
|
649
|
+
const enhancedQuery = `
|
|
650
|
+
mutation CheckoutShippingAddressUpdate($checkoutId: ID!, $shippingAddress: AddressInput!) {
|
|
651
|
+
checkoutShippingAddressUpdate(checkoutId: $checkoutId, shippingAddress: $shippingAddress) {
|
|
652
|
+
checkout {
|
|
653
|
+
id
|
|
654
|
+
shippingAddress {
|
|
655
|
+
streetAddress1
|
|
656
|
+
city
|
|
657
|
+
postalCode
|
|
658
|
+
country {
|
|
659
|
+
code
|
|
660
|
+
}
|
|
661
|
+
countryArea
|
|
662
|
+
}
|
|
663
|
+
totalPrice {
|
|
664
|
+
gross {
|
|
665
|
+
amount
|
|
666
|
+
currency
|
|
667
|
+
}
|
|
668
|
+
net {
|
|
669
|
+
amount
|
|
670
|
+
currency
|
|
671
|
+
}
|
|
672
|
+
tax {
|
|
673
|
+
amount
|
|
674
|
+
currency
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
shippingPrice {
|
|
678
|
+
gross {
|
|
679
|
+
amount
|
|
680
|
+
currency
|
|
681
|
+
}
|
|
682
|
+
net {
|
|
683
|
+
amount
|
|
684
|
+
currency
|
|
685
|
+
}
|
|
686
|
+
tax {
|
|
687
|
+
amount
|
|
688
|
+
currency
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
subtotalPrice {
|
|
692
|
+
gross {
|
|
693
|
+
amount
|
|
694
|
+
currency
|
|
695
|
+
}
|
|
696
|
+
net {
|
|
697
|
+
amount
|
|
698
|
+
currency
|
|
699
|
+
}
|
|
700
|
+
tax {
|
|
701
|
+
amount
|
|
702
|
+
currency
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
lines {
|
|
706
|
+
quantity
|
|
707
|
+
totalPrice {
|
|
708
|
+
gross {
|
|
709
|
+
amount
|
|
710
|
+
currency
|
|
711
|
+
}
|
|
712
|
+
net {
|
|
713
|
+
amount
|
|
714
|
+
currency
|
|
715
|
+
}
|
|
716
|
+
tax {
|
|
717
|
+
amount
|
|
718
|
+
currency
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
variant {
|
|
722
|
+
id
|
|
723
|
+
name
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
errors {
|
|
728
|
+
field
|
|
729
|
+
message
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
`;
|
|
734
|
+
|
|
735
|
+
const res = await fetch(endpoint, {
|
|
736
|
+
method: "POST",
|
|
737
|
+
headers: { "Content-Type": "application/json" },
|
|
738
|
+
body: JSON.stringify({ query: enhancedQuery, variables }),
|
|
739
|
+
});
|
|
740
|
+
if (!res.ok) throw new Error("Failed to update shipping address");
|
|
741
|
+
const json = await res.json();
|
|
742
|
+
const errs = json.data?.checkoutShippingAddressUpdate?.errors as
|
|
743
|
+
| Array<{ message?: string }>
|
|
744
|
+
| undefined;
|
|
745
|
+
if (errs?.length)
|
|
746
|
+
throw new Error(errs[0]?.message || "Shipping address update error");
|
|
747
|
+
|
|
748
|
+
// Extract and store tax information
|
|
749
|
+
const checkout = json.data?.checkoutShippingAddressUpdate?.checkout;
|
|
750
|
+
if (checkout) {
|
|
751
|
+
try {
|
|
752
|
+
const subtotalTax = checkout.subtotalPrice?.tax?.amount || 0;
|
|
753
|
+
const shippingTax = checkout.shippingPrice?.tax?.amount || 0;
|
|
754
|
+
const subtotalNet = checkout.subtotalPrice?.net?.amount || 0;
|
|
755
|
+
const shippingNet = checkout.shippingPrice?.net?.amount || 0;
|
|
756
|
+
const currency = checkout.totalPrice?.gross?.currency || "USD";
|
|
757
|
+
|
|
758
|
+
setTaxInfo({
|
|
759
|
+
totalTax: subtotalTax,
|
|
760
|
+
shippingTax,
|
|
761
|
+
subtotalNet,
|
|
762
|
+
shippingNet,
|
|
763
|
+
currency,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Update the Saleor total with the gross amount including tax
|
|
767
|
+
const grossTotal = checkout.totalPrice?.gross?.amount;
|
|
768
|
+
if (typeof grossTotal === "number") {
|
|
769
|
+
setSaleorTotal(grossTotal);
|
|
770
|
+
}
|
|
771
|
+
} catch (taxError) {
|
|
772
|
+
console.warn("Failed to parse tax information:", taxError);
|
|
773
|
+
// Don't throw error, just log it so address update still succeeds
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
},
|
|
777
|
+
[endpoint]
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
const updateBillingAddress = useCallback(
|
|
781
|
+
async (id: string, addr: AddressInputTS) => {
|
|
782
|
+
try {
|
|
783
|
+
const variables = { id, billingAddress: addr };
|
|
784
|
+
const res = await fetch(endpoint, {
|
|
785
|
+
method: "POST",
|
|
786
|
+
headers: { "Content-Type": "application/json" },
|
|
787
|
+
body: JSON.stringify({
|
|
788
|
+
query: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
|
789
|
+
variables,
|
|
790
|
+
}),
|
|
791
|
+
});
|
|
792
|
+
if (!res.ok) throw new Error("Failed to update billing address");
|
|
793
|
+
|
|
794
|
+
const json = await res.json();
|
|
795
|
+
|
|
796
|
+
// Check for GraphQL errors
|
|
797
|
+
if (json.errors && json.errors.length > 0) {
|
|
798
|
+
const graphqlError = json.errors[0];
|
|
799
|
+
throw handleGraphQLError(graphqlError, "Billing address update");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const errs = json.data?.checkoutBillingAddressUpdate?.errors as
|
|
803
|
+
| Array<{ message?: string }>
|
|
804
|
+
| undefined;
|
|
805
|
+
if (errs?.length) {
|
|
806
|
+
const errorMessage =
|
|
807
|
+
errs[0]?.message || "Billing address update error";
|
|
808
|
+
throw handleGraphQLError(
|
|
809
|
+
new Error(errorMessage),
|
|
810
|
+
"Billing address update"
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
} catch (error) {
|
|
814
|
+
// If it's already a handled error, rethrow it
|
|
815
|
+
if (
|
|
816
|
+
error instanceof Error &&
|
|
817
|
+
(error.message.includes("checkout session") ||
|
|
818
|
+
error.message.includes("session has expired"))
|
|
819
|
+
) {
|
|
820
|
+
throw error;
|
|
821
|
+
}
|
|
822
|
+
// Otherwise, handle it
|
|
823
|
+
throw handleGraphQLError(error, "Billing address update");
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
[endpoint, handleGraphQLError]
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
const updateCheckoutEmail = useCallback(
|
|
830
|
+
async (id: string, email: string, onError?: (message: string) => void) => {
|
|
831
|
+
try {
|
|
832
|
+
const variables = { checkoutId: id, email };
|
|
833
|
+
const res = await fetch(endpoint, {
|
|
834
|
+
method: "POST",
|
|
835
|
+
headers: { "Content-Type": "application/json" },
|
|
836
|
+
body: JSON.stringify({ query: CHECKOUT_EMAIL_UPDATE, variables }),
|
|
837
|
+
});
|
|
838
|
+
if (!res.ok) throw new Error("Failed to update checkout email");
|
|
839
|
+
|
|
840
|
+
const json = await res.json();
|
|
841
|
+
|
|
842
|
+
// Check for GraphQL errors
|
|
843
|
+
if (json.errors && json.errors.length > 0) {
|
|
844
|
+
const graphqlError = json.errors[0];
|
|
845
|
+
const errorMessage = graphqlError.message || "GraphQL error";
|
|
846
|
+
if (onError) onError(errorMessage);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const errs = json.data?.checkoutEmailUpdate?.errors as
|
|
851
|
+
| Array<{ message?: string }>
|
|
852
|
+
| undefined;
|
|
853
|
+
if (errs?.length) {
|
|
854
|
+
const errorMessage = errs[0]?.message || "Email update error";
|
|
855
|
+
if (onError) onError(errorMessage);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
} catch (error) {
|
|
859
|
+
const errorMessage =
|
|
860
|
+
error instanceof Error ? error.message : "Email update failed";
|
|
861
|
+
if (onError) onError(errorMessage);
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
[endpoint]
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
const getCheckoutDetails = useCallback(
|
|
868
|
+
async (id: string, signal?: AbortSignal) => {
|
|
869
|
+
try {
|
|
870
|
+
const res = await fetch(endpoint, {
|
|
871
|
+
method: "POST",
|
|
872
|
+
headers: { "Content-Type": "application/json" },
|
|
873
|
+
body: JSON.stringify({
|
|
874
|
+
query: GET_CHECKOUT_DETAILS,
|
|
875
|
+
variables: { id },
|
|
876
|
+
}),
|
|
877
|
+
signal,
|
|
878
|
+
});
|
|
879
|
+
if (!res.ok) throw new Error("Failed to fetch checkout details");
|
|
880
|
+
|
|
881
|
+
const json = await res.json();
|
|
882
|
+
|
|
883
|
+
// Check for GraphQL errors
|
|
884
|
+
if (json.errors && json.errors.length > 0) {
|
|
885
|
+
const graphqlError = json.errors[0];
|
|
886
|
+
throw handleGraphQLError(graphqlError, "Get checkout details");
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const checkout = json.data?.checkout;
|
|
890
|
+
if (!checkout) {
|
|
891
|
+
throw handleGraphQLError(
|
|
892
|
+
new Error("Unable to determine checkout details"),
|
|
893
|
+
"Get checkout details"
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const rawTotal = checkout.totalPrice?.gross?.amount;
|
|
898
|
+
const rawSubtotal = checkout.subtotalPrice?.gross?.amount;
|
|
899
|
+
const lines = checkout.lines || [];
|
|
900
|
+
const deliveryMethod = checkout.deliveryMethod;
|
|
901
|
+
|
|
902
|
+
// Extract voucher information
|
|
903
|
+
const voucherCode = checkout.voucherCode || null;
|
|
904
|
+
const discount = checkout.discount
|
|
905
|
+
? {
|
|
906
|
+
amount: checkout.discount.amount,
|
|
907
|
+
currency: checkout.discount.currency,
|
|
908
|
+
}
|
|
909
|
+
: null;
|
|
910
|
+
|
|
911
|
+
return {
|
|
912
|
+
total: rawTotal,
|
|
913
|
+
subtotal: rawSubtotal,
|
|
914
|
+
lines,
|
|
915
|
+
deliveryMethod,
|
|
916
|
+
voucherInfo: { voucherCode, discount },
|
|
917
|
+
fullCheckoutData: checkout, // Include full checkout data for validation
|
|
918
|
+
};
|
|
919
|
+
} catch (error) {
|
|
920
|
+
// If it's already a handled error, rethrow it
|
|
921
|
+
if (
|
|
922
|
+
error instanceof Error &&
|
|
923
|
+
(error.message.includes("checkout session") ||
|
|
924
|
+
error.message.includes("session has expired"))
|
|
925
|
+
) {
|
|
926
|
+
throw error;
|
|
927
|
+
}
|
|
928
|
+
// Otherwise, handle it
|
|
929
|
+
throw handleGraphQLError(error, "Get checkout details");
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
[endpoint, handleGraphQLError]
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
const updateDeliveryMethod = useCallback(
|
|
936
|
+
async (id: string, methodId: string) => {
|
|
937
|
+
if (updatingDeliveryRef.current) return;
|
|
938
|
+
updatingDeliveryRef.current = true;
|
|
939
|
+
try {
|
|
940
|
+
const variables = { id, deliveryMethodId: methodId };
|
|
941
|
+
const res = await fetch(endpoint, {
|
|
942
|
+
method: "POST",
|
|
943
|
+
headers: { "Content-Type": "application/json" },
|
|
944
|
+
body: JSON.stringify({
|
|
945
|
+
query: CHECKOUT_DELIVERY_METHOD_UPDATE,
|
|
946
|
+
variables,
|
|
947
|
+
}),
|
|
948
|
+
});
|
|
949
|
+
if (!res.ok) {
|
|
950
|
+
const errorText = await res.text();
|
|
951
|
+
throw new Error(
|
|
952
|
+
`Failed to set delivery method: ${res.status} ${res.statusText} ${errorText}`
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const json = await res.json();
|
|
957
|
+
|
|
958
|
+
// Check for GraphQL errors
|
|
959
|
+
if (json.errors && json.errors.length > 0) {
|
|
960
|
+
const graphqlError = json.errors[0];
|
|
961
|
+
throw handleGraphQLError(graphqlError, "Delivery method update");
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const errs = json.data?.checkoutDeliveryMethodUpdate?.errors as
|
|
965
|
+
| Array<{ message?: string; field?: string; code?: string }>
|
|
966
|
+
| undefined;
|
|
967
|
+
if (errs?.length) {
|
|
968
|
+
const errorDetails = errs
|
|
969
|
+
.map((e) => `${e.field || "unknown"}: ${e.message || "unknown"}`)
|
|
970
|
+
.join(", ");
|
|
971
|
+
const errorMessage =
|
|
972
|
+
errs[0]?.message || `Delivery method update error: ${errorDetails}`;
|
|
973
|
+
throw handleGraphQLError(
|
|
974
|
+
new Error(errorMessage),
|
|
975
|
+
"Delivery method update"
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Extract and store tax information from delivery method update
|
|
980
|
+
const checkout = json.data?.checkoutDeliveryMethodUpdate?.checkout;
|
|
981
|
+
if (checkout) {
|
|
982
|
+
try {
|
|
983
|
+
const subtotalTax = checkout.subtotalPrice?.tax?.amount || 0;
|
|
984
|
+
const shippingTax = checkout.shippingPrice?.tax?.amount || 0;
|
|
985
|
+
const subtotalNet = checkout.subtotalPrice?.net?.amount || 0;
|
|
986
|
+
const shippingNet = checkout.shippingPrice?.net?.amount || 0;
|
|
987
|
+
const currency = checkout.totalPrice?.gross?.currency || "USD";
|
|
988
|
+
|
|
989
|
+
setTaxInfo({
|
|
990
|
+
totalTax: subtotalTax,
|
|
991
|
+
shippingTax,
|
|
992
|
+
subtotalNet,
|
|
993
|
+
shippingNet,
|
|
994
|
+
currency,
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
// Update the Saleor total with the gross amount including tax
|
|
998
|
+
const grossTotal = checkout.totalPrice?.gross?.amount;
|
|
999
|
+
if (typeof grossTotal === "number") {
|
|
1000
|
+
setSaleorTotal(grossTotal);
|
|
1001
|
+
}
|
|
1002
|
+
} catch (taxError) {
|
|
1003
|
+
console.warn(
|
|
1004
|
+
"Failed to parse tax information from delivery method update:",
|
|
1005
|
+
taxError
|
|
1006
|
+
);
|
|
1007
|
+
// Don't throw error, just log it so delivery method update still succeeds
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
// If it's already a handled error, rethrow it
|
|
1012
|
+
if (
|
|
1013
|
+
error instanceof Error &&
|
|
1014
|
+
(error.message.includes("checkout session") ||
|
|
1015
|
+
error.message.includes("session has expired"))
|
|
1016
|
+
) {
|
|
1017
|
+
throw error;
|
|
1018
|
+
}
|
|
1019
|
+
// Otherwise, handle it
|
|
1020
|
+
throw handleGraphQLError(error, "Delivery method update");
|
|
1021
|
+
} finally {
|
|
1022
|
+
updatingDeliveryRef.current = false;
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
[endpoint, handleGraphQLError]
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
const applyVoucher = useCallback(
|
|
1029
|
+
async (voucherCode: string) => {
|
|
1030
|
+
if (!checkoutId) {
|
|
1031
|
+
setVoucherError("No checkout session found");
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
setIsApplyingVoucher(true);
|
|
1036
|
+
setVoucherError(null);
|
|
1037
|
+
|
|
1038
|
+
try {
|
|
1039
|
+
const variables = { checkoutId, promoCode: voucherCode };
|
|
1040
|
+
const res = await fetch(endpoint, {
|
|
1041
|
+
method: "POST",
|
|
1042
|
+
headers: { "Content-Type": "application/json" },
|
|
1043
|
+
body: JSON.stringify({ query: ADD_VOUCHER_TO_CHECKOUT, variables }),
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
if (!res.ok) {
|
|
1047
|
+
throw new Error(
|
|
1048
|
+
`Failed to apply voucher: ${res.status} ${res.statusText}`
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const json = await res.json();
|
|
1053
|
+
|
|
1054
|
+
if (json.errors && json.errors.length > 0) {
|
|
1055
|
+
throw new Error(json.errors[0].message || "Failed to apply voucher");
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const result = json.data?.checkoutAddPromoCode;
|
|
1059
|
+
if (result?.errors && result.errors.length > 0) {
|
|
1060
|
+
const error = result.errors[0];
|
|
1061
|
+
switch (error.code) {
|
|
1062
|
+
case "INVALID":
|
|
1063
|
+
setVoucherError("Promo code is invalid");
|
|
1064
|
+
break;
|
|
1065
|
+
case "VOUCHER_NOT_APPLICABLE":
|
|
1066
|
+
setVoucherError("Voucher is not applicable to this checkout");
|
|
1067
|
+
break;
|
|
1068
|
+
default:
|
|
1069
|
+
setVoucherError(error.message || "Failed to apply voucher");
|
|
1070
|
+
}
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (result?.checkout) {
|
|
1075
|
+
const checkout = result.checkout;
|
|
1076
|
+
setVoucherInfo({
|
|
1077
|
+
voucherCode: checkout.voucherCode || null,
|
|
1078
|
+
discount: checkout.discount
|
|
1079
|
+
? {
|
|
1080
|
+
amount: checkout.discount.amount,
|
|
1081
|
+
currency: checkout.discount.currency,
|
|
1082
|
+
}
|
|
1083
|
+
: null,
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
// Update total with new discounted price
|
|
1087
|
+
const grossTotal = checkout.totalPrice?.gross?.amount;
|
|
1088
|
+
if (typeof grossTotal === "number") {
|
|
1089
|
+
setSaleorTotal(grossTotal);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
console.error("Error applying voucher:", error);
|
|
1094
|
+
setVoucherError(
|
|
1095
|
+
error instanceof Error ? error.message : "Failed to apply voucher"
|
|
1096
|
+
);
|
|
1097
|
+
} finally {
|
|
1098
|
+
setIsApplyingVoucher(false);
|
|
1099
|
+
}
|
|
1100
|
+
},
|
|
1101
|
+
[checkoutId, endpoint]
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
const removeVoucher = useCallback(async () => {
|
|
1105
|
+
if (!checkoutId || !voucherInfo?.voucherCode) {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
setIsApplyingVoucher(true);
|
|
1110
|
+
setVoucherError(null);
|
|
1111
|
+
|
|
1112
|
+
try {
|
|
1113
|
+
const variables = { checkoutId, promoCode: voucherInfo.voucherCode };
|
|
1114
|
+
const res = await fetch(endpoint, {
|
|
1115
|
+
method: "POST",
|
|
1116
|
+
headers: { "Content-Type": "application/json" },
|
|
1117
|
+
body: JSON.stringify({
|
|
1118
|
+
query: REMOVE_VOUCHER_FROM_CHECKOUT,
|
|
1119
|
+
variables,
|
|
1120
|
+
}),
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
if (!res.ok) {
|
|
1124
|
+
throw new Error(
|
|
1125
|
+
`Failed to remove voucher: ${res.status} ${res.statusText}`
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const json = await res.json();
|
|
1130
|
+
|
|
1131
|
+
if (json.errors && json.errors.length > 0) {
|
|
1132
|
+
throw new Error(json.errors[0].message || "Failed to remove voucher");
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const result = json.data?.checkoutRemovePromoCode;
|
|
1136
|
+
if (result?.errors && result.errors.length > 0) {
|
|
1137
|
+
setVoucherError(result.errors[0].message || "Failed to remove voucher");
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (result?.checkout) {
|
|
1142
|
+
const checkout = result.checkout;
|
|
1143
|
+
setVoucherInfo({
|
|
1144
|
+
voucherCode: checkout.voucherCode || null,
|
|
1145
|
+
discount: checkout.discount
|
|
1146
|
+
? {
|
|
1147
|
+
amount: checkout.discount.amount,
|
|
1148
|
+
currency: checkout.discount.currency,
|
|
1149
|
+
}
|
|
1150
|
+
: null,
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// Update total with new price without discount
|
|
1154
|
+
const grossTotal = checkout.totalPrice?.gross?.amount;
|
|
1155
|
+
if (typeof grossTotal === "number") {
|
|
1156
|
+
setSaleorTotal(grossTotal);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
console.error("Error removing voucher:", error);
|
|
1161
|
+
setVoucherError(
|
|
1162
|
+
error instanceof Error ? error.message : "Failed to remove voucher"
|
|
1163
|
+
);
|
|
1164
|
+
} finally {
|
|
1165
|
+
setIsApplyingVoucher(false);
|
|
1166
|
+
}
|
|
1167
|
+
}, [checkoutId, endpoint, voucherInfo?.voucherCode]);
|
|
1168
|
+
|
|
1169
|
+
const buildAddressFromForm = useCallback(
|
|
1170
|
+
(info: typeof shippingInfo): AddressInputTS => ({
|
|
1171
|
+
firstName: info.firstName?.trim() || "",
|
|
1172
|
+
lastName: info.lastName?.trim() || "",
|
|
1173
|
+
streetAddress1: info.address?.trim() || "",
|
|
1174
|
+
city: info.city?.trim() || "",
|
|
1175
|
+
postalCode: info.zipCode?.trim() || "",
|
|
1176
|
+
country: info.country || "US",
|
|
1177
|
+
countryArea: info.state?.trim() || undefined,
|
|
1178
|
+
phone: info.phone?.trim() || null,
|
|
1179
|
+
}),
|
|
1180
|
+
[]
|
|
1181
|
+
);
|
|
1182
|
+
|
|
1183
|
+
const buildAddressFromAccount = useCallback(
|
|
1184
|
+
(addr: AccountAddressLite): AddressInputTS => ({
|
|
1185
|
+
firstName: addr?.firstName || "",
|
|
1186
|
+
lastName: addr?.lastName || "",
|
|
1187
|
+
streetAddress1: addr?.streetAddress1 || "",
|
|
1188
|
+
city: addr?.city || "",
|
|
1189
|
+
postalCode: addr?.postalCode || "",
|
|
1190
|
+
country: addr?.country?.code || "",
|
|
1191
|
+
countryArea: addr?.countryArea || undefined,
|
|
1192
|
+
phone: addr?.phone ?? null,
|
|
1193
|
+
}),
|
|
1194
|
+
[]
|
|
1195
|
+
);
|
|
1196
|
+
|
|
1197
|
+
// Init guest form
|
|
1198
|
+
useEffect(() => {
|
|
1199
|
+
if (!isLoggedIn) {
|
|
1200
|
+
setShippingInfo((prev) => ({
|
|
1201
|
+
...prev,
|
|
1202
|
+
firstName: guestShippingInfo.firstName || "",
|
|
1203
|
+
lastName: guestShippingInfo.lastName || "",
|
|
1204
|
+
address: guestShippingInfo.address || "",
|
|
1205
|
+
city: guestShippingInfo.city || "",
|
|
1206
|
+
state: guestShippingInfo.state || "",
|
|
1207
|
+
zipCode: guestShippingInfo.zipCode || "",
|
|
1208
|
+
email: guestEmail || "",
|
|
1209
|
+
phone: guestShippingInfo.phone || "",
|
|
1210
|
+
country: guestShippingInfo.country || prev.country || "US",
|
|
1211
|
+
}));
|
|
1212
|
+
}
|
|
1213
|
+
}, [isLoggedIn, guestEmail, guestShippingInfo]);
|
|
1214
|
+
|
|
1215
|
+
// Account addresses
|
|
1216
|
+
const { data: meData, refetch: refetchMe } = useQuery<MeAddressesData>(
|
|
1217
|
+
ME_ADDRESSES_QUERY,
|
|
1218
|
+
{
|
|
1219
|
+
skip: !isLoggedIn,
|
|
1220
|
+
fetchPolicy: "cache-and-network",
|
|
1221
|
+
}
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
// Payment gateways
|
|
1225
|
+
const { data: paymentGatewaysData } = useQuery(gql(GET_PAYMENT_GATEWAYS), {
|
|
1226
|
+
variables: { checkoutId },
|
|
1227
|
+
skip: !checkoutId,
|
|
1228
|
+
fetchPolicy: "cache-and-network",
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
const [setDefaultAddress] = useMutation<
|
|
1232
|
+
AccountSetDefaultAddressData,
|
|
1233
|
+
AccountSetDefaultAddressVars
|
|
1234
|
+
>(ACCOUNT_SET_DEFAULT_ADDRESS, {
|
|
1235
|
+
refetchQueries: [{ query: ME_ADDRESSES_QUERY }],
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
const [updateWillCallDeliveryMethod] = useMutation<
|
|
1239
|
+
WillCallDeliveryMethodUpdateData,
|
|
1240
|
+
WillCallDeliveryMethodUpdateVars
|
|
1241
|
+
>(CHECKOUT_DELIVERY_METHOD_UPDATE_WILL_CALL);
|
|
1242
|
+
|
|
1243
|
+
const accountShipping = useMemo(() => {
|
|
1244
|
+
const me = meData?.me;
|
|
1245
|
+
if (!me || !me.addresses?.length) return null;
|
|
1246
|
+
const defId = me.defaultShippingAddress?.id;
|
|
1247
|
+
return (
|
|
1248
|
+
(defId
|
|
1249
|
+
? me.addresses.find((a: AccountAddressLite) => a.id === defId)
|
|
1250
|
+
: me.addresses[0]) || null
|
|
1251
|
+
);
|
|
1252
|
+
}, [meData]);
|
|
1253
|
+
|
|
1254
|
+
const accountBilling = useMemo(() => {
|
|
1255
|
+
const me = meData?.me;
|
|
1256
|
+
if (!me || !me.addresses?.length) return null;
|
|
1257
|
+
const defId = me.defaultBillingAddress?.id;
|
|
1258
|
+
return (
|
|
1259
|
+
(defId
|
|
1260
|
+
? me.addresses.find((a: AccountAddressLite) => a.id === defId)
|
|
1261
|
+
: me.addresses[0]) || null
|
|
1262
|
+
);
|
|
1263
|
+
}, [meData]);
|
|
1264
|
+
|
|
1265
|
+
// Initialize selected ids - wait for data to load before setting defaults
|
|
1266
|
+
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(
|
|
1267
|
+
null
|
|
1268
|
+
);
|
|
1269
|
+
const [selectedBillingAddressId, setSelectedBillingAddressId] = useState<
|
|
1270
|
+
string | null
|
|
1271
|
+
>(null);
|
|
1272
|
+
const [addressAutoSelectionComplete, setAddressAutoSelectionComplete] =
|
|
1273
|
+
useState(false);
|
|
1274
|
+
|
|
1275
|
+
// Auto-select addresses but track completion for delivery method loading timing
|
|
1276
|
+
useEffect(() => {
|
|
1277
|
+
if (isLoggedIn && meData?.me) {
|
|
1278
|
+
let hasChanges = false;
|
|
1279
|
+
|
|
1280
|
+
// Auto-select shipping address - default or first available
|
|
1281
|
+
if (!selectedAddressId) {
|
|
1282
|
+
const defaultShippingId = meData.me.defaultShippingAddress?.id;
|
|
1283
|
+
const firstAddressId = meData.me.addresses?.[0]?.id;
|
|
1284
|
+
const addressToUse = defaultShippingId || firstAddressId;
|
|
1285
|
+
if (addressToUse) {
|
|
1286
|
+
setSelectedAddressId(addressToUse);
|
|
1287
|
+
hasChanges = true;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Set billing address - default or first available
|
|
1292
|
+
if (!selectedBillingAddressId) {
|
|
1293
|
+
const defaultBillingId = meData.me.defaultBillingAddress?.id;
|
|
1294
|
+
const firstAddressId = meData.me.addresses?.[0]?.id;
|
|
1295
|
+
const addressToUse = defaultBillingId || firstAddressId;
|
|
1296
|
+
if (addressToUse) {
|
|
1297
|
+
setSelectedBillingAddressId(addressToUse);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// Mark auto-selection as complete after all address selections are done
|
|
1302
|
+
if (hasChanges || selectedAddressId || !meData.me.addresses?.length) {
|
|
1303
|
+
// Use a small delay to ensure state updates are processed
|
|
1304
|
+
setTimeout(() => setAddressAutoSelectionComplete(true), 50);
|
|
1305
|
+
}
|
|
1306
|
+
} else if (!isLoggedIn) {
|
|
1307
|
+
// For guests, mark auto-selection as complete immediately
|
|
1308
|
+
setAddressAutoSelectionComplete(true);
|
|
1309
|
+
}
|
|
1310
|
+
}, [isLoggedIn, meData?.me, selectedAddressId, selectedBillingAddressId]);
|
|
1311
|
+
|
|
1312
|
+
// This effect is removed as it conflicts with the selectedAddressId effect below
|
|
1313
|
+
// The selectedAddressId effect handles both auto-selected and manually selected addresses
|
|
1314
|
+
|
|
1315
|
+
const handleSetDefaultAddress = async (
|
|
1316
|
+
addressId: string,
|
|
1317
|
+
type: "SHIPPING" | "BILLING"
|
|
1318
|
+
) => {
|
|
1319
|
+
if (!isLoggedIn || !addressId) return;
|
|
1320
|
+
try {
|
|
1321
|
+
const { data } = await setDefaultAddress({
|
|
1322
|
+
variables: { id: addressId, type },
|
|
1323
|
+
});
|
|
1324
|
+
if (data?.accountSetDefaultAddress?.errors?.length) {
|
|
1325
|
+
throw new Error(
|
|
1326
|
+
data.accountSetDefaultAddress.errors[0]?.message ||
|
|
1327
|
+
"Failed to update default address"
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
if (type === "SHIPPING") setSelectedAddressId(addressId);
|
|
1331
|
+
else setSelectedBillingAddressId(addressId);
|
|
1332
|
+
await refetchMe();
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
console.error(
|
|
1335
|
+
`Failed to set default ${type.toLowerCase()} address:`,
|
|
1336
|
+
error
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
// Hydrate shipping form when selectedAddressId changes (logged in)
|
|
1342
|
+
useEffect(() => {
|
|
1343
|
+
if (!isLoggedIn || !meData?.me?.addresses) return;
|
|
1344
|
+
const selectedAddress = meData.me.addresses.find(
|
|
1345
|
+
(addr: AccountAddressLite) => addr.id === selectedAddressId
|
|
1346
|
+
);
|
|
1347
|
+
if (selectedAddress) {
|
|
1348
|
+
setShippingInfo((prev) => ({
|
|
1349
|
+
...prev,
|
|
1350
|
+
firstName: selectedAddress.firstName || "",
|
|
1351
|
+
lastName: selectedAddress.lastName || "",
|
|
1352
|
+
address: selectedAddress.streetAddress1 || "",
|
|
1353
|
+
city: selectedAddress.city || "",
|
|
1354
|
+
state: selectedAddress.countryArea || "",
|
|
1355
|
+
zipCode: selectedAddress.postalCode || "",
|
|
1356
|
+
email: meData.me?.email || prev.email,
|
|
1357
|
+
phone: selectedAddress.phone || "",
|
|
1358
|
+
country: selectedAddress.country?.code || prev.country || "US",
|
|
1359
|
+
}));
|
|
1360
|
+
|
|
1361
|
+
// Clear address tracking to allow fresh API call with new address
|
|
1362
|
+
lastAddressHashRef.current = "";
|
|
1363
|
+
|
|
1364
|
+
// Clear tax info when address changes to trigger recalculation
|
|
1365
|
+
setTaxInfo(null);
|
|
1366
|
+
setIsCalculatingTax(false);
|
|
1367
|
+
|
|
1368
|
+
// Clear any delivery methods error when address is hydrated
|
|
1369
|
+
if (
|
|
1370
|
+
shippingError &&
|
|
1371
|
+
shippingError.includes("No delivery methods found")
|
|
1372
|
+
) {
|
|
1373
|
+
setShippingError(null);
|
|
1374
|
+
lastFetchedAtRef.current = 0;
|
|
1375
|
+
fetchingMethodsRef.current = false;
|
|
1376
|
+
setShippingMethods([]);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}, [isLoggedIn, selectedAddressId, meData, shippingError]);
|
|
1380
|
+
|
|
1381
|
+
// Removed automatic population of shipping info from accountShipping
|
|
1382
|
+
// Users must explicitly select an address to load delivery methods
|
|
1383
|
+
|
|
1384
|
+
const hasCompleteShippingInfo = useMemo(() => {
|
|
1385
|
+
const s = shippingInfo;
|
|
1386
|
+
return !!(
|
|
1387
|
+
s.firstName &&
|
|
1388
|
+
s.lastName &&
|
|
1389
|
+
s.address &&
|
|
1390
|
+
s.city &&
|
|
1391
|
+
s.zipCode &&
|
|
1392
|
+
s.country
|
|
1393
|
+
);
|
|
1394
|
+
}, [shippingInfo]);
|
|
1395
|
+
// ---- Payload builders (idempotent) ----
|
|
1396
|
+
const shippingPayload: AddressInputTS | null = useMemo(() => {
|
|
1397
|
+
// If shipping to dealer, use dealer address
|
|
1398
|
+
if (isShipToDealer && selectedDealer) {
|
|
1399
|
+
return {
|
|
1400
|
+
firstName: "Dealer",
|
|
1401
|
+
lastName: "Pickup",
|
|
1402
|
+
streetAddress1: selectedDealer.address.streetAddress1 || "",
|
|
1403
|
+
city: selectedDealer.address.city || "",
|
|
1404
|
+
postalCode: selectedDealer.address.postalCode || "",
|
|
1405
|
+
country: selectedDealer.address.country?.code || "US",
|
|
1406
|
+
countryArea: selectedDealer.address?.countryArea || "",
|
|
1407
|
+
phone: selectedDealer.phone || null,
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Standard shipping logic
|
|
1412
|
+
if (isLoggedIn && selectedAddressId) {
|
|
1413
|
+
const addr = meData?.me?.addresses?.find(
|
|
1414
|
+
(a: AccountAddressLite) => a.id === selectedAddressId
|
|
1415
|
+
) as AccountAddressLite | undefined;
|
|
1416
|
+
return addr ? buildAddressFromAccount(addr) : null;
|
|
1417
|
+
}
|
|
1418
|
+
if (isLoggedIn && accountShipping && addressAutoSelectionComplete)
|
|
1419
|
+
return buildAddressFromAccount(
|
|
1420
|
+
accountShipping as unknown as AccountAddressLite
|
|
1421
|
+
);
|
|
1422
|
+
if (hasCompleteShippingInfo) return buildAddressFromForm(shippingInfo);
|
|
1423
|
+
return null;
|
|
1424
|
+
}, [
|
|
1425
|
+
isShipToDealer,
|
|
1426
|
+
selectedDealer,
|
|
1427
|
+
isLoggedIn,
|
|
1428
|
+
selectedAddressId,
|
|
1429
|
+
meData,
|
|
1430
|
+
accountShipping,
|
|
1431
|
+
addressAutoSelectionComplete,
|
|
1432
|
+
hasCompleteShippingInfo,
|
|
1433
|
+
shippingInfo,
|
|
1434
|
+
buildAddressFromAccount,
|
|
1435
|
+
buildAddressFromForm,
|
|
1436
|
+
]);
|
|
1437
|
+
|
|
1438
|
+
const hasCompleteBillingInfo = useMemo(() => {
|
|
1439
|
+
const b = billingInfo;
|
|
1440
|
+
return !!(
|
|
1441
|
+
b.firstName &&
|
|
1442
|
+
b.lastName &&
|
|
1443
|
+
b.address &&
|
|
1444
|
+
b.city &&
|
|
1445
|
+
b.zipCode &&
|
|
1446
|
+
b.country
|
|
1447
|
+
);
|
|
1448
|
+
}, [billingInfo]);
|
|
1449
|
+
|
|
1450
|
+
const canShowDeliveryMethods = useMemo(() => {
|
|
1451
|
+
// If shipping to dealer, we can show delivery methods once dealer is selected
|
|
1452
|
+
if (isShipToDealer) {
|
|
1453
|
+
return !!selectedDealer;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Don't show delivery methods until auto-selection is complete
|
|
1457
|
+
if (!addressAutoSelectionComplete) return false;
|
|
1458
|
+
|
|
1459
|
+
if (isLoggedIn) {
|
|
1460
|
+
// For logged in users, require address selection (auto or manual)
|
|
1461
|
+
const hasSelectedAddress = !!selectedAddressId;
|
|
1462
|
+
const addressExists = meData?.me?.addresses?.some(
|
|
1463
|
+
(addr: AccountAddressLite) => addr.id === selectedAddressId
|
|
1464
|
+
);
|
|
1465
|
+
return hasSelectedAddress && addressExists;
|
|
1466
|
+
}
|
|
1467
|
+
// For guest users, require complete shipping info
|
|
1468
|
+
return hasCompleteShippingInfo;
|
|
1469
|
+
}, [
|
|
1470
|
+
isShipToDealer,
|
|
1471
|
+
selectedDealer,
|
|
1472
|
+
addressAutoSelectionComplete,
|
|
1473
|
+
isLoggedIn,
|
|
1474
|
+
selectedAddressId,
|
|
1475
|
+
meData,
|
|
1476
|
+
hasCompleteShippingInfo,
|
|
1477
|
+
]);
|
|
1478
|
+
|
|
1479
|
+
const billingPayload: AddressInputTS | null = useMemo(() => {
|
|
1480
|
+
// Only sync billing if we have valid data
|
|
1481
|
+
if (useShippingAsBilling) {
|
|
1482
|
+
// For dealer shipping, use customer address if available, otherwise use dealer address
|
|
1483
|
+
if (isShipToDealer) {
|
|
1484
|
+
if (isLoggedIn && selectedAddressId) {
|
|
1485
|
+
const addr = meData?.me?.addresses?.find(
|
|
1486
|
+
(a: AccountAddressLite) => a.id === selectedAddressId
|
|
1487
|
+
) as AccountAddressLite | undefined;
|
|
1488
|
+
return addr ? buildAddressFromAccount(addr) : shippingPayload;
|
|
1489
|
+
}
|
|
1490
|
+
return hasCompleteShippingInfo
|
|
1491
|
+
? buildAddressFromForm(shippingInfo)
|
|
1492
|
+
: shippingPayload;
|
|
1493
|
+
}
|
|
1494
|
+
// Only return shipping payload if it's complete and valid
|
|
1495
|
+
return shippingPayload && hasCompleteShippingInfo
|
|
1496
|
+
? shippingPayload
|
|
1497
|
+
: null;
|
|
1498
|
+
}
|
|
1499
|
+
if (isLoggedIn) {
|
|
1500
|
+
const selectedBilling = selectedBillingAddressId
|
|
1501
|
+
? (meData?.me?.addresses?.find(
|
|
1502
|
+
(a: AccountAddressLite) => a.id === selectedBillingAddressId
|
|
1503
|
+
) as AccountAddressLite | undefined)
|
|
1504
|
+
: (accountBilling as unknown as AccountAddressLite | null);
|
|
1505
|
+
return selectedBilling ? buildAddressFromAccount(selectedBilling) : null;
|
|
1506
|
+
}
|
|
1507
|
+
return hasCompleteBillingInfo ? buildAddressFromForm(billingInfo) : null;
|
|
1508
|
+
}, [
|
|
1509
|
+
useShippingAsBilling,
|
|
1510
|
+
isShipToDealer,
|
|
1511
|
+
isLoggedIn,
|
|
1512
|
+
selectedAddressId,
|
|
1513
|
+
meData,
|
|
1514
|
+
buildAddressFromAccount,
|
|
1515
|
+
shippingPayload,
|
|
1516
|
+
hasCompleteShippingInfo,
|
|
1517
|
+
shippingInfo,
|
|
1518
|
+
buildAddressFromForm,
|
|
1519
|
+
selectedBillingAddressId,
|
|
1520
|
+
accountBilling,
|
|
1521
|
+
billingInfo,
|
|
1522
|
+
hasCompleteBillingInfo,
|
|
1523
|
+
]);
|
|
1524
|
+
|
|
1525
|
+
/** UPDATED: fetchShippingMethods now returns methods immediately, not just via state */
|
|
1526
|
+
const fetchShippingMethods = useCallback(
|
|
1527
|
+
async (id: string): Promise<ShippingMethod[]> => {
|
|
1528
|
+
if (fetchingMethodsRef.current) {
|
|
1529
|
+
// brief wait for the in-flight request to complete, then return whatever we have
|
|
1530
|
+
await sleep(100);
|
|
1531
|
+
return shippingMethods;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Basic throttle: avoid hammering endpoint if we called it very recently
|
|
1535
|
+
const now = Date.now();
|
|
1536
|
+
if (now - lastFetchedAtRef.current < 200) {
|
|
1537
|
+
return shippingMethods;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
fetchingMethodsRef.current = true;
|
|
1541
|
+
setShippingLoading(true);
|
|
1542
|
+
setShippingError(null);
|
|
1543
|
+
|
|
1544
|
+
try {
|
|
1545
|
+
const requestBody = {
|
|
1546
|
+
query: GET_CHECKOUT_SHIPPING_METHODS,
|
|
1547
|
+
variables: { id },
|
|
1548
|
+
};
|
|
1549
|
+
const res = await fetch(endpoint, {
|
|
1550
|
+
method: "POST",
|
|
1551
|
+
headers: { "Content-Type": "application/json" },
|
|
1552
|
+
body: JSON.stringify(requestBody),
|
|
1553
|
+
});
|
|
1554
|
+
if (!res.ok) {
|
|
1555
|
+
let errorMessage = `Failed to fetch shipping methods: ${res.status} ${res.statusText}`;
|
|
1556
|
+
|
|
1557
|
+
// Handle specific error cases
|
|
1558
|
+
if (res.status === 401 || res.status === 403) {
|
|
1559
|
+
errorMessage =
|
|
1560
|
+
"Your session has expired. Please refresh the page to continue.";
|
|
1561
|
+
} else if (res.status === 408 || res.status === 504) {
|
|
1562
|
+
errorMessage =
|
|
1563
|
+
"Request timeout occurred. Please check your connection and try again.";
|
|
1564
|
+
} else if (res.status >= 500) {
|
|
1565
|
+
errorMessage =
|
|
1566
|
+
"Server error occurred. Please try again in a moment.";
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
throw new Error(errorMessage);
|
|
1570
|
+
}
|
|
1571
|
+
const json: GraphQLShippingMethodsResponse = await res.json();
|
|
1572
|
+
if (json.errors) {
|
|
1573
|
+
throw new Error(json.errors[0]?.message || "GraphQL error");
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const rawMethods = json.data?.checkout?.availableShippingMethods || [];
|
|
1577
|
+
const methods: ShippingMethod[] = rawMethods.map((m) => ({
|
|
1578
|
+
id: m.id,
|
|
1579
|
+
name: m.name,
|
|
1580
|
+
price: { amount: m.price.amount, currency: m.price.currency },
|
|
1581
|
+
minimumDeliveryDays: m.minimumDeliveryDays ?? null,
|
|
1582
|
+
maximumDeliveryDays: m.maximumDeliveryDays ?? null,
|
|
1583
|
+
}));
|
|
1584
|
+
// Update state but also return immediately for the caller to use
|
|
1585
|
+
setShippingMethods(methods);
|
|
1586
|
+
lastFetchedAtRef.current = Date.now();
|
|
1587
|
+
|
|
1588
|
+
// Sync/clean selections against fresh list
|
|
1589
|
+
if (!methods.length) {
|
|
1590
|
+
setSelectedShippingId(null);
|
|
1591
|
+
setSelectedShippingMethodId(null);
|
|
1592
|
+
setUserHasSelectedDelivery(false);
|
|
1593
|
+
|
|
1594
|
+
// Check if empty methods might indicate session expiry
|
|
1595
|
+
// If we had methods before and now have none, it could be session expiry
|
|
1596
|
+
if (shippingMethods.length > 0) {
|
|
1597
|
+
setShippingError(
|
|
1598
|
+
"No shipping methods available. This might be due to session expiry. Please refresh the page if the issue persists."
|
|
1599
|
+
);
|
|
1600
|
+
} else {
|
|
1601
|
+
// Clear any previous shipping errors since we got a valid API response
|
|
1602
|
+
setShippingError(null);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// Mark this fetch as completed to prevent immediate retry
|
|
1606
|
+
lastFetchedAtRef.current = Date.now();
|
|
1607
|
+
// IMPORTANT: Also mark the address hash to prevent infinite retries
|
|
1608
|
+
// Use the lastAddressHashRef if available, or create a default
|
|
1609
|
+
if (!lastAddressHashRef.current) {
|
|
1610
|
+
lastAddressHashRef.current = "no-methods-available";
|
|
1611
|
+
}
|
|
1612
|
+
} else {
|
|
1613
|
+
setShippingError(null);
|
|
1614
|
+
// Don't automatically restore previous shipping method selection
|
|
1615
|
+
// Users should explicitly select delivery method for each checkout
|
|
1616
|
+
if (globalSelectedShippingId) {
|
|
1617
|
+
setSelectedShippingMethodId(null);
|
|
1618
|
+
setUserHasSelectedDelivery(false);
|
|
1619
|
+
}
|
|
1620
|
+
if (
|
|
1621
|
+
selectedShippingId &&
|
|
1622
|
+
!isMethodAvailable(selectedShippingId, methods)
|
|
1623
|
+
) {
|
|
1624
|
+
setSelectedShippingId(null);
|
|
1625
|
+
setSelectedShippingMethodId(null);
|
|
1626
|
+
setUserHasSelectedDelivery(false);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
return methods;
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
let errorMessage = "Failed to load shipping methods";
|
|
1633
|
+
|
|
1634
|
+
if (error instanceof Error) {
|
|
1635
|
+
if (error.name === "AbortError") {
|
|
1636
|
+
return shippingMethods; // Return existing methods if request was aborted
|
|
1637
|
+
} else if (
|
|
1638
|
+
error.message.includes("Failed to fetch") ||
|
|
1639
|
+
error.message.includes("NetworkError")
|
|
1640
|
+
) {
|
|
1641
|
+
errorMessage =
|
|
1642
|
+
"Network connection issue. Please check your internet and try again.";
|
|
1643
|
+
} else if (error.message.includes("timeout")) {
|
|
1644
|
+
errorMessage = "Request timeout. Please try again.";
|
|
1645
|
+
} else {
|
|
1646
|
+
errorMessage = error.message;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
setShippingError(errorMessage);
|
|
1651
|
+
throw error;
|
|
1652
|
+
} finally {
|
|
1653
|
+
setShippingLoading(false);
|
|
1654
|
+
fetchingMethodsRef.current = false;
|
|
1655
|
+
}
|
|
1656
|
+
},
|
|
1657
|
+
[
|
|
1658
|
+
endpoint,
|
|
1659
|
+
// Removed frequently changing dependencies to prevent retriggering:
|
|
1660
|
+
// selectedShippingId, globalSelectedShippingId, shippingMethods
|
|
1661
|
+
]
|
|
1662
|
+
);
|
|
1663
|
+
|
|
1664
|
+
/** NEW: gets usable methods (state or fresh), with retries if empty/stale */
|
|
1665
|
+
const ensureShippingMethodsAvailable = useCallback(
|
|
1666
|
+
async (id: string): Promise<ShippingMethod[]> => {
|
|
1667
|
+
// If state already has methods, use them
|
|
1668
|
+
if (shippingMethods.length > 0) return shippingMethods;
|
|
1669
|
+
|
|
1670
|
+
// Fetch once and return result (even if empty)
|
|
1671
|
+
// Don't retry if API returns empty array - this is a valid response
|
|
1672
|
+
try {
|
|
1673
|
+
const methods = await fetchShippingMethods(id);
|
|
1674
|
+
// Return whatever we get from the API - empty array is valid
|
|
1675
|
+
return methods;
|
|
1676
|
+
} catch (error) {
|
|
1677
|
+
// Only retry on actual API errors, not empty results
|
|
1678
|
+
console.warn("Failed to fetch shipping methods, retrying...", error);
|
|
1679
|
+
|
|
1680
|
+
const methods = await withRetry(
|
|
1681
|
+
async () => {
|
|
1682
|
+
return await fetchShippingMethods(id);
|
|
1683
|
+
},
|
|
1684
|
+
2,
|
|
1685
|
+
1000
|
|
1686
|
+
); // Reduced retries, only for actual errors
|
|
1687
|
+
|
|
1688
|
+
return methods;
|
|
1689
|
+
}
|
|
1690
|
+
},
|
|
1691
|
+
[fetchShippingMethods]
|
|
1692
|
+
);
|
|
1693
|
+
|
|
1694
|
+
/** NEW: Force retry shipping methods with state reset */
|
|
1695
|
+
const handleRetryShippingMethods = useCallback(async () => {
|
|
1696
|
+
if (!checkoutId || shippingLoading || isUpdatingDelivery) return;
|
|
1697
|
+
|
|
1698
|
+
// Clear error state and force fresh fetch
|
|
1699
|
+
setShippingError(null);
|
|
1700
|
+
setShippingMethods([]);
|
|
1701
|
+
lastFetchedAtRef.current = 0; // Reset throttle
|
|
1702
|
+
lastAddressHashRef.current = ""; // Reset address hash to force refetch
|
|
1703
|
+
|
|
1704
|
+
try {
|
|
1705
|
+
await fetchShippingMethods(checkoutId);
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
const msg =
|
|
1708
|
+
error instanceof Error
|
|
1709
|
+
? error.message
|
|
1710
|
+
: "Failed to retry shipping methods";
|
|
1711
|
+
setShippingError(msg);
|
|
1712
|
+
// Re-throw the error so calling functions know it failed
|
|
1713
|
+
throw error;
|
|
1714
|
+
}
|
|
1715
|
+
}, [checkoutId, fetchShippingMethods]);
|
|
1716
|
+
|
|
1717
|
+
// Will Call functions
|
|
1718
|
+
const fetchCollectionPoints = useCallback(
|
|
1719
|
+
async (checkoutId: string) => {
|
|
1720
|
+
if (!checkoutId) return;
|
|
1721
|
+
|
|
1722
|
+
setWillCallLoading(true);
|
|
1723
|
+
setWillCallError(null);
|
|
1724
|
+
|
|
1725
|
+
try {
|
|
1726
|
+
const { data } = await apolloClient.query<
|
|
1727
|
+
GetCheckoutCollectionPointsData,
|
|
1728
|
+
GetCheckoutCollectionPointsVars
|
|
1729
|
+
>({
|
|
1730
|
+
query: GET_CHECKOUT_COLLECTION_POINTS,
|
|
1731
|
+
variables: { checkoutId },
|
|
1732
|
+
fetchPolicy: "network-only",
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
if (data?.checkout?.availableCollectionPoints) {
|
|
1736
|
+
setCollectionPoints(data.checkout.availableCollectionPoints);
|
|
1737
|
+
} else {
|
|
1738
|
+
setCollectionPoints([]);
|
|
1739
|
+
}
|
|
1740
|
+
} catch (error) {
|
|
1741
|
+
console.error("Failed to fetch collection points:", error);
|
|
1742
|
+
setWillCallError("Failed to load pickup locations");
|
|
1743
|
+
setCollectionPoints([]);
|
|
1744
|
+
} finally {
|
|
1745
|
+
setWillCallLoading(false);
|
|
1746
|
+
}
|
|
1747
|
+
},
|
|
1748
|
+
[apolloClient]
|
|
1749
|
+
);
|
|
1750
|
+
|
|
1751
|
+
const handleCollectionPointSelect = useCallback(
|
|
1752
|
+
async (collectionPointId: string) => {
|
|
1753
|
+
if (!checkoutId) return;
|
|
1754
|
+
|
|
1755
|
+
setIsProcessingSelection(true);
|
|
1756
|
+
setSelectedCollectionPointId(collectionPointId);
|
|
1757
|
+
// Clear regular shipping selection when collection point is selected
|
|
1758
|
+
setSelectedShippingId(null);
|
|
1759
|
+
setSelectedShippingMethodId(null);
|
|
1760
|
+
setUserHasSelectedDelivery(false);
|
|
1761
|
+
// Set will call as selected when a collection point is chosen
|
|
1762
|
+
setIsWillCallSelected(true);
|
|
1763
|
+
// Clear shipping tax when switching to local pickup
|
|
1764
|
+
setTaxInfo((prev) =>
|
|
1765
|
+
prev
|
|
1766
|
+
? {
|
|
1767
|
+
...prev,
|
|
1768
|
+
shippingTax: 0,
|
|
1769
|
+
shippingNet: 0,
|
|
1770
|
+
}
|
|
1771
|
+
: null
|
|
1772
|
+
);
|
|
1773
|
+
// Clear delivery ref so API will be called when switching back to shipping
|
|
1774
|
+
lastDeliveryRef.current = null;
|
|
1775
|
+
setIsUpdatingDelivery(true);
|
|
1776
|
+
|
|
1777
|
+
try {
|
|
1778
|
+
const response = await updateWillCallDeliveryMethod({
|
|
1779
|
+
variables: {
|
|
1780
|
+
id: checkoutId,
|
|
1781
|
+
deliveryMethodId: collectionPointId,
|
|
1782
|
+
},
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
if (response.data?.checkoutDeliveryMethodUpdate?.errors?.length) {
|
|
1786
|
+
const error = response.data.checkoutDeliveryMethodUpdate.errors[0];
|
|
1787
|
+
throw new Error(error.message);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// Mark as user selected delivery
|
|
1791
|
+
setUserHasSelectedDelivery(true);
|
|
1792
|
+
|
|
1793
|
+
// Update total if needed
|
|
1794
|
+
const checkout = response.data?.checkoutDeliveryMethodUpdate?.checkout;
|
|
1795
|
+
if (checkout?.subtotalPrice?.gross?.amount !== undefined) {
|
|
1796
|
+
setSaleorTotal(checkout.subtotalPrice.gross.amount);
|
|
1797
|
+
}
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
console.error("Failed to set collection point:", error);
|
|
1800
|
+
setWillCallError(
|
|
1801
|
+
error instanceof Error
|
|
1802
|
+
? error.message
|
|
1803
|
+
: "Failed to set pickup location"
|
|
1804
|
+
);
|
|
1805
|
+
setSelectedCollectionPointId(null);
|
|
1806
|
+
} finally {
|
|
1807
|
+
setIsUpdatingDelivery(false);
|
|
1808
|
+
setIsProcessingSelection(false);
|
|
1809
|
+
}
|
|
1810
|
+
},
|
|
1811
|
+
[checkoutId, updateWillCallDeliveryMethod]
|
|
1812
|
+
);
|
|
1813
|
+
|
|
1814
|
+
// Refs for idempotent pushes
|
|
1815
|
+
const lastShippingRef = useRef<AddressInputTS | null>(null);
|
|
1816
|
+
const lastBillingRef = useRef<AddressInputTS | null>(null);
|
|
1817
|
+
const lastDeliveryRef = useRef<string | null>(null);
|
|
1818
|
+
const lastCheckoutIdRef = useRef<string | null>(null);
|
|
1819
|
+
|
|
1820
|
+
// Consolidated effect for shipping address + delivery methods
|
|
1821
|
+
useEffect(() => {
|
|
1822
|
+
let mounted = true;
|
|
1823
|
+
const syncShippingAndDelivery = async () => {
|
|
1824
|
+
if (!isClient || !checkoutId || !shippingPayload) return;
|
|
1825
|
+
|
|
1826
|
+
// Prevent delivery method API calls if there's already a delivery method or address validation error
|
|
1827
|
+
// But still allow tax calculation for address updates
|
|
1828
|
+
const hasDeliveryError =
|
|
1829
|
+
shippingError &&
|
|
1830
|
+
(shippingError.includes("No delivery methods found") ||
|
|
1831
|
+
shippingError.includes("not valid for the address") ||
|
|
1832
|
+
shippingError.includes("postal code"));
|
|
1833
|
+
|
|
1834
|
+
// If there's a delivery error, we can still update address for tax calculation
|
|
1835
|
+
// but skip delivery method fetching
|
|
1836
|
+
|
|
1837
|
+
// Reduced throttling for more responsive tax calculation
|
|
1838
|
+
const now = Date.now();
|
|
1839
|
+
if (now - lastFetchedAtRef.current < 300) {
|
|
1840
|
+
// 300ms throttle for better responsiveness
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
if (
|
|
1845
|
+
lastCheckoutIdRef.current &&
|
|
1846
|
+
lastCheckoutIdRef.current !== checkoutId
|
|
1847
|
+
) {
|
|
1848
|
+
resetCheckoutState();
|
|
1849
|
+
lastCheckoutIdRef.current = checkoutId;
|
|
1850
|
+
// Clear delivery method selection for new checkout session
|
|
1851
|
+
setSelectedShippingMethodId(null);
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
if (!lastCheckoutIdRef.current) {
|
|
1855
|
+
lastCheckoutIdRef.current = checkoutId;
|
|
1856
|
+
// Clear delivery method selection when starting fresh checkout
|
|
1857
|
+
setSelectedShippingMethodId(null);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const addressHash = JSON.stringify({
|
|
1861
|
+
country: shippingPayload.country,
|
|
1862
|
+
postalCode: shippingPayload.postalCode,
|
|
1863
|
+
streetAddress1: shippingPayload.streetAddress1,
|
|
1864
|
+
city: shippingPayload.city,
|
|
1865
|
+
phone: shippingPayload.phone, // Include phone to trigger retry when phone changes
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
const shouldUpdateAddress = !shallowEq(
|
|
1869
|
+
shippingPayload,
|
|
1870
|
+
lastShippingRef.current
|
|
1871
|
+
);
|
|
1872
|
+
const addressChanged = addressHash !== lastAddressHashRef.current;
|
|
1873
|
+
const noMethodsLoaded = shippingMethods.length === 0;
|
|
1874
|
+
const shouldFetchMethods =
|
|
1875
|
+
!hasDeliveryError &&
|
|
1876
|
+
(addressChanged || (noMethodsLoaded && canShowDeliveryMethods));
|
|
1877
|
+
|
|
1878
|
+
// Don't fetch methods if we just failed with the same address hasState/Provinceh (but still allow address updates for tax)
|
|
1879
|
+
if (
|
|
1880
|
+
shouldFetchMethods &&
|
|
1881
|
+
addressHash === lastAddressHashRef.current &&
|
|
1882
|
+
shippingError
|
|
1883
|
+
) {
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// Don't fetch methods if we've already attempted this address and got empty results (but still allow address updates for tax)
|
|
1888
|
+
if (
|
|
1889
|
+
shouldFetchMethods &&
|
|
1890
|
+
addressHashAttemptedRef.current.has(addressHash) &&
|
|
1891
|
+
shippingMethods.length === 0
|
|
1892
|
+
) {
|
|
1893
|
+
// Don't return here - still allow address update for tax calculation
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
try {
|
|
1897
|
+
if (shouldUpdateAddress) {
|
|
1898
|
+
setIsCalculatingTax(true);
|
|
1899
|
+
await updateShippingAddress(checkoutId, shippingPayload);
|
|
1900
|
+
lastShippingRef.current = shippingPayload;
|
|
1901
|
+
setIsCalculatingTax(false);
|
|
1902
|
+
|
|
1903
|
+
// Track shipping info event
|
|
1904
|
+
if (items.length > 0) {
|
|
1905
|
+
const products: Product[] = items.map((item, index) => ({
|
|
1906
|
+
item_id: item.id,
|
|
1907
|
+
item_name: item.name,
|
|
1908
|
+
item_category: item.category || "Products",
|
|
1909
|
+
price: item.price,
|
|
1910
|
+
quantity: item.quantity,
|
|
1911
|
+
currency: "USD",
|
|
1912
|
+
index: index,
|
|
1913
|
+
}));
|
|
1914
|
+
|
|
1915
|
+
const totalValue = items.reduce(
|
|
1916
|
+
(sum, item) => sum + item.price * item.quantity,
|
|
1917
|
+
0
|
|
1918
|
+
);
|
|
1919
|
+
|
|
1920
|
+
// Include shipping address data in the event
|
|
1921
|
+
const shippingAddress = {
|
|
1922
|
+
first_name: shippingPayload.firstName,
|
|
1923
|
+
last_name: shippingPayload.lastName,
|
|
1924
|
+
address_line_1: shippingPayload.streetAddress1,
|
|
1925
|
+
city: shippingPayload.city,
|
|
1926
|
+
state: shippingPayload.countryArea,
|
|
1927
|
+
postal_code: shippingPayload.postalCode,
|
|
1928
|
+
country: shippingPayload.country,
|
|
1929
|
+
};
|
|
1930
|
+
|
|
1931
|
+
gtmAddShippingInfo(
|
|
1932
|
+
products,
|
|
1933
|
+
"USD",
|
|
1934
|
+
totalValue,
|
|
1935
|
+
undefined,
|
|
1936
|
+
undefined,
|
|
1937
|
+
shippingAddress,
|
|
1938
|
+
gtmConfig?.container_id
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
// Only fetch delivery methods if we don't have a delivery error and conditions are met
|
|
1943
|
+
if (shouldFetchMethods && mounted && !hasDeliveryError) {
|
|
1944
|
+
lastAddressHashRef.current = addressHash;
|
|
1945
|
+
// Track that we're attempting this address
|
|
1946
|
+
addressHashAttemptedRef.current.add(addressHash);
|
|
1947
|
+
// use returned methods; don't rely on state right away
|
|
1948
|
+
await fetchShippingMethods(checkoutId);
|
|
1949
|
+
}
|
|
1950
|
+
} catch (e) {
|
|
1951
|
+
if (mounted) {
|
|
1952
|
+
setIsCalculatingTax(false);
|
|
1953
|
+
const msg =
|
|
1954
|
+
e instanceof Error ? e.message : "Failed to sync shipping address";
|
|
1955
|
+
setShippingError(msg);
|
|
1956
|
+
// Mark this address hash as failed to prevent immediate retry
|
|
1957
|
+
lastAddressHashRef.current = addressHash;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
};
|
|
1961
|
+
|
|
1962
|
+
// Allow effect to run for tax calculation even if delivery methods can't be shown
|
|
1963
|
+
if (canShowDeliveryMethods || (shippingPayload && checkoutId)) {
|
|
1964
|
+
const timer = setTimeout(() => {
|
|
1965
|
+
if (mounted) void syncShippingAndDelivery();
|
|
1966
|
+
}, 150); // Reduced timeout for faster tax calculation
|
|
1967
|
+
return () => {
|
|
1968
|
+
clearTimeout(timer);
|
|
1969
|
+
mounted = false;
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
return () => {
|
|
1973
|
+
mounted = false;
|
|
1974
|
+
};
|
|
1975
|
+
}, [
|
|
1976
|
+
isClient,
|
|
1977
|
+
checkoutId,
|
|
1978
|
+
canShowDeliveryMethods,
|
|
1979
|
+
shippingPayload,
|
|
1980
|
+
// Removed problematic dependencies that cause loops:
|
|
1981
|
+
// - shippingMethods.length (causes retrigger when methods load)
|
|
1982
|
+
// - shippingError/shippingLoading (change frequently)
|
|
1983
|
+
// - function dependencies (recreated every render)
|
|
1984
|
+
]);
|
|
1985
|
+
|
|
1986
|
+
// Separate billing sync effect
|
|
1987
|
+
useEffect(() => {
|
|
1988
|
+
(async () => {
|
|
1989
|
+
if (!isClient || !checkoutId) return;
|
|
1990
|
+
if (
|
|
1991
|
+
billingPayload &&
|
|
1992
|
+
!shallowEq(billingPayload, lastBillingRef.current)
|
|
1993
|
+
) {
|
|
1994
|
+
// Validate required fields before attempting sync
|
|
1995
|
+
const requiredFields = [
|
|
1996
|
+
"firstName",
|
|
1997
|
+
"lastName",
|
|
1998
|
+
"streetAddress1",
|
|
1999
|
+
"city",
|
|
2000
|
+
"postalCode",
|
|
2001
|
+
"country",
|
|
2002
|
+
];
|
|
2003
|
+
const missingFields = requiredFields.filter(
|
|
2004
|
+
(field) => !billingPayload[field as keyof AddressInputTS]
|
|
2005
|
+
);
|
|
2006
|
+
|
|
2007
|
+
if (missingFields.length > 0) {
|
|
2008
|
+
console.warn(
|
|
2009
|
+
"[Checkout] Billing sync skipped - missing fields:",
|
|
2010
|
+
missingFields.join(", ")
|
|
2011
|
+
);
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
try {
|
|
2016
|
+
await updateBillingAddress(checkoutId, billingPayload);
|
|
2017
|
+
lastBillingRef.current = billingPayload;
|
|
2018
|
+
} catch (e) {
|
|
2019
|
+
const msg =
|
|
2020
|
+
e instanceof Error ? e.message : "Failed to sync billing address";
|
|
2021
|
+
console.error("[Checkout] Billing sync error:", msg);
|
|
2022
|
+
// Don't throw error, just log it to avoid breaking the checkout flow
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
})();
|
|
2026
|
+
}, [isClient, checkoutId, billingPayload, updateBillingAddress]);
|
|
2027
|
+
|
|
2028
|
+
// Clear stale total when user picks another delivery method
|
|
2029
|
+
useEffect(() => {
|
|
2030
|
+
if (selectedShippingId && lastDeliveryRef.current !== selectedShippingId) {
|
|
2031
|
+
setSaleorTotal(null);
|
|
2032
|
+
}
|
|
2033
|
+
}, [selectedShippingId]);
|
|
2034
|
+
|
|
2035
|
+
// Apply delivery method & get total; now robust against async state
|
|
2036
|
+
useEffect(() => {
|
|
2037
|
+
let mounted = true;
|
|
2038
|
+
const applyDeliveryMethod = async () => {
|
|
2039
|
+
if (!checkoutId || !selectedShippingId) return;
|
|
2040
|
+
|
|
2041
|
+
// Don't apply delivery method if will call is selected
|
|
2042
|
+
if (isWillCallSelected) return;
|
|
2043
|
+
|
|
2044
|
+
// Check if method is available in current state first (no fetch needed)
|
|
2045
|
+
let methodStillAvailable = shippingMethods.find(
|
|
2046
|
+
(m) => m.id === selectedShippingId
|
|
2047
|
+
);
|
|
2048
|
+
|
|
2049
|
+
// Only fetch methods if we don't have any or the selected method isn't available
|
|
2050
|
+
if (!methodStillAvailable && shippingMethods.length === 0) {
|
|
2051
|
+
const methods = await ensureShippingMethodsAvailable(checkoutId);
|
|
2052
|
+
methodStillAvailable = methods.find((m) => m.id === selectedShippingId);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
if (!methodStillAvailable) {
|
|
2056
|
+
setShippingError(
|
|
2057
|
+
"The selected shipping method is no longer available. Please select a different method."
|
|
2058
|
+
);
|
|
2059
|
+
setSelectedShippingId(null);
|
|
2060
|
+
setSelectedShippingMethodId(null);
|
|
2061
|
+
setUserHasSelectedDelivery(false);
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
if (lastDeliveryRef.current === selectedShippingId) return;
|
|
2066
|
+
|
|
2067
|
+
// Check if we actually need to do any work
|
|
2068
|
+
const addressNeedsSync =
|
|
2069
|
+
shippingPayload && !shallowEq(shippingPayload, lastShippingRef.current);
|
|
2070
|
+
|
|
2071
|
+
try {
|
|
2072
|
+
// Only show updating state if we're actually updating address
|
|
2073
|
+
if (addressNeedsSync) {
|
|
2074
|
+
setIsUpdatingDelivery(true);
|
|
2075
|
+
}
|
|
2076
|
+
setIsCalculatingTotal(true);
|
|
2077
|
+
|
|
2078
|
+
// Only sync address if it has actually changed
|
|
2079
|
+
if (addressNeedsSync) {
|
|
2080
|
+
await updateShippingAddress(checkoutId, shippingPayload);
|
|
2081
|
+
lastShippingRef.current = shippingPayload;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// Validate that the selected method is still available before calling API
|
|
2085
|
+
const isMethodValid =
|
|
2086
|
+
methodStillAvailable &&
|
|
2087
|
+
shippingMethods.some((m) => m.id === selectedShippingId);
|
|
2088
|
+
if (!isMethodValid) {
|
|
2089
|
+
// If method is no longer valid, fetch fresh methods and validate again
|
|
2090
|
+
console.warn(
|
|
2091
|
+
"Selected shipping method is no longer valid, fetching fresh methods..."
|
|
2092
|
+
);
|
|
2093
|
+
const freshMethods = await ensureShippingMethodsAvailable(checkoutId);
|
|
2094
|
+
const validMethod = freshMethods.find(
|
|
2095
|
+
(m) => m.id === selectedShippingId
|
|
2096
|
+
);
|
|
2097
|
+
|
|
2098
|
+
if (!validMethod) {
|
|
2099
|
+
throw new Error(
|
|
2100
|
+
`Selected shipping method ${selectedShippingId} is no longer available. Please select a different method.`
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
await updateDeliveryMethod(checkoutId, selectedShippingId);
|
|
2106
|
+
lastDeliveryRef.current = selectedShippingId;
|
|
2107
|
+
|
|
2108
|
+
if (totalsAbortRef.current) totalsAbortRef.current.abort();
|
|
2109
|
+
totalsAbortRef.current = new AbortController();
|
|
2110
|
+
|
|
2111
|
+
if (mounted) {
|
|
2112
|
+
const details = await getCheckoutDetails(
|
|
2113
|
+
checkoutId,
|
|
2114
|
+
totalsAbortRef.current.signal
|
|
2115
|
+
);
|
|
2116
|
+
if (mounted && !totalsAbortRef.current.signal.aborted) {
|
|
2117
|
+
setSaleorTotal(details.total);
|
|
2118
|
+
setVoucherInfo(details.voucherInfo);
|
|
2119
|
+
setShippingError(null);
|
|
2120
|
+
// Mark as user selected since delivery method was successfully applied
|
|
2121
|
+
setUserHasSelectedDelivery(true);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
} catch (e) {
|
|
2125
|
+
if (mounted) {
|
|
2126
|
+
let errorMessage = "Failed to set delivery method";
|
|
2127
|
+
if (e instanceof Error) {
|
|
2128
|
+
if (
|
|
2129
|
+
e.message.includes("checkout session") ||
|
|
2130
|
+
e.message.includes("session has expired") ||
|
|
2131
|
+
e.message.includes("Couldn't resolve to a node")
|
|
2132
|
+
) {
|
|
2133
|
+
errorMessage =
|
|
2134
|
+
"Your checkout session has expired. Please refresh the page or restart your checkout to continue.";
|
|
2135
|
+
} else if (e.message.includes("not applicable")) {
|
|
2136
|
+
errorMessage =
|
|
2137
|
+
"This shipping method is not available for your address or items. Please select a different method.";
|
|
2138
|
+
setSelectedShippingId(null);
|
|
2139
|
+
setSelectedShippingMethodId(null);
|
|
2140
|
+
setUserHasSelectedDelivery(false);
|
|
2141
|
+
} else if (
|
|
2142
|
+
e.message.includes("Failed to fetch") ||
|
|
2143
|
+
e.message.includes("NetworkError") ||
|
|
2144
|
+
e.message.includes("timeout")
|
|
2145
|
+
) {
|
|
2146
|
+
errorMessage =
|
|
2147
|
+
"Network issue occurred. Please check your connection and try again.";
|
|
2148
|
+
} else {
|
|
2149
|
+
errorMessage = e.message;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
setShippingError(errorMessage);
|
|
2153
|
+
}
|
|
2154
|
+
} finally {
|
|
2155
|
+
if (mounted) {
|
|
2156
|
+
setIsUpdatingDelivery(false);
|
|
2157
|
+
setIsCalculatingTotal(false);
|
|
2158
|
+
setIsProcessingSelection(false);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
};
|
|
2162
|
+
|
|
2163
|
+
void applyDeliveryMethod();
|
|
2164
|
+
return () => {
|
|
2165
|
+
mounted = false;
|
|
2166
|
+
};
|
|
2167
|
+
}, [
|
|
2168
|
+
checkoutId,
|
|
2169
|
+
selectedShippingId,
|
|
2170
|
+
isWillCallSelected,
|
|
2171
|
+
shippingPayload,
|
|
2172
|
+
ensureShippingMethodsAvailable,
|
|
2173
|
+
updateDeliveryMethod,
|
|
2174
|
+
updateShippingAddress,
|
|
2175
|
+
getCheckoutDetails,
|
|
2176
|
+
setSelectedShippingMethodId,
|
|
2177
|
+
]);
|
|
2178
|
+
|
|
2179
|
+
// Product restriction validation effect
|
|
2180
|
+
useEffect(() => {
|
|
2181
|
+
const validateRestrictions = async () => {
|
|
2182
|
+
if (!checkoutId || !hasCompleteShippingInfo) {
|
|
2183
|
+
setProductRestrictions([]);
|
|
2184
|
+
setHasRestrictionViolations(false);
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
try {
|
|
2189
|
+
const details = await getCheckoutDetails(checkoutId);
|
|
2190
|
+
const restrictions = checkProductRestrictions(
|
|
2191
|
+
details.fullCheckoutData,
|
|
2192
|
+
shippingInfo.state,
|
|
2193
|
+
shippingInfo.zipCode
|
|
2194
|
+
);
|
|
2195
|
+
|
|
2196
|
+
setProductRestrictions(restrictions);
|
|
2197
|
+
setHasRestrictionViolations(restrictions.length > 0);
|
|
2198
|
+
} catch (error) {
|
|
2199
|
+
console.warn("Failed to validate product restrictions:", error);
|
|
2200
|
+
// Don't block checkout on validation errors, just clear restrictions
|
|
2201
|
+
setProductRestrictions([]);
|
|
2202
|
+
setHasRestrictionViolations(false);
|
|
2203
|
+
}
|
|
2204
|
+
};
|
|
2205
|
+
|
|
2206
|
+
validateRestrictions();
|
|
2207
|
+
}, [
|
|
2208
|
+
checkoutId,
|
|
2209
|
+
hasCompleteShippingInfo,
|
|
2210
|
+
shippingInfo.state,
|
|
2211
|
+
shippingInfo.zipCode,
|
|
2212
|
+
getCheckoutDetails,
|
|
2213
|
+
]);
|
|
2214
|
+
|
|
2215
|
+
// Will Call collection points effect
|
|
2216
|
+
useEffect(() => {
|
|
2217
|
+
const willCallEnabled = isWillCallEnabled();
|
|
2218
|
+
|
|
2219
|
+
if (!willCallEnabled || !checkoutId || !hasCompleteShippingInfo) {
|
|
2220
|
+
setCollectionPoints([]);
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
fetchCollectionPoints(checkoutId);
|
|
2225
|
+
}, [
|
|
2226
|
+
checkoutId,
|
|
2227
|
+
hasCompleteShippingInfo,
|
|
2228
|
+
isWillCallEnabled,
|
|
2229
|
+
fetchCollectionPoints,
|
|
2230
|
+
]);
|
|
2231
|
+
|
|
2232
|
+
// Initial checkout details load
|
|
2233
|
+
useEffect(() => {
|
|
2234
|
+
const abortController = new AbortController();
|
|
2235
|
+
totalsAbortRef.current = abortController;
|
|
2236
|
+
|
|
2237
|
+
const loadInitialDetails = async () => {
|
|
2238
|
+
if (!checkoutId) return;
|
|
2239
|
+
try {
|
|
2240
|
+
setIsCalculatingTotal(true);
|
|
2241
|
+
const details = await getCheckoutDetails(
|
|
2242
|
+
checkoutId,
|
|
2243
|
+
abortController.signal
|
|
2244
|
+
);
|
|
2245
|
+
if (!abortController.signal.aborted) {
|
|
2246
|
+
setSaleorTotal(details.total);
|
|
2247
|
+
setVoucherInfo(details.voucherInfo);
|
|
2248
|
+
|
|
2249
|
+
// Sync delivery method state with what's actually in Saleor
|
|
2250
|
+
if (details.deliveryMethod) {
|
|
2251
|
+
setSelectedShippingId(details.deliveryMethod.id);
|
|
2252
|
+
setSelectedShippingMethodId(details.deliveryMethod.id);
|
|
2253
|
+
setUserHasSelectedDelivery(true);
|
|
2254
|
+
|
|
2255
|
+
// Add the method to shipping methods if not already there
|
|
2256
|
+
setShippingMethods((prev) => {
|
|
2257
|
+
const exists = prev.find(
|
|
2258
|
+
(m) => m.id === details.deliveryMethod.id
|
|
2259
|
+
);
|
|
2260
|
+
if (exists) return prev;
|
|
2261
|
+
return [
|
|
2262
|
+
...prev,
|
|
2263
|
+
{
|
|
2264
|
+
id: details.deliveryMethod.id,
|
|
2265
|
+
name: details.deliveryMethod.name,
|
|
2266
|
+
price: {
|
|
2267
|
+
amount: details.deliveryMethod.price?.amount || 0,
|
|
2268
|
+
currency: details.deliveryMethod.price?.currency || "USD",
|
|
2269
|
+
},
|
|
2270
|
+
},
|
|
2271
|
+
];
|
|
2272
|
+
});
|
|
2273
|
+
} else {
|
|
2274
|
+
// Clear any frontend state if Saleor doesn't have a delivery method
|
|
2275
|
+
setSelectedShippingId(null);
|
|
2276
|
+
setSelectedShippingMethodId(null);
|
|
2277
|
+
setUserHasSelectedDelivery(false);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
} catch (e: unknown) {
|
|
2281
|
+
if (e instanceof Error && e.name !== "AbortError") {
|
|
2282
|
+
setSaleorTotal(null);
|
|
2283
|
+
}
|
|
2284
|
+
} finally {
|
|
2285
|
+
if (!abortController.signal.aborted) setIsCalculatingTotal(false);
|
|
2286
|
+
}
|
|
2287
|
+
};
|
|
2288
|
+
|
|
2289
|
+
void loadInitialDetails();
|
|
2290
|
+
return () => {
|
|
2291
|
+
abortController.abort(new Error("Component unmounted"));
|
|
2292
|
+
};
|
|
2293
|
+
}, [checkoutId, getCheckoutDetails, setSelectedShippingMethodId]);
|
|
2294
|
+
|
|
2295
|
+
// Validate before payment — robust against stale state
|
|
2296
|
+
const persistGuestInfoAndValidateShipping = useCallback(
|
|
2297
|
+
async (bypassShippingValidation = false) => {
|
|
2298
|
+
// Validate email for both logged-in and guest users
|
|
2299
|
+
const emailToValidate = isLoggedIn ? user?.email : shippingInfo.email;
|
|
2300
|
+
|
|
2301
|
+
if (!emailToValidate || emailToValidate.trim() === "") {
|
|
2302
|
+
throw new Error("Email address is required to complete your order.");
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
// Basic email format validation
|
|
2306
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
2307
|
+
if (!emailRegex.test(emailToValidate)) {
|
|
2308
|
+
throw new Error("Please enter a valid email address.");
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
if (!isLoggedIn) {
|
|
2312
|
+
useGlobalStore.getState().setGuestEmail(shippingInfo.email);
|
|
2313
|
+
useGlobalStore.getState().setGuestShippingInfo({
|
|
2314
|
+
firstName: shippingInfo.firstName,
|
|
2315
|
+
lastName: shippingInfo.lastName,
|
|
2316
|
+
address: shippingInfo.address,
|
|
2317
|
+
city: shippingInfo.city,
|
|
2318
|
+
state: shippingInfo.state,
|
|
2319
|
+
zipCode: shippingInfo.zipCode,
|
|
2320
|
+
phone: shippingInfo.phone,
|
|
2321
|
+
country: shippingInfo.country,
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
if (!checkoutId) {
|
|
2325
|
+
throw new Error(
|
|
2326
|
+
"Checkout ID is missing. Please refresh the page and try again."
|
|
2327
|
+
);
|
|
2328
|
+
}
|
|
2329
|
+
// Check if either regular shipping or will-call is selected
|
|
2330
|
+
if (!selectedShippingId && !selectedCollectionPointId) {
|
|
2331
|
+
throw createDeliveryMethodError(
|
|
2332
|
+
"Please select a delivery method or pickup location before completing your order."
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// If bypassing shipping validation (for payment redirects), skip the validation
|
|
2337
|
+
if (bypassShippingValidation) {
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// If will-call is selected, skip regular shipping validation
|
|
2342
|
+
if (selectedCollectionPointId) {
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// For payment flow, don't refresh methods if we already have valid ones
|
|
2347
|
+
// This prevents issues where methods become unavailable during payment processing
|
|
2348
|
+
let methods = shippingMethods;
|
|
2349
|
+
let selectedMethod = methods.find((m) => m.id === selectedShippingId);
|
|
2350
|
+
|
|
2351
|
+
// Only fetch fresh methods if we don't have any or the selected method is missing
|
|
2352
|
+
if (methods.length === 0 || !selectedMethod) {
|
|
2353
|
+
try {
|
|
2354
|
+
methods = await ensureShippingMethodsAvailable(checkoutId);
|
|
2355
|
+
selectedMethod = methods.find((m) => m.id === selectedShippingId);
|
|
2356
|
+
|
|
2357
|
+
// If still no methods after fresh fetch, this might be a temporary API issue
|
|
2358
|
+
if (methods.length === 0) {
|
|
2359
|
+
console.error(
|
|
2360
|
+
"No shipping methods available after fresh fetch - this might be a temporary API issue"
|
|
2361
|
+
);
|
|
2362
|
+
|
|
2363
|
+
// For payment flow, we can proceed if the user already had a valid method selected
|
|
2364
|
+
// since they already went through proper validation earlier
|
|
2365
|
+
if (selectedShippingId && shippingMethods.length > 0) {
|
|
2366
|
+
console.warn(
|
|
2367
|
+
"Using previously validated shipping methods for payment completion"
|
|
2368
|
+
);
|
|
2369
|
+
methods = shippingMethods;
|
|
2370
|
+
selectedMethod = methods.find((m) => m.id === selectedShippingId);
|
|
2371
|
+
|
|
2372
|
+
if (selectedMethod) {
|
|
2373
|
+
} else {
|
|
2374
|
+
throw new Error(
|
|
2375
|
+
"No delivery methods are available and cached method is invalid. Please refresh the page and try again."
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
} else {
|
|
2379
|
+
throw new Error(
|
|
2380
|
+
"No delivery methods are available. This may be due to checkout session issues. Please refresh the page and try again."
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
} catch (error) {
|
|
2385
|
+
// If we can't even get shipping methods, the checkout might be expired/invalid
|
|
2386
|
+
if (error instanceof Error) {
|
|
2387
|
+
if (error.message.includes("No delivery methods")) {
|
|
2388
|
+
throw error; // Re-throw our specific error
|
|
2389
|
+
} else if (
|
|
2390
|
+
error.message.includes("session") ||
|
|
2391
|
+
error.message.includes("expired") ||
|
|
2392
|
+
error.message.includes("401") ||
|
|
2393
|
+
error.message.includes("403")
|
|
2394
|
+
) {
|
|
2395
|
+
throw new Error(
|
|
2396
|
+
"Your checkout session has expired. Please refresh the page to start a new checkout session."
|
|
2397
|
+
);
|
|
2398
|
+
} else if (
|
|
2399
|
+
error.message.includes("network") ||
|
|
2400
|
+
error.message.includes("Failed to fetch")
|
|
2401
|
+
) {
|
|
2402
|
+
throw new Error(
|
|
2403
|
+
"Network connection issue during checkout validation. Please check your connection and try again."
|
|
2404
|
+
);
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
throw new Error(
|
|
2408
|
+
"Unable to validate delivery methods during checkout. Please refresh the page and try again."
|
|
2409
|
+
);
|
|
2410
|
+
}
|
|
2411
|
+
} else {
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// If selected method is not available, refresh methods and try again
|
|
2415
|
+
if (!selectedMethod) {
|
|
2416
|
+
try {
|
|
2417
|
+
setIsRecoveringDelivery(true);
|
|
2418
|
+
// Force a fresh fetch of shipping methods
|
|
2419
|
+
setShippingError(null);
|
|
2420
|
+
setShippingMethods([]);
|
|
2421
|
+
lastFetchedAtRef.current = 0;
|
|
2422
|
+
lastAddressHashRef.current = "";
|
|
2423
|
+
|
|
2424
|
+
methods = await fetchShippingMethods(checkoutId);
|
|
2425
|
+
selectedMethod = methods.find((m) => m.id === selectedShippingId);
|
|
2426
|
+
|
|
2427
|
+
if (!selectedMethod) {
|
|
2428
|
+
// Clear the invalid selection and show updated methods
|
|
2429
|
+
setSelectedShippingId(null);
|
|
2430
|
+
setSelectedShippingMethodId(null);
|
|
2431
|
+
setUserHasSelectedDelivery(false);
|
|
2432
|
+
|
|
2433
|
+
if (methods.length === 0) {
|
|
2434
|
+
throw createDeliveryMethodError(
|
|
2435
|
+
"No delivery methods are currently available for your address. Please verify your shipping address or contact support."
|
|
2436
|
+
);
|
|
2437
|
+
} else {
|
|
2438
|
+
throw createDeliveryMethodError(
|
|
2439
|
+
`Your previously selected delivery method is no longer available. Please choose from the ${
|
|
2440
|
+
methods.length
|
|
2441
|
+
} available method${
|
|
2442
|
+
methods.length > 1 ? "s" : ""
|
|
2443
|
+
} below and try again.`
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
} catch (refreshError) {
|
|
2448
|
+
// If refresh also fails, provide helpful guidance
|
|
2449
|
+
if (
|
|
2450
|
+
refreshError instanceof Error &&
|
|
2451
|
+
refreshError.message.includes("No delivery methods")
|
|
2452
|
+
) {
|
|
2453
|
+
throw refreshError; // Re-throw the no methods error
|
|
2454
|
+
} else if (
|
|
2455
|
+
refreshError instanceof Error &&
|
|
2456
|
+
refreshError.message.includes("available method")
|
|
2457
|
+
) {
|
|
2458
|
+
throw refreshError; // Re-throw the selection guidance error
|
|
2459
|
+
}
|
|
2460
|
+
throw createDeliveryMethodError(
|
|
2461
|
+
"The delivery method became unavailable during payment processing. Please select a new delivery method and try again."
|
|
2462
|
+
);
|
|
2463
|
+
} finally {
|
|
2464
|
+
setIsRecoveringDelivery(false);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// Wait briefly if a delivery update is in flight
|
|
2469
|
+
if (isUpdatingDelivery) {
|
|
2470
|
+
await sleep(300);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
// Confirm selection server-side with retry for payment stability (only for regular shipping)
|
|
2474
|
+
if (selectedShippingId) {
|
|
2475
|
+
try {
|
|
2476
|
+
await updateDeliveryMethod(checkoutId, selectedShippingId);
|
|
2477
|
+
|
|
2478
|
+
// Double-check the method was actually set by querying back
|
|
2479
|
+
const verification = await fetch(
|
|
2480
|
+
endpoint,
|
|
2481
|
+
{
|
|
2482
|
+
method: "POST",
|
|
2483
|
+
headers: { "Content-Type": "application/json" },
|
|
2484
|
+
body: JSON.stringify({
|
|
2485
|
+
query: `
|
|
2486
|
+
query VerifyDeliveryMethod($checkoutId: ID!) {
|
|
2487
|
+
checkout(id: $checkoutId) {
|
|
2488
|
+
id
|
|
2489
|
+
deliveryMethod {
|
|
2490
|
+
... on ShippingMethod {
|
|
2491
|
+
id
|
|
2492
|
+
name
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
`,
|
|
2498
|
+
variables: { checkoutId },
|
|
2499
|
+
}),
|
|
2500
|
+
}
|
|
2501
|
+
);
|
|
2502
|
+
|
|
2503
|
+
if (verification.ok) {
|
|
2504
|
+
const verifyData = await verification.json();
|
|
2505
|
+
const setMethod = verifyData.data?.checkout?.deliveryMethod;
|
|
2506
|
+
|
|
2507
|
+
if (!setMethod || setMethod.id !== selectedShippingId) {
|
|
2508
|
+
console.warn("Delivery method verification failed, retrying...");
|
|
2509
|
+
try {
|
|
2510
|
+
// Retry once more
|
|
2511
|
+
await updateDeliveryMethod(checkoutId, selectedShippingId);
|
|
2512
|
+
} catch (retryError) {
|
|
2513
|
+
console.warn(
|
|
2514
|
+
"Retry also failed, but proceeding with payment since method was originally valid:",
|
|
2515
|
+
retryError
|
|
2516
|
+
);
|
|
2517
|
+
// Don't throw - proceed with payment since user originally had valid method
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
} else {
|
|
2521
|
+
console.warn(
|
|
2522
|
+
"Verification request failed, but proceeding with payment since method was originally valid"
|
|
2523
|
+
);
|
|
2524
|
+
// Don't throw - proceed with payment since user originally had valid method
|
|
2525
|
+
}
|
|
2526
|
+
} catch (error) {
|
|
2527
|
+
if (error instanceof Error) {
|
|
2528
|
+
if (error.message.includes("Couldn't resolve to a node")) {
|
|
2529
|
+
// Try to refresh methods one more time before failing
|
|
2530
|
+
try {
|
|
2531
|
+
await handleRetryShippingMethods();
|
|
2532
|
+
setSelectedShippingId(null);
|
|
2533
|
+
setSelectedShippingMethodId(null);
|
|
2534
|
+
setUserHasSelectedDelivery(false);
|
|
2535
|
+
throw createDeliveryMethodError(
|
|
2536
|
+
"The delivery method became invalid during checkout. Fresh delivery options have been loaded - please select one and try again."
|
|
2537
|
+
);
|
|
2538
|
+
} catch (refreshError) {
|
|
2539
|
+
// Clear selection and provide recovery instructions
|
|
2540
|
+
setSelectedShippingId(null);
|
|
2541
|
+
setSelectedShippingMethodId(null);
|
|
2542
|
+
setUserHasSelectedDelivery(false);
|
|
2543
|
+
|
|
2544
|
+
// Provide more specific error based on what failed
|
|
2545
|
+
if (refreshError instanceof Error) {
|
|
2546
|
+
if (
|
|
2547
|
+
refreshError.message.includes("session") ||
|
|
2548
|
+
refreshError.message.includes("expired")
|
|
2549
|
+
) {
|
|
2550
|
+
throw new Error(
|
|
2551
|
+
"Your session expired during checkout. The page will reload automatically to restore your session."
|
|
2552
|
+
);
|
|
2553
|
+
} else if (
|
|
2554
|
+
refreshError.message.includes("network") ||
|
|
2555
|
+
refreshError.message.includes("Failed to fetch")
|
|
2556
|
+
) {
|
|
2557
|
+
throw createDeliveryMethodError(
|
|
2558
|
+
"Network connection issue during checkout. Please check your connection and try selecting a delivery method again."
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
throw createDeliveryMethodError(
|
|
2563
|
+
"The delivery method became unavailable during payment processing. Please select a new delivery method and try again."
|
|
2564
|
+
);
|
|
2565
|
+
}
|
|
2566
|
+
} else if (error.message.includes("not applicable")) {
|
|
2567
|
+
// Clear selection and let user choose again
|
|
2568
|
+
setSelectedShippingId(null);
|
|
2569
|
+
setSelectedShippingMethodId(null);
|
|
2570
|
+
setUserHasSelectedDelivery(false);
|
|
2571
|
+
throw createDeliveryMethodError(
|
|
2572
|
+
"The selected shipping method is not available for your address or items. Please choose a different delivery method and try again."
|
|
2573
|
+
);
|
|
2574
|
+
}
|
|
2575
|
+
throw createDeliveryMethodError(
|
|
2576
|
+
`Unable to confirm delivery method: ${error.message}`
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
throw createDeliveryMethodError(
|
|
2580
|
+
"Failed to confirm delivery method. Please refresh and try again."
|
|
2581
|
+
);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
},
|
|
2585
|
+
[
|
|
2586
|
+
isLoggedIn,
|
|
2587
|
+
shippingInfo,
|
|
2588
|
+
checkoutId,
|
|
2589
|
+
selectedShippingId,
|
|
2590
|
+
selectedCollectionPointId,
|
|
2591
|
+
isUpdatingDelivery,
|
|
2592
|
+
updateDeliveryMethod,
|
|
2593
|
+
ensureShippingMethodsAvailable,
|
|
2594
|
+
]
|
|
2595
|
+
);
|
|
2596
|
+
|
|
2597
|
+
const selectedShipping = useMemo(
|
|
2598
|
+
() => shippingMethods.find((m) => m.id === selectedShippingId) || null,
|
|
2599
|
+
[shippingMethods, selectedShippingId]
|
|
2600
|
+
);
|
|
2601
|
+
|
|
2602
|
+
const grandTotal = useMemo(() => {
|
|
2603
|
+
// If we have Saleor total (includes tax and shipping), use it
|
|
2604
|
+
if (typeof saleorTotal === "number" && saleorTotal > 0) {
|
|
2605
|
+
return saleorTotal;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// Fallback: calculate from store total + shipping (but this won't include tax)
|
|
2609
|
+
return (selectedShipping?.price?.amount || 0) + totalAmount;
|
|
2610
|
+
}, [saleorTotal, totalAmount, selectedShipping]);
|
|
2611
|
+
|
|
2612
|
+
// Field handlers (shipping) with debounced validation and tax calculation
|
|
2613
|
+
// Add debounce timer ref
|
|
2614
|
+
const emailUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
2615
|
+
|
|
2616
|
+
const handleFieldChange = (
|
|
2617
|
+
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
|
2618
|
+
) => {
|
|
2619
|
+
const { name, value } = e.target;
|
|
2620
|
+
setShippingInfo((prev) => ({ ...prev, [name]: value }));
|
|
2621
|
+
|
|
2622
|
+
// Validate email field as user types
|
|
2623
|
+
if (name === "email") {
|
|
2624
|
+
const emailValidationError = validateEmail(value);
|
|
2625
|
+
setEmailError(emailValidationError);
|
|
2626
|
+
|
|
2627
|
+
// Clear previous timeout
|
|
2628
|
+
if (emailUpdateTimeoutRef.current) {
|
|
2629
|
+
clearTimeout(emailUpdateTimeoutRef.current);
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// Update checkout email when valid email is entered (for guest users)
|
|
2633
|
+
if (!emailValidationError && !isLoggedIn && checkoutId && value.trim()) {
|
|
2634
|
+
// Clear previous timeout
|
|
2635
|
+
if (emailUpdateTimeoutRef.current) {
|
|
2636
|
+
clearTimeout(emailUpdateTimeoutRef.current);
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
// Debounce the email update to avoid too many API calls
|
|
2640
|
+
emailUpdateTimeoutRef.current = setTimeout(() => {
|
|
2641
|
+
// Call the function and handle the callback
|
|
2642
|
+
updateCheckoutEmail(checkoutId, value, (errorMessage) => {
|
|
2643
|
+
setEmailError(errorMessage);
|
|
2644
|
+
});
|
|
2645
|
+
}, 1000);
|
|
2646
|
+
} else {
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// Mark auto-selection as complete when guest starts entering address
|
|
2651
|
+
if (!isLoggedIn && !addressAutoSelectionComplete) {
|
|
2652
|
+
setAddressAutoSelectionComplete(true);
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
// Clear any existing shipping errors and tax info when user changes address fields
|
|
2656
|
+
// This allows the delivery method and tax calculation APIs to be called with new address
|
|
2657
|
+
if (
|
|
2658
|
+
[
|
|
2659
|
+
"firstName",
|
|
2660
|
+
"lastName",
|
|
2661
|
+
"address",
|
|
2662
|
+
"city",
|
|
2663
|
+
"zipCode",
|
|
2664
|
+
"state",
|
|
2665
|
+
"country",
|
|
2666
|
+
].includes(name)
|
|
2667
|
+
) {
|
|
2668
|
+
// Clear local pickup selection when address changes (since pickup locations are tied to address)
|
|
2669
|
+
if (isWillCallSelected) {
|
|
2670
|
+
setIsWillCallSelected(false);
|
|
2671
|
+
setSelectedCollectionPointId(null);
|
|
2672
|
+
setWillCallError(null);
|
|
2673
|
+
// Clear any will call related shipping errors
|
|
2674
|
+
if (
|
|
2675
|
+
shippingError &&
|
|
2676
|
+
(shippingError.includes("click and collect") ||
|
|
2677
|
+
shippingError.includes("warehouse address"))
|
|
2678
|
+
) {
|
|
2679
|
+
setShippingError(null);
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
// Clear tax info on address changes to trigger recalculation
|
|
2684
|
+
setTaxInfo(null);
|
|
2685
|
+
setIsCalculatingTax(false);
|
|
2686
|
+
|
|
2687
|
+
// Reset address tracking to force fresh API calls
|
|
2688
|
+
lastAddressHashRef.current = "";
|
|
2689
|
+
lastFetchedAtRef.current = 0;
|
|
2690
|
+
fetchingMethodsRef.current = false;
|
|
2691
|
+
|
|
2692
|
+
// If there's a delivery method or address validation error, clear it and allow new API call
|
|
2693
|
+
if (
|
|
2694
|
+
shippingError &&
|
|
2695
|
+
(shippingError.includes("No delivery methods found") ||
|
|
2696
|
+
shippingError.includes("not valid for the address") ||
|
|
2697
|
+
shippingError.includes("Delivery Method Error"))
|
|
2698
|
+
) {
|
|
2699
|
+
setShippingError(null);
|
|
2700
|
+
// Clear existing methods to trigger fresh fetch
|
|
2701
|
+
setShippingMethods([]);
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// Also clear postal code validation errors when user changes any address fields
|
|
2705
|
+
if (shippingError && shippingError.includes("postal code")) {
|
|
2706
|
+
setShippingError(null);
|
|
2707
|
+
lastAddressHashRef.current = "";
|
|
2708
|
+
lastFetchedAtRef.current = 0;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// Clear any pending postal code validation
|
|
2712
|
+
if (validationTimeoutRef.current) {
|
|
2713
|
+
clearTimeout(validationTimeoutRef.current);
|
|
2714
|
+
validationTimeoutRef.current = null;
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// When user changes state/country, trigger a re-validation after form updates
|
|
2718
|
+
if (name === "state" || name === "country") {
|
|
2719
|
+
// Set a flag or trigger re-validation via a separate effect
|
|
2720
|
+
setTimeout(() => {
|
|
2721
|
+
const event = new CustomEvent("revalidatePostalCode", {
|
|
2722
|
+
detail: { changedField: name, newValue: value },
|
|
2723
|
+
});
|
|
2724
|
+
window.dispatchEvent(event);
|
|
2725
|
+
}, 100);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
// Debounce validation for postal code to prevent race conditions
|
|
2730
|
+
if (name === "zipCode") {
|
|
2731
|
+
// Set new validation timeout - but only if no delivery method API is running
|
|
2732
|
+
validationTimeoutRef.current = setTimeout(() => {
|
|
2733
|
+
// Only validate if not currently loading delivery methods
|
|
2734
|
+
if (!shippingLoading && !isUpdatingDelivery) {
|
|
2735
|
+
const isValid = isValidPostalCode(value, shippingInfo.country);
|
|
2736
|
+
if (!isValid && value.length >= 3) {
|
|
2737
|
+
setShippingError(
|
|
2738
|
+
"Please enter a valid postal code for the selected country."
|
|
2739
|
+
);
|
|
2740
|
+
} else if (
|
|
2741
|
+
isValid &&
|
|
2742
|
+
shippingError &&
|
|
2743
|
+
shippingError.includes("postal code")
|
|
2744
|
+
) {
|
|
2745
|
+
// Clear postal code error when it becomes valid
|
|
2746
|
+
setShippingError(null);
|
|
2747
|
+
// Reset to allow delivery methods to be fetched with valid postal code
|
|
2748
|
+
lastAddressHashRef.current = "";
|
|
2749
|
+
lastFetchedAtRef.current = 0;
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
}, 1500); // Increased debounce to allow delivery API calls to complete first
|
|
2753
|
+
}
|
|
2754
|
+
};
|
|
2755
|
+
|
|
2756
|
+
// Billing handlers
|
|
2757
|
+
const handleBillingFieldChange = (
|
|
2758
|
+
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
|
2759
|
+
) => {
|
|
2760
|
+
const { name, value } = e.target;
|
|
2761
|
+
setBillingInfo((prev) => ({ ...prev, [name]: value }));
|
|
2762
|
+
};
|
|
2763
|
+
|
|
2764
|
+
const handleUseShippingAsBillingChange = (checked: boolean) => {
|
|
2765
|
+
setUseShippingAsBilling(checked);
|
|
2766
|
+
if (checked) {
|
|
2767
|
+
setBillingInfo({
|
|
2768
|
+
...shippingInfo,
|
|
2769
|
+
email: shippingInfo.email,
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
};
|
|
2773
|
+
|
|
2774
|
+
const handlePlaceOrder = async () => {
|
|
2775
|
+
try {
|
|
2776
|
+
if (DEBUG_HALT_AFTER_PAYMENT) return;
|
|
2777
|
+
const billingAddress = useShippingAsBilling
|
|
2778
|
+
? buildAddressFromForm(shippingInfo)
|
|
2779
|
+
: buildAddressFromForm(billingInfo);
|
|
2780
|
+
if (checkoutId) {
|
|
2781
|
+
await updateBillingAddress(checkoutId, billingAddress);
|
|
2782
|
+
await updateShippingAddress(
|
|
2783
|
+
checkoutId,
|
|
2784
|
+
buildAddressFromForm(shippingInfo)
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
2787
|
+
window.location.href = `/order-confirmation${
|
|
2788
|
+
checkoutId ? `?checkoutId=${encodeURIComponent(checkoutId)}` : ""
|
|
2789
|
+
}`;
|
|
2790
|
+
} catch (e) {
|
|
2791
|
+
console.error("Error during checkout:", e);
|
|
2792
|
+
}
|
|
2793
|
+
};
|
|
2794
|
+
|
|
2795
|
+
const payAmount = useMemo(() => {
|
|
2796
|
+
if (typeof saleorTotal === "number" && saleorTotal > 0) return saleorTotal;
|
|
2797
|
+
if (selectedShipping && totalAmount > 0) {
|
|
2798
|
+
const shippingCost = selectedShipping.price?.amount || 0;
|
|
2799
|
+
return totalAmount + shippingCost;
|
|
2800
|
+
}
|
|
2801
|
+
if (selectedShippingId && shippingMethods.length > 0 && totalAmount > 0) {
|
|
2802
|
+
const foundMethod = shippingMethods.find(
|
|
2803
|
+
(m) => m.id === selectedShippingId
|
|
2804
|
+
);
|
|
2805
|
+
if (foundMethod) return totalAmount + (foundMethod.price?.amount || 0);
|
|
2806
|
+
}
|
|
2807
|
+
return null;
|
|
2808
|
+
}, [
|
|
2809
|
+
saleorTotal,
|
|
2810
|
+
selectedShipping,
|
|
2811
|
+
totalAmount,
|
|
2812
|
+
selectedShippingId,
|
|
2813
|
+
shippingMethods,
|
|
2814
|
+
]);
|
|
2815
|
+
|
|
2816
|
+
// Re-validate postal code when state/country changes
|
|
2817
|
+
useEffect(() => {
|
|
2818
|
+
const handleRevalidation = (event: CustomEvent) => {
|
|
2819
|
+
const {} = event.detail;
|
|
2820
|
+
|
|
2821
|
+
if (shippingInfo.zipCode && shippingInfo.zipCode.length >= 3) {
|
|
2822
|
+
const isValid = isValidPostalCode(
|
|
2823
|
+
shippingInfo.zipCode,
|
|
2824
|
+
shippingInfo.country
|
|
2825
|
+
);
|
|
2826
|
+
|
|
2827
|
+
if (
|
|
2828
|
+
isValid &&
|
|
2829
|
+
shippingError &&
|
|
2830
|
+
(shippingError.includes("postal code") ||
|
|
2831
|
+
shippingError.includes("not valid for the address") ||
|
|
2832
|
+
shippingError.includes("Delivery Method Error"))
|
|
2833
|
+
) {
|
|
2834
|
+
setShippingError(null);
|
|
2835
|
+
lastAddressHashRef.current = "";
|
|
2836
|
+
lastFetchedAtRef.current = 0;
|
|
2837
|
+
fetchingMethodsRef.current = false;
|
|
2838
|
+
// Clear methods to trigger fresh fetch with corrected address
|
|
2839
|
+
setShippingMethods([]);
|
|
2840
|
+
} else if (!isValid) {
|
|
2841
|
+
setShippingError(
|
|
2842
|
+
"Please enter a valid postal code for the selected country."
|
|
2843
|
+
);
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
};
|
|
2847
|
+
|
|
2848
|
+
window.addEventListener(
|
|
2849
|
+
"revalidatePostalCode",
|
|
2850
|
+
handleRevalidation as EventListener
|
|
2851
|
+
);
|
|
2852
|
+
|
|
2853
|
+
return () => {
|
|
2854
|
+
window.removeEventListener(
|
|
2855
|
+
"revalidatePostalCode",
|
|
2856
|
+
handleRevalidation as EventListener
|
|
2857
|
+
);
|
|
2858
|
+
};
|
|
2859
|
+
}, [
|
|
2860
|
+
shippingInfo.zipCode,
|
|
2861
|
+
shippingInfo.country,
|
|
2862
|
+
shippingError,
|
|
2863
|
+
isValidPostalCode,
|
|
2864
|
+
]);
|
|
2865
|
+
|
|
2866
|
+
// Cleanup on unmount
|
|
2867
|
+
useEffect(() => {
|
|
2868
|
+
return () => {
|
|
2869
|
+
if (totalsAbortRef.current) totalsAbortRef.current.abort();
|
|
2870
|
+
if (validationTimeoutRef.current) {
|
|
2871
|
+
clearTimeout(validationTimeoutRef.current);
|
|
2872
|
+
}
|
|
2873
|
+
};
|
|
2874
|
+
}, []);
|
|
2875
|
+
|
|
2876
|
+
// Memoize the payment ready callback to prevent infinite loops
|
|
2877
|
+
const handlePaymentReady = useCallback((trigger: () => Promise<void>) => {
|
|
2878
|
+
setPaymentTriggerFn({ fn: trigger });
|
|
2879
|
+
}, []);
|
|
2880
|
+
|
|
2881
|
+
if (!isClient) return <LoadingUI className="h-[80vh]" />;
|
|
2882
|
+
if (items.length === 0 && !checkoutId)
|
|
2883
|
+
return (
|
|
2884
|
+
<EmptyState
|
|
2885
|
+
className="h-[80vh]"
|
|
2886
|
+
text="Your cart is empty"
|
|
2887
|
+
buttonLabel="Continue Shopping"
|
|
2888
|
+
onClick={() => route.push("/")}
|
|
2889
|
+
/>
|
|
2890
|
+
);
|
|
2891
|
+
|
|
2892
|
+
return (
|
|
2893
|
+
<div className="px-4 md:px-6 md:py-8 py-6 lg:max-w-7xl mx-auto lg:py-10">
|
|
2894
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-14">
|
|
2895
|
+
<div className="lg:col-span-2 space-y-4 lg:border-r lg:border-[var(--color-secondary-200)] lg:pr-14">
|
|
2896
|
+
<CheckoutHeader isLoggedIn={isLoggedIn} />
|
|
2897
|
+
|
|
2898
|
+
<ContactDetailsSection
|
|
2899
|
+
isLoggedIn={isLoggedIn}
|
|
2900
|
+
userEmail={user?.email}
|
|
2901
|
+
guestEmail={shippingInfo.email}
|
|
2902
|
+
onEmailChange={handleFieldChange}
|
|
2903
|
+
emailError={emailError}
|
|
2904
|
+
/>
|
|
2905
|
+
|
|
2906
|
+
<DealerShippingSection
|
|
2907
|
+
isShipToDealer={isShipToDealer}
|
|
2908
|
+
onShippingTypeChange={setIsShipToDealer}
|
|
2909
|
+
selectedDealer={selectedDealer}
|
|
2910
|
+
onDealerSelect={setSelectedDealer}
|
|
2911
|
+
/>
|
|
2912
|
+
|
|
2913
|
+
{!isShipToDealer && (
|
|
2914
|
+
<AddressManagement
|
|
2915
|
+
isLoggedIn={isLoggedIn}
|
|
2916
|
+
shippingInfo={shippingInfo}
|
|
2917
|
+
billingInfo={billingInfo}
|
|
2918
|
+
useShippingAsBilling={useShippingAsBilling}
|
|
2919
|
+
onShippingChange={handleFieldChange}
|
|
2920
|
+
onBillingChange={handleBillingFieldChange}
|
|
2921
|
+
onUseShippingAsBillingChange={handleUseShippingAsBillingChange}
|
|
2922
|
+
onShippingPhoneChange={(phone) =>
|
|
2923
|
+
setShippingInfo((f) => ({
|
|
2924
|
+
...f,
|
|
2925
|
+
phone,
|
|
2926
|
+
}))
|
|
2927
|
+
}
|
|
2928
|
+
onBillingPhoneChange={(phone) => {
|
|
2929
|
+
setBillingInfo((prev) => ({
|
|
2930
|
+
...prev,
|
|
2931
|
+
phone,
|
|
2932
|
+
}));
|
|
2933
|
+
}}
|
|
2934
|
+
meData={meData}
|
|
2935
|
+
formData={formData}
|
|
2936
|
+
setFormData={setFormData}
|
|
2937
|
+
selectedAddressId={selectedAddressId}
|
|
2938
|
+
setSelectedAddressId={(id) => {
|
|
2939
|
+
setSelectedAddressId(id);
|
|
2940
|
+
setAddressAutoSelectionComplete(true); // Mark as complete when manually selected
|
|
2941
|
+
|
|
2942
|
+
// Clear tax info when address selection changes
|
|
2943
|
+
setTaxInfo(null);
|
|
2944
|
+
setIsCalculatingTax(false);
|
|
2945
|
+
|
|
2946
|
+
// Clear delivery methods error when user selects different address
|
|
2947
|
+
if (
|
|
2948
|
+
shippingError &&
|
|
2949
|
+
shippingError.includes("No delivery methods found")
|
|
2950
|
+
) {
|
|
2951
|
+
setShippingError(null);
|
|
2952
|
+
lastAddressHashRef.current = "";
|
|
2953
|
+
lastFetchedAtRef.current = 0;
|
|
2954
|
+
fetchingMethodsRef.current = false;
|
|
2955
|
+
setShippingMethods([]);
|
|
2956
|
+
}
|
|
2957
|
+
}}
|
|
2958
|
+
selectedBillingAddressId={selectedBillingAddressId}
|
|
2959
|
+
setSelectedBillingAddressId={setSelectedBillingAddressId}
|
|
2960
|
+
onAddressAdded={async () => {
|
|
2961
|
+
await refetchMe();
|
|
2962
|
+
}}
|
|
2963
|
+
onSetDefaultAddress={handleSetDefaultAddress}
|
|
2964
|
+
/>
|
|
2965
|
+
)}
|
|
2966
|
+
|
|
2967
|
+
<AddressInformationSection />
|
|
2968
|
+
|
|
2969
|
+
{/* Product Restriction Messages */}
|
|
2970
|
+
{productRestrictions.length > 0 && (
|
|
2971
|
+
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
|
2972
|
+
<div className="flex items-start gap-3">
|
|
2973
|
+
<div className="flex-shrink-0 mt-0.5">
|
|
2974
|
+
<svg
|
|
2975
|
+
className="w-5 h-5 text-orange-600"
|
|
2976
|
+
fill="currentColor"
|
|
2977
|
+
viewBox="0 0 20 20"
|
|
2978
|
+
>
|
|
2979
|
+
<path
|
|
2980
|
+
fillRule="evenodd"
|
|
2981
|
+
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
|
2982
|
+
clipRule="evenodd"
|
|
2983
|
+
/>
|
|
2984
|
+
</svg>
|
|
2985
|
+
</div>
|
|
2986
|
+
<div className="flex-1">
|
|
2987
|
+
<h3 className="text-sm font-semibold text-orange-800 mb-3 uppercase tracking-wide">
|
|
2988
|
+
Shipping Restriction
|
|
2989
|
+
{productRestrictions.length > 1 ? "s" : ""} Detected
|
|
2990
|
+
</h3>
|
|
2991
|
+
<div className="space-y-4">
|
|
2992
|
+
{productRestrictions.map((restriction, index) => (
|
|
2993
|
+
<div
|
|
2994
|
+
key={index}
|
|
2995
|
+
className="bg-white border border-orange-100 rounded p-3"
|
|
2996
|
+
>
|
|
2997
|
+
<p className="font-medium text-orange-900 text-sm">
|
|
2998
|
+
{restriction.productName}
|
|
2999
|
+
</p>
|
|
3000
|
+
<p className="text-orange-700 text-sm mt-1">
|
|
3001
|
+
{restriction.checkoutMessage}
|
|
3002
|
+
</p>
|
|
3003
|
+
</div>
|
|
3004
|
+
))}
|
|
3005
|
+
</div>
|
|
3006
|
+
<div className="mt-4 p-3 bg-orange-100 rounded border border-orange-200">
|
|
3007
|
+
<p className="text-sm text-orange-800 font-medium">
|
|
3008
|
+
Please update your shipping address or remove the
|
|
3009
|
+
restricted item(s) to continue.
|
|
3010
|
+
</p>
|
|
3011
|
+
</div>
|
|
3012
|
+
</div>
|
|
3013
|
+
</div>
|
|
3014
|
+
</div>
|
|
3015
|
+
)}
|
|
3016
|
+
|
|
3017
|
+
{!hasRestrictionViolations && (
|
|
3018
|
+
<>
|
|
3019
|
+
<DeliveryMethodSection
|
|
3020
|
+
checkoutId={checkoutId}
|
|
3021
|
+
canShowDeliveryMethods={!!canShowDeliveryMethods}
|
|
3022
|
+
hasCompleteShippingInfo={!!hasCompleteShippingInfo}
|
|
3023
|
+
missingForDelivery={missingForDelivery}
|
|
3024
|
+
shippingLoading={shippingLoading}
|
|
3025
|
+
shippingMethods={shippingMethods}
|
|
3026
|
+
shippingError={shippingError}
|
|
3027
|
+
selectedShippingId={selectedShippingId}
|
|
3028
|
+
isUpdatingDelivery={isUpdatingDelivery || isRecoveringDelivery}
|
|
3029
|
+
isProcessingSelection={isProcessingSelection}
|
|
3030
|
+
isWillCallSelected={isWillCallSelected}
|
|
3031
|
+
onShippingMethodSelect={async (methodId) => {
|
|
3032
|
+
setIsProcessingSelection(true);
|
|
3033
|
+
try {
|
|
3034
|
+
// If switching from will call, check if methods need refresh
|
|
3035
|
+
if (isWillCallSelected && checkoutId) {
|
|
3036
|
+
try {
|
|
3037
|
+
// First check if the method exists in current methods
|
|
3038
|
+
const existingMethod = shippingMethods.find(
|
|
3039
|
+
(m) => m.id === methodId
|
|
3040
|
+
);
|
|
3041
|
+
|
|
3042
|
+
if (!existingMethod || shippingMethods.length === 0) {
|
|
3043
|
+
// Only fetch fresh methods if needed
|
|
3044
|
+
setShippingLoading(true);
|
|
3045
|
+
setShippingError(null);
|
|
3046
|
+
lastDeliveryRef.current = null;
|
|
3047
|
+
|
|
3048
|
+
// Fetch fresh shipping methods without clearing existing ones
|
|
3049
|
+
const freshMethods = await fetchShippingMethods(
|
|
3050
|
+
checkoutId
|
|
3051
|
+
);
|
|
3052
|
+
|
|
3053
|
+
// Validate that the selected method exists in fresh methods
|
|
3054
|
+
const validMethod = freshMethods.find(
|
|
3055
|
+
(m) => m.id === methodId
|
|
3056
|
+
);
|
|
3057
|
+
if (!validMethod) {
|
|
3058
|
+
setShippingError(
|
|
3059
|
+
"The selected shipping method is no longer available. Please select a different method."
|
|
3060
|
+
);
|
|
3061
|
+
setIsProcessingSelection(false);
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
} catch (error) {
|
|
3066
|
+
console.error(
|
|
3067
|
+
"Failed to fetch fresh shipping methods:",
|
|
3068
|
+
error
|
|
3069
|
+
);
|
|
3070
|
+
setShippingError(
|
|
3071
|
+
"Failed to load shipping methods. Please try again."
|
|
3072
|
+
);
|
|
3073
|
+
setIsProcessingSelection(false);
|
|
3074
|
+
return;
|
|
3075
|
+
} finally {
|
|
3076
|
+
setShippingLoading(false);
|
|
3077
|
+
}
|
|
3078
|
+
} else {
|
|
3079
|
+
// Even if not switching from will call, validate method exists
|
|
3080
|
+
const validMethod = shippingMethods.find(
|
|
3081
|
+
(m) => m.id === methodId
|
|
3082
|
+
);
|
|
3083
|
+
if (!validMethod) {
|
|
3084
|
+
setShippingError(
|
|
3085
|
+
"The selected shipping method is no longer available. Please refresh the page and select a different method."
|
|
3086
|
+
);
|
|
3087
|
+
setIsProcessingSelection(false);
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
// Batch state updates to reduce re-renders
|
|
3093
|
+
setSelectedShippingId(methodId);
|
|
3094
|
+
setSelectedShippingMethodId(methodId);
|
|
3095
|
+
setUserHasSelectedDelivery(true);
|
|
3096
|
+
// Clear will call selection when regular shipping is selected
|
|
3097
|
+
setIsWillCallSelected(false);
|
|
3098
|
+
setSelectedCollectionPointId(null);
|
|
3099
|
+
// Clear any will call related shipping errors
|
|
3100
|
+
if (
|
|
3101
|
+
shippingError &&
|
|
3102
|
+
(shippingError.includes("click and collect") ||
|
|
3103
|
+
shippingError.includes("warehouse address"))
|
|
3104
|
+
) {
|
|
3105
|
+
setShippingError(null);
|
|
3106
|
+
}
|
|
3107
|
+
} finally {
|
|
3108
|
+
// Always reset processing state when done
|
|
3109
|
+
setIsProcessingSelection(false);
|
|
3110
|
+
}
|
|
3111
|
+
}}
|
|
3112
|
+
onRetryShippingMethods={handleRetryShippingMethods}
|
|
3113
|
+
/>
|
|
3114
|
+
|
|
3115
|
+
<WillCallSection
|
|
3116
|
+
checkoutId={checkoutId}
|
|
3117
|
+
willCallEnabled={isWillCallEnabled()}
|
|
3118
|
+
collectionPoints={collectionPoints}
|
|
3119
|
+
selectedCollectionPointId={selectedCollectionPointId}
|
|
3120
|
+
isUpdatingDelivery={isUpdatingDelivery || isRecoveringDelivery}
|
|
3121
|
+
isProcessingSelection={isProcessingSelection}
|
|
3122
|
+
onCollectionPointSelect={handleCollectionPointSelect}
|
|
3123
|
+
userState={shippingInfo.state}
|
|
3124
|
+
willCallLoading={willCallLoading}
|
|
3125
|
+
willCallError={willCallError}
|
|
3126
|
+
/>
|
|
3127
|
+
</>
|
|
3128
|
+
)}
|
|
3129
|
+
|
|
3130
|
+
{checkoutId &&
|
|
3131
|
+
payAmount &&
|
|
3132
|
+
!isCalculatingTotal &&
|
|
3133
|
+
((selectedShippingId && userHasSelectedDelivery) ||
|
|
3134
|
+
(isWillCallSelected && selectedCollectionPointId)) &&
|
|
3135
|
+
!hasRestrictionViolations && (
|
|
3136
|
+
<CheckoutQuestions
|
|
3137
|
+
isLoggedIn={isLoggedIn}
|
|
3138
|
+
grandTotal={grandTotal}
|
|
3139
|
+
checkoutId={checkoutId}
|
|
3140
|
+
onQuestionsChange={setCheckoutQuestionAnswers}
|
|
3141
|
+
onValidationChange={setAreCheckoutQuestionsValid}
|
|
3142
|
+
onSaveQuestions={(saveFn) =>
|
|
3143
|
+
setSaveCheckoutQuestions(() => saveFn)
|
|
3144
|
+
}
|
|
3145
|
+
/>
|
|
3146
|
+
)}
|
|
3147
|
+
|
|
3148
|
+
{checkoutId && (
|
|
3149
|
+
<PaymentStep
|
|
3150
|
+
onBack={() => {}}
|
|
3151
|
+
onComplete={handlePlaceOrder}
|
|
3152
|
+
totalAmount={payAmount || 0}
|
|
3153
|
+
checkoutId={checkoutId}
|
|
3154
|
+
availablePaymentGateways={
|
|
3155
|
+
paymentGatewaysData?.checkout?.availablePaymentGateways
|
|
3156
|
+
}
|
|
3157
|
+
kountConfig={kountConfig}
|
|
3158
|
+
taxInfo={taxInfo}
|
|
3159
|
+
isCalculatingTotal={isCalculatingTotal}
|
|
3160
|
+
disabled={
|
|
3161
|
+
isCalculatingTotal ||
|
|
3162
|
+
hasRestrictionViolations ||
|
|
3163
|
+
!(
|
|
3164
|
+
(selectedShippingId && userHasSelectedDelivery) ||
|
|
3165
|
+
(isWillCallSelected && selectedCollectionPointId)
|
|
3166
|
+
)
|
|
3167
|
+
}
|
|
3168
|
+
onPaymentReady={handlePaymentReady}
|
|
3169
|
+
onStartPayment={async () => {
|
|
3170
|
+
// Validate terms and conditions if required
|
|
3171
|
+
if (termsData?.page?.isPublished && !termsAccepted) {
|
|
3172
|
+
throw new Error(
|
|
3173
|
+
"Please accept the Terms and Conditions to continue."
|
|
3174
|
+
);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
await persistGuestInfoAndValidateShipping(true);
|
|
3178
|
+
// Save checkout questions when payment is initiated
|
|
3179
|
+
if (
|
|
3180
|
+
saveCheckoutQuestions &&
|
|
3181
|
+
typeof saveCheckoutQuestions === "function"
|
|
3182
|
+
) {
|
|
3183
|
+
try {
|
|
3184
|
+
await saveCheckoutQuestions();
|
|
3185
|
+
} catch (error) {
|
|
3186
|
+
console.error("Failed to save checkout questions:", error);
|
|
3187
|
+
// Don't throw error to prevent payment from being blocked
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
}}
|
|
3191
|
+
isProcessingPayment={isProcessingPayment}
|
|
3192
|
+
setIsProcessingPayment={setIsProcessingPayment}
|
|
3193
|
+
selectedShippingId={selectedShippingId || undefined}
|
|
3194
|
+
userEmail={user?.email}
|
|
3195
|
+
guestEmail={shippingInfo.email}
|
|
3196
|
+
lineItems={items}
|
|
3197
|
+
questionsValid={areCheckoutQuestionsValid}
|
|
3198
|
+
termsAccepted={termsAccepted}
|
|
3199
|
+
termsData={termsData}
|
|
3200
|
+
onTermsModalOpen={() => setIsTermsModalOpen(true)}
|
|
3201
|
+
onTermsAcceptedChange={setTermsAccepted}
|
|
3202
|
+
billingAddress={(() => {
|
|
3203
|
+
const addressInfo = useShippingAsBilling
|
|
3204
|
+
? shippingInfo
|
|
3205
|
+
: billingInfo;
|
|
3206
|
+
// Only send billing address if we have minimum required fields
|
|
3207
|
+
if (
|
|
3208
|
+
addressInfo.firstName &&
|
|
3209
|
+
addressInfo.lastName &&
|
|
3210
|
+
addressInfo.address &&
|
|
3211
|
+
addressInfo.city &&
|
|
3212
|
+
addressInfo.zipCode
|
|
3213
|
+
) {
|
|
3214
|
+
return {
|
|
3215
|
+
firstName: addressInfo.firstName || "",
|
|
3216
|
+
lastName: addressInfo.lastName || "",
|
|
3217
|
+
address: addressInfo.address || "",
|
|
3218
|
+
city: addressInfo.city || "",
|
|
3219
|
+
state: addressInfo.state || "",
|
|
3220
|
+
zipCode: addressInfo.zipCode || "",
|
|
3221
|
+
country: addressInfo.country || "US",
|
|
3222
|
+
phone: addressInfo.phone || undefined,
|
|
3223
|
+
};
|
|
3224
|
+
}
|
|
3225
|
+
return undefined;
|
|
3226
|
+
})()}
|
|
3227
|
+
shippingAddress={(() => {
|
|
3228
|
+
// For dealer shipping, use dealer address
|
|
3229
|
+
if (isShipToDealer && selectedDealer) {
|
|
3230
|
+
return {
|
|
3231
|
+
firstName: "Dealer",
|
|
3232
|
+
lastName: "Pickup",
|
|
3233
|
+
address: selectedDealer.address.streetAddress1 || "",
|
|
3234
|
+
city: selectedDealer.address.city || "",
|
|
3235
|
+
state: selectedDealer.address.countryArea || "",
|
|
3236
|
+
zipCode: selectedDealer.address.postalCode || "",
|
|
3237
|
+
country: selectedDealer.address.country?.code || "US",
|
|
3238
|
+
phone: selectedDealer.phone || undefined,
|
|
3239
|
+
dealerName: selectedDealer.name,
|
|
3240
|
+
};
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
// Only send shipping address if we have minimum required fields
|
|
3244
|
+
if (
|
|
3245
|
+
shippingInfo.firstName &&
|
|
3246
|
+
shippingInfo.lastName &&
|
|
3247
|
+
shippingInfo.address &&
|
|
3248
|
+
shippingInfo.city &&
|
|
3249
|
+
shippingInfo.zipCode
|
|
3250
|
+
) {
|
|
3251
|
+
return {
|
|
3252
|
+
firstName: shippingInfo.firstName || "",
|
|
3253
|
+
lastName: shippingInfo.lastName || "",
|
|
3254
|
+
address: shippingInfo.address || "",
|
|
3255
|
+
city: shippingInfo.city || "",
|
|
3256
|
+
state: shippingInfo.state || "",
|
|
3257
|
+
zipCode: shippingInfo.zipCode || "",
|
|
3258
|
+
country: shippingInfo.country || "US",
|
|
3259
|
+
phone: shippingInfo.phone || undefined,
|
|
3260
|
+
};
|
|
3261
|
+
}
|
|
3262
|
+
return undefined;
|
|
3263
|
+
})()}
|
|
3264
|
+
/>
|
|
3265
|
+
)}
|
|
3266
|
+
</div>
|
|
3267
|
+
|
|
3268
|
+
<OrderSummary
|
|
3269
|
+
lineItems={items}
|
|
3270
|
+
totalAmount={totalAmount}
|
|
3271
|
+
selectedShipping={selectedShipping}
|
|
3272
|
+
grandTotal={grandTotal}
|
|
3273
|
+
saleorTotal={saleorTotal}
|
|
3274
|
+
isUpdatingDelivery={isUpdatingDelivery}
|
|
3275
|
+
shippingLoading={shippingLoading}
|
|
3276
|
+
isCalculatingTotal={isCalculatingTotal}
|
|
3277
|
+
taxInfo={taxInfo}
|
|
3278
|
+
isCalculatingTax={isCalculatingTax}
|
|
3279
|
+
voucherInfo={voucherInfo}
|
|
3280
|
+
onApplyVoucher={applyVoucher}
|
|
3281
|
+
onRemoveVoucher={removeVoucher}
|
|
3282
|
+
isApplyingVoucher={isApplyingVoucher}
|
|
3283
|
+
voucherError={voucherError}
|
|
3284
|
+
selectedCollectionPointId={selectedCollectionPointId}
|
|
3285
|
+
onCompletePayment={paymentTriggerFn.fn || undefined}
|
|
3286
|
+
isPaymentProcessing={isProcessingPayment.isModalOpen}
|
|
3287
|
+
paymentDisabled={
|
|
3288
|
+
isCalculatingTotal ||
|
|
3289
|
+
hasRestrictionViolations ||
|
|
3290
|
+
!(
|
|
3291
|
+
(selectedShippingId && userHasSelectedDelivery) ||
|
|
3292
|
+
(isWillCallSelected && selectedCollectionPointId)
|
|
3293
|
+
)
|
|
3294
|
+
}
|
|
3295
|
+
paymentDisabledReason={
|
|
3296
|
+
isCalculatingTotal
|
|
3297
|
+
? "Calculating total..."
|
|
3298
|
+
: hasRestrictionViolations
|
|
3299
|
+
? "Please resolve product restrictions"
|
|
3300
|
+
: "Select a delivery method"
|
|
3301
|
+
}
|
|
3302
|
+
/>
|
|
3303
|
+
</div>
|
|
3304
|
+
|
|
3305
|
+
{/* Terms and Conditions Modal */}
|
|
3306
|
+
<CheckoutTermsModal
|
|
3307
|
+
isModalOpen={isTermsModalOpen}
|
|
3308
|
+
onClose={() => setIsTermsModalOpen(false)}
|
|
3309
|
+
/>
|
|
3310
|
+
</div>
|
|
3311
|
+
);
|
|
3312
|
+
}
|