From 6b17bdb7493d8c0b518662dd522ae2ea5d6a65b5 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 10 Jan 2025 13:23:42 -0600 Subject: [PATCH] feat(core): integrate Soul vibe (#1852) * chore(core): use next canary and enable ppr * fix: use suggested functions over deprecated ones * chore(core): update tailwind config and layout to use Soul config * fix: eslint issues * chore(core): add roboto mono font * chore(core): add @/vibes/* path in tsconfig * feat(core): add FeaturedProductsList component and use in Homepage (#1673) * feat(core): add FeaturedProductsCarousel and use in Homepage (#1675) * feat(core): add best selling products to Hompage (#1676) * chore: bump components to include latest Streamable util (#1681) * chore(core): use next canary and enable ppr * feat: replace catalyst homepage slideshow * refactor: updated slideshow structure based on PR feedback * feat: add translations for homepage slider * fix: slide not in view when element inside is focused (#1687) * chore(core): use next canary and enable ppr * fix: use suggested functions over deprecated ones * chore(core): update tailwind config and layout to use Soul config * fix: eslint issues * chore(core): add roboto mono font * chore(core): add @/vibes/* path in tsconfig * feat(core): add FeaturedProductsList component and use in Homepage (#1673) * feat(core): add FeaturedProductsCarousel and use in Homepage (#1675) * feat(core): add best selling products to Hompage (#1676) * chore: bump components to include latest Streamable util (#1681) * chore(core): use next canary and enable ppr * feat: replace catalyst homepage slideshow * refactor: updated slideshow structure based on PR feedback * feat: add translations for homepage slider * fix: slide not in view when element inside is focused --------- Co-authored-by: Jorge Moya * feat: add header (#1702) * feat: add soul SignInSection * refactor: ErrorMessage -> FieldError * feat: add form status * feat: add error message for sign in * fix: remove nextjs internals for redirect error handling * fix: add back metadata request locale * feat: add footer with streamable sitemap links (#1698) * fix: import Image instead of BcImage (#1719) * fix: form status bg color * feat: update forgot password to soul * refactor: nest lastResult in forgotPassword action * fix: inline recaptcha fragment in ResetPageQuery * feat: remove bestselling products from Home (#1725) * feat(core): add subscribe form to Homepage (#1682) * feat: stream in products for carousel/featured in Home (#1726) * feat: add empty state to FeaturedProductsCarousel (#1731) * chore: run pnpm lint -- --fix (#1736) * fix: bump VIBES components and pass in accessibility props in Home (#1739) * feat: add empty state for FeatureProductsList (#1745) * feat: add logout route handler * feat: add dynamic form primitive * feat: update sign up form to soul * fix: lint issues * fix: register customer required fields * refactor: use zod to parse register customer input * fix: sync dynamic form with VIBES * fix: remove quantity from dynamic form * fix: htmlFor in groups * feat: update Breadcrumbs to use AnimatedLink and new skeleton design (#1754) * feat: update change password to SOUL * feat: update button props and variables * feat: add search functionality to header * fix: change quick search param name to "term" This matches what the search page expects. * chore: bring in latest VIBES changes * feat: use header search translated error message * feat: don't attempt to search if less than 3 chars * chore: bring in latest VIBES changes * feat: better i18n for header search * feat(soul): add account layout components * chore: remove static pages (#1762) * chore: remove edge runtime from pages (#1763) * chore: bring in latet VIBES changes * feat: use Soul in category page * feat: add imagePriority prop to ProductCard (#1767) * fix: move font variables to html element * feat: add props and css variables for slideshow * feat: add props and css variables for carousel * feat: add CSS variables to ProductCard * chore: sync navigation primitive with VIBES * chore: add Makeswift team to CODEOWNERS * feat: add BlogPostContent to blog/[id] page * refactor: move default css variables into Accordion * refactor: move default css variables into Button, ButtonLink * refactor: move default css variables into Carousel * refactor: move default css variables into Slideshow * feat: add props to ProductsCarousel * feat(core): add account settings soul components * feat: add FeaturedBlogPostList to /blog * feat: add variables to Navigation * feat: add variables to StickySidebarLayout * feat: add props to ProductList * feat: add variables to banner/header * feat: use FeaturedBlogPostList in /blog/tag/ route * feat: add footer variables and logo * feat: add localized contact title * feat: add ProductDetails to PDP (#1751) * fix: section max width * feat: add pagination and breadcrumbs to /blog and /blog/tag/[tagId] * feat: refactor ProductListSection breadcrumbs * feat: sync breadcrumbs and implementations with vibes * feat: undo ProductListSection breadcrumb change to ensure it's not displayed for 1 breadcrumb * feat: update pageInfoTransformer * feat: add related products to PDP (#1788) * feat: add reviews to PDP (#1786) * fix: ensure page info transformer returns null if no previous/next page * feat: add product description to PDP (#1790) * feat: Soul ProductsListSection for search results * feat: add support for custom blog url routing, update blog tag filtering, delete /blog/tag/[tagId] route * feat: add empty state to BlogPostList * refactor: move app fonts from the root layout to their own file * feat: update /blog/[blogId] route to use skeleton and streamable props * feat: use Soul in brand page * refactor: move search params cache to category page * refactor: move search params cache to search page * feat: Add NotFound section to 404 page * fix: aria-label and placeholder in Select * fix: pass placeholder prop from ProductsListSection into Sorting * fix: rendering or label and placeholder props * chore: bring latest ProducstListSection from VIBES * feat: add height prop to Logo * feat: add FeaturedProductsCarousel to 404 page * fix: use grid for pdp specifications * fix: reduce gap on pdp * chore: bring in latest changes from Soul VIBE * feat: handle localization for new PLP labels * fix: facets transformer range input mapping * feat: add cart from VIBES * feat: replace cart with VIBES cart * fix: remove empty string in value (#1832) * chore: bring in latest changes from VIBES * feat: show sub-categories * chore: revert nextjs (#1840) * fix: remove regression tests workflow (#1843) * feat: add Error page and ErrorBoundary * feat(orders): update the orders list to use vibes section * chore: remove unused files in facet pages (#1838) * fix: add missing labels to number input (#1855) * chore: upgrade next to 15.1.1-canary.26 Next.js renamed expireTag and expirePath to unstable_ * chore: add changeset (#1853) * chore: remove unused components in /components (#1866) * chore: remove store selector page that is no longer being used (#1868) * chore: remove /cart-quantity route that is no longer being used (#1869) * chore: remove unused components in /blog (#1867) * chore: remove @bigcommerce/makeswift group from codeowners (#1872) * fix: don't pass `{}` to `createSearchParamsCache` * chore: upgrade next to 15.2.0-canary.1 This release contain fixes for 404 revalidation issue with revalidateTag bug. * feat(core): update order details page with VIBES * refactor(core): remove product path fetching in favor of line item path * fix: use lazy promise on getCartCount to defer cookies() execution This fixes 500 error: needs to bail out of prerendering at this point because it used cookies() Without the lazy promise, the getCartCount() is immediately executed, and calls cookies(). With lazy promise, the getCartCount() execution is deferred, until await/then is used. * feat(core): update address list to use VIBES * feat(core): add country list to account addresses * Add down for maintenance page * fix: accessibility issues in PDP (#1877) * Add Web Pages * chore: bring in latest changes from VIBES * fix: add hideOverflow to sections and carousels * fix: use SectionLayout in FeaturedProductCarousel * fix: use SectionLayout in FeaturedBlogList --------- Co-authored-by: Hunter Garrett Co-authored-by: Alan Pledger Co-authored-by: Miguel Oller Co-authored-by: Chancellor Clark Co-authored-by: Miguel Oller Co-authored-by: Daniel Almaguer Co-authored-by: jordanarldt Co-authored-by: Aleksey Gurtovoy Co-authored-by: Fikri Karim --- .changeset/tidy-stingrays-search.md | 13 + .github/workflows/regression-tests.yml | 228 ----- .../_actions/change-password.ts | 66 +- .../_components/change-password-form.tsx | 136 --- .../(default)/(auth)/change-password/page.tsx | 30 +- .../(default)/(auth)/login/_actions/login.ts | 41 +- .../(auth)/login/_components/login-form.tsx | 131 --- .../_actions/reset-password.ts | 59 +- .../reset-password-form/fragment.ts | 8 - .../_components/reset-password-form/index.tsx | 155 ---- .../(auth)/login/forgot-password/page.tsx | 67 +- .../[locale]/(default)/(auth)/login/page.tsx | 57 +- .../register/_actions/register-customer.ts | 213 ++++- .../_components/register-customer-form.tsx | 394 -------- .../(default)/(auth)/register/page-data.ts | 28 +- .../(default)/(auth)/register/page.tsx | 44 +- .../(faceted)/_components/faceted-search.tsx | 33 - .../(faceted)/_components/facets.tsx | 332 ------- .../(faceted)/_components/mobile-side-nav.tsx | 32 - .../(faceted)/_components/refine-by.tsx | 160 ---- .../(faceted)/_components/sort-by.tsx | 52 -- .../(default)/(faceted)/brand/[slug]/page.tsx | 311 +++++-- .../(faceted)/brand/[slug]/static/page.tsx | 55 -- .../[slug]/_components/empty-state.tsx | 22 - .../[slug]/_components/sub-categories.tsx | 53 -- .../(faceted)/category/[slug]/page-data.ts | 21 +- .../(faceted)/category/[slug]/page.tsx | 378 +++++--- .../(faceted)/category/[slug]/static/page.tsx | 64 -- .../(faceted)/fetch-faceted-search.ts | 77 +- .../(default)/(faceted)/search/page.tsx | 340 +++++-- .../app/[locale]/(default)/(faceted)/types.ts | 12 - .../(default)/_components/slideshow/index.tsx | 55 ++ .../_components/slideshow/slide-bg-01.jpg | Bin 0 -> 1069403 bytes .../_components/slideshow/slide-bg-02.jpg | Bin 0 -> 387823 bytes .../_components/slideshow/slide-bg-03.jpg | Bin 0 -> 478254 bytes .../account/_components/tab-heading.tsx | 4 +- .../account/_components/tab-navigation.tsx | 44 - .../addresses/_actions/address-action.ts | 39 + .../addresses/_actions/create-address.ts | 263 ++++++ .../addresses/_actions/delete-address.ts | 81 +- .../addresses/_actions/update-address.ts | 272 ++++++ .../addresses/_components/address-book.tsx | 111 --- .../addresses/add/_actions/add-address.ts | 99 -- .../add/_components/add-address-form.tsx | 320 ------- .../(default)/account/addresses/add/page.tsx | 100 -- .../edit/[slug]/_actions/update-address.ts | 111 --- .../[slug]/_components/edit-address-form.tsx | 407 --------- .../account/addresses/edit/[slug]/page.tsx | 142 --- .../(default)/account/addresses/page-data.ts | 24 +- .../(default)/account/addresses/page.tsx | 94 +- .../app/[locale]/(default)/account/layout.tsx | 20 +- .../[slug]/_components/order-details.tsx | 293 ------ .../(default)/account/order/[slug]/page.tsx | 71 -- .../[slug] => orders/[id]}/page-data.tsx | 98 +- .../(default)/account/orders/[id]/page.tsx | 39 + .../orders/_components/orders-list.tsx | 242 ----- .../orders/_components/product-snippet.tsx | 279 ------ .../(default)/account/orders/fragment.ts | 27 + .../(default)/account/orders/page-data.ts | 23 +- .../(default)/account/orders/page.tsx | 38 +- .../settings/_actions/change-password.ts | 73 ++ .../settings/_actions/update-customer.ts | 103 +-- .../settings/_components/text-field.tsx | 69 -- .../_components/update-settings-form.tsx | 381 -------- .../_actions/change-password.ts | 94 -- .../_components/change-password-form.tsx | 241 ----- .../account/settings/change-password/page.tsx | 32 - .../(default)/account/settings/page-data.tsx | 109 +-- .../(default)/account/settings/page.tsx | 20 +- .../[blogId]/_components/print-button.tsx | 24 - .../[blogId]/_components/sharing-links.tsx | 99 -- .../(default)/blog/[blogId]/page-data.ts | 56 +- .../[locale]/(default)/blog/[blogId]/page.tsx | 114 ++- core/app/[locale]/(default)/blog/page-data.ts | 75 +- core/app/[locale]/(default)/blog/page.tsx | 111 ++- .../(default)/blog/tag/[tagId]/page.tsx | 45 - .../cart/_actions/redirect-to-checkout.ts | 50 +- .../(default)/cart/_actions/remove-item.ts | 17 +- .../cart/_actions/update-line-item.ts | 369 ++++++++ .../update-quantity.ts} | 32 +- .../(default)/cart/_components/cart-item.tsx | 265 ------ .../cart/_components/cart-viewed.tsx | 16 +- .../cart/_components/checkout-button.tsx | 28 - .../cart/_components/checkout-summary.tsx | 108 --- .../coupon-code/apply-coupon-code.ts | 67 -- .../cart/_components/coupon-code/fragment.ts | 16 - .../cart/_components/coupon-code/index.tsx | 151 --- .../coupon-code/remove-coupon-code.ts | 67 -- .../(default)/cart/_components/empty-cart.tsx | 15 - .../cart/_components/item-quantity/index.tsx | 199 ---- .../_components/remove-from-cart-button.tsx | 23 - .../cart/_components/remove-item.tsx | 70 -- .../shipping-estimator/fragment.ts | 49 - .../get-shipping-countries.ts | 41 - .../_components/shipping-estimator/index.tsx | 127 --- .../_components/shipping-info/fragment.ts | 34 - .../cart/_components/shipping-info/index.tsx | 193 ---- .../shipping-info/submit-shipping-info.ts | 132 --- .../_components/shipping-options/fragment.ts | 15 - .../_components/shipping-options/index.tsx | 71 -- .../shipping-options/submit-button.tsx | 20 - .../shipping-options/submit-shipping-costs.ts | 70 -- core/app/[locale]/(default)/cart/loading.tsx | 9 + core/app/[locale]/(default)/cart/page-data.ts | 187 ++++ core/app/[locale]/(default)/cart/page.tsx | 175 ++-- .../(default)/compare/_actions/add-to-cart.ts | 6 +- core/app/[locale]/(default)/compare/page.tsx | 2 - core/app/[locale]/(default)/error.tsx | 16 + core/app/[locale]/(default)/layout.tsx | 19 +- .../[locale]/(default)/logout/route.ts} | 4 +- core/app/[locale]/(default)/page.tsx | 101 +- .../add-to-cart.tsx} | 154 +++- .../[slug]/_components/description.tsx | 28 - .../product/[slug]/_components/details.tsx | 206 ----- .../[slug]/_components/gallery/fragment.ts | 19 - .../[slug]/_components/gallery/index.tsx | 46 - .../fields/checkbox-field/fragment.ts | 13 - .../fields/checkbox-field/index.tsx | 55 -- .../fields/date-field/fragment.ts | 13 - .../product-form/fields/date-field/index.tsx | 70 -- .../fields/multi-line-text-field/fragment.ts | 13 - .../fields/multi-line-text-field/index.tsx | 57 -- .../fields/multiple-choice-field/fragment.ts | 31 - .../fields/multiple-choice-field/index.tsx | 251 ----- .../fields/number-field/fragment.ts | 14 - .../fields/number-field/index.tsx | 64 -- .../product-form/fields/quantity-field.tsx | 29 - .../fields/shared/error-message.tsx | 3 - .../fields/text-field/fragment.ts | 12 - .../product-form/fields/text-field/index.tsx | 56 -- .../_components/product-form/fragment.ts | 51 -- .../[slug]/_components/product-form/index.tsx | 175 ---- .../product-form/use-product-form.ts | 22 - .../product-review-schema/fragment.ts | 15 + .../product-review-schema.tsx | 16 +- .../_components/product-schema/fragment.ts | 41 + .../index.tsx} | 42 +- .../_components/product-viewed/fragment.ts | 27 + .../index.tsx} | 12 +- .../[slug]/_components/related-products.tsx | 61 -- .../[slug]/_components/review-summary.tsx | 51 -- .../product/[slug]/_components/reviews.tsx | 142 +-- .../product/[slug]/_components/warranty.tsx | 28 - .../(default)/product/[slug]/page-data.ts | 248 ++++- .../(default)/product/[slug]/page.tsx | 238 ++++- .../(default)/product/[slug]/static/page.tsx | 100 -- core/app/[locale]/(default)/static/page.tsx | 12 - .../webpages/_components/web-page.tsx | 84 ++ .../(default)/webpages/contact/[id]/page.tsx | 2 - .../(default)/webpages/normal/[id]/page.tsx | 55 +- core/app/[locale]/_components/error.tsx | 16 - core/app/[locale]/error.tsx | 15 +- core/app/[locale]/layout.tsx | 23 +- core/app/[locale]/maintenance/page.tsx | 42 +- core/app/[locale]/not-found.tsx | 59 +- .../_components/locale-link.tsx | 30 - core/app/[locale]/store-selector/page.tsx | 76 -- core/app/api/cart-quantity/route.ts | 23 - core/app/fonts.ts | 22 + core/app/globals.css | 29 + core/app/providers.tsx | 10 +- core/client/fragments/form-fields-values.ts | 34 - core/client/fragments/product-item.ts | 28 - core/components/blog-post-card/fragment.ts | 1 + core/components/blog-post-card/index.tsx | 29 - core/components/breadcrumbs/index.tsx | 24 - .../featured-products-carousel/fragment.ts | 12 + .../fragment.ts | 4 +- core/components/footer/footer.tsx | 109 ++- core/components/form-fields/checkboxes.tsx | 121 --- core/components/form-fields/date.tsx | 107 --- core/components/form-fields/index.ts | 11 - .../components/form-fields/multiline-text.tsx | 64 -- core/components/form-fields/numbers-only.tsx | 82 -- core/components/form-fields/password.tsx | 70 -- .../form-fields/picklist-or-text.tsx | 46 - core/components/form-fields/picklist.tsx | 95 -- core/components/form-fields/radio-buttons.tsx | 67 -- .../form-fields/shared/field-handlers.ts | 240 ----- .../form-fields/shared/field-wrapper.tsx | 22 - .../form-fields/shared/parse-fields.ts | 230 ----- core/components/form-fields/text.tsx | 67 -- core/components/header/_actions/fragment.ts | 19 + .../header/_actions/get-search-results.ts | 67 -- core/components/header/_actions/search.ts | 115 +++ core/components/header/cart-icon.tsx | 54 -- core/components/header/cart.tsx | 36 - core/components/header/index.tsx | 170 ++-- core/components/header/quick-search.tsx | 86 -- core/components/link/index.tsx | 5 +- .../product-card-carousel/index.tsx | 36 - .../add-to-cart/form/_actions/add-to-cart.ts | 76 -- .../product-card/add-to-cart/form/index.tsx | 79 -- .../product-card/add-to-cart/fragment.ts | 21 - .../product-card/add-to-cart/index.tsx | 30 - core/components/product-card/fragment.ts | 5 +- core/components/product-card/index.tsx | 45 - core/components/slideshow/index.tsx | 37 - core/components/slideshow/slideshow-bg-01.jpg | Bin 446483 -> 0 bytes .../subscribe/_actions/subscribe.ts | 25 + core/components/subscribe/index.tsx | 18 + core/components/ui/accordions/accordions.tsx | 44 - core/components/ui/accordions/index.ts | 3 - core/components/ui/badge/badge.tsx | 24 - core/components/ui/badge/index.ts | 1 - .../ui/blog-post-card/blog-post-card.tsx | 66 -- core/components/ui/blog-post-card/index.ts | 1 - .../components/ui/breadcrumbs/breadcrumbs.tsx | 54 -- core/components/ui/breadcrumbs/index.ts | 1 - core/components/ui/button/button.tsx | 6 +- core/components/ui/carousel/carousel.tsx | 190 ---- core/components/ui/carousel/index.ts | 3 - .../ui/compare-drawer/compare-drawer.tsx | 2 +- core/components/ui/dropdown/dropdown.tsx | 68 -- core/components/ui/dropdown/index.ts | 1 - core/components/ui/footer/footer.tsx | 148 --- core/components/ui/footer/index.ts | 1 - core/components/ui/footer/locale.tsx | 24 - core/components/ui/form/checkbox/checkbox.tsx | 4 +- core/components/ui/form/counter/counter.tsx | 2 +- .../ui/form/date-picker/date-picker.tsx | 14 +- core/components/ui/form/input/input.tsx | 2 +- .../ui/form/pick-list/pick-list.tsx | 4 +- .../ui/form/radio-group/radio-group.tsx | 4 +- .../ui/form/rectangle-list/rectangle-list.tsx | 2 +- core/components/ui/form/select/select.tsx | 4 +- core/components/ui/form/swatch/swatch.tsx | 4 +- .../ui/form/text-area/text-area.tsx | 2 +- core/components/ui/gallery/gallery.tsx | 113 --- core/components/ui/gallery/index.ts | 3 - core/components/ui/header/header.tsx | 145 --- core/components/ui/header/index.ts | 2 - core/components/ui/header/input.tsx | 56 -- core/components/ui/header/locale-switcher.tsx | 127 --- core/components/ui/header/mobile-nav.tsx | 143 --- core/components/ui/header/search.tsx | 284 ------ core/components/ui/product-card/compare.tsx | 67 -- core/components/ui/product-card/index.ts | 1 - .../ui/product-card/product-card.tsx | 125 --- core/components/ui/sheet/index.ts | 3 - core/components/ui/sheet/sheet.tsx | 49 - core/components/ui/slideshow/index.ts | 3 - core/components/ui/slideshow/slideshow.tsx | 184 ---- core/components/ui/tabs/index.ts | 3 - core/components/ui/tabs/tabs.tsx | 42 - core/components/ui/tag/index.ts | 3 - core/components/ui/tag/tag.tsx | 34 - core/data-transformers/facets-transformer.ts | 195 ++++ .../form-field-transformer}/fragment.ts | 39 +- .../form-field-transformer/index.ts | 145 +++ .../form-field-transformer}/utils.ts | 90 +- core/data-transformers/logo-transformer.ts | 2 +- .../order-details-transformer.ts | 131 +++ core/data-transformers/orders-transformer.ts | 44 + .../page-info-transformer.ts | 22 + core/data-transformers/prices-transformer.ts | 2 +- .../product-card-transformer.ts | 24 + .../product-options-transformer.ts | 192 ++++ .../search-results-transformer.ts | 86 ++ core/lib/utils.ts | 4 + core/messages/en.json | 280 +++--- core/middlewares/with-routes.ts | 34 +- core/next.config.ts | 1 + core/package.json | 16 +- core/tailwind.config.js | 163 +++- core/tsconfig.json | 1 + .../soul/form/button-radio-group/index.tsx | 62 ++ .../soul/form/card-radio-group/index.tsx | 67 ++ core/vibes/soul/form/checkbox-group/index.tsx | 61 ++ core/vibes/soul/form/checkbox/index.tsx | 97 ++ core/vibes/soul/form/date-picker/index.tsx | 80 ++ core/vibes/soul/form/field-error/index.tsx | 16 + core/vibes/soul/form/form-status/index.tsx | 28 + core/vibes/soul/form/input/index.tsx | 46 + core/vibes/soul/form/label/index.tsx | 15 + core/vibes/soul/form/number-input/index.tsx | 108 +++ core/vibes/soul/form/radio-group/index.tsx | 82 ++ core/vibes/soul/form/range-input/index.tsx | 139 +++ core/vibes/soul/form/select/index.tsx | 99 ++ .../soul/form/swatch-radio-group/index.tsx | 85 ++ core/vibes/soul/form/textarea/index.tsx | 34 + core/vibes/soul/form/toggle-group/index.tsx | 52 ++ core/vibes/soul/lib/streamable.tsx | 110 +++ .../soul/primitives/accordions/index.tsx | 132 +++ core/vibes/soul/primitives/alert/index.tsx | 66 ++ .../soul/primitives/animated-link/index.tsx | 26 + core/vibes/soul/primitives/badge/index.tsx | 34 + core/vibes/soul/primitives/banner/index.tsx | 87 ++ .../soul/primitives/blog-post-card/index.tsx | 93 ++ .../soul/primitives/breadcrumbs/index.tsx | 77 ++ .../soul/primitives/button-link/index.tsx | 90 ++ core/vibes/soul/primitives/button/index.tsx | 117 +++ core/vibes/soul/primitives/calendar.tsx | 48 + core/vibes/soul/primitives/carousel/index.tsx | 331 +++++++ .../primitives/cursor-pagination/index.tsx | 147 +++ core/vibes/soul/primitives/drawer/index.tsx | 25 + .../soul/primitives/dynamic-form/index.tsx | 372 ++++++++ .../soul/primitives/dynamic-form/schema.ts | 243 +++++ .../primitives/inline-email-form/index.tsx | 83 ++ .../primitives/inline-email-form/schema.ts | 5 + core/vibes/soul/primitives/logo/index.tsx | 64 ++ .../soul/primitives/navigation/index.tsx | 864 ++++++++++++++++++ .../soul/primitives/navigation/schema.ts | 10 + .../soul/primitives/price-label/index.tsx | 104 +++ .../soul/primitives/product-card/compare.tsx | 42 + .../soul/primitives/product-card/index.tsx | 188 ++++ .../primitives/products-carousel/index.tsx | 181 ++++ .../products-list/compare-drawer.tsx | 111 +++ .../soul/primitives/products-list/index.tsx | 148 +++ core/vibes/soul/primitives/rating/index.tsx | 85 ++ .../soul/primitives/side-panel/index.tsx | 46 + core/vibes/soul/primitives/spinner/index.tsx | 24 + core/vibes/soul/primitives/toaster/index.tsx | 87 ++ .../account-layout-link-select.tsx | 24 + .../account-layout/account-layout-link.tsx | 30 + .../soul/sections/account-layout/index.tsx | 38 + .../change-password-form.tsx | 82 ++ .../account-settings-section/index.tsx | 48 + .../account-settings-section/schema.ts | 32 + .../update-account-form.tsx | 136 +++ .../sections/address-list-section/index.tsx | 382 ++++++++ .../sections/address-list-section/schema.ts | 17 + .../soul/sections/blog-post-content/index.tsx | 165 ++++ .../soul/sections/blog-post-list/index.tsx | 98 ++ core/vibes/soul/sections/cart/index.tsx | 473 ++++++++++ core/vibes/soul/sections/cart/schema.ts | 16 + core/vibes/soul/sections/cart/types.ts | 43 + core/vibes/soul/sections/error/index.tsx | 34 + .../featured-blog-post-list/index.tsx | 54 ++ .../featured-products-carousel/index.tsx | 64 ++ .../sections/featured-products-list/index.tsx | 63 ++ core/vibes/soul/sections/footer/index.tsx | 381 ++++++++ .../soul/sections/footer/payment-icons.tsx | 147 +++ .../soul/sections/footer/social-icons.tsx | 47 + .../forgot-password-form.tsx | 72 ++ .../forgot-password-section/index.tsx | 29 + .../forgot-password-section/schema.ts | 5 + .../soul/sections/header-section/index.tsx | 53 ++ .../vibes/soul/sections/maintenance/index.tsx | 81 ++ core/vibes/soul/sections/not-found/index.tsx | 20 + .../sections/order-details-section/index.tsx | 270 ++++++ .../sections/order-list-section/index.tsx | 37 + .../order-list-section/order-list-item.tsx | 67 ++ .../order-list-line-item.tsx | 57 ++ .../order-list-section/order-list.tsx | 40 + .../soul/sections/product-detail/index.tsx | 316 +++++++ .../product-detail/product-detail-form.tsx | 342 +++++++ .../product-detail/product-gallery.tsx | 82 ++ .../soul/sections/product-detail/schema.ts | 149 +++ .../products-list-section/filter-parsers.ts | 25 + .../products-list-section/filters-panel.tsx | 358 ++++++++ .../sections/products-list-section/index.tsx | 171 ++++ .../products-list-section/sorting.tsx | 59 ++ .../sections/reset-password-section/index.tsx | 36 + .../reset-password-form.tsx | 74 ++ .../sections/reset-password-section/schema.ts | 24 + core/vibes/soul/sections/reviews/index.tsx | 157 ++++ .../soul/sections/section-layout/index.tsx | 45 + .../soul/sections/sign-in-section/index.tsx | 46 + .../soul/sections/sign-in-section/schema.ts | 6 + .../sections/sign-in-section/sign-in-form.tsx | 76 ++ .../soul/sections/sign-up-section/index.tsx | 27 + core/vibes/soul/sections/slideshow/index.tsx | 277 ++++++ .../sections/sticky-sidebar-layout/index.tsx | 83 ++ core/vibes/soul/sections/subscribe/index.tsx | 56 ++ pnpm-lock.yaml | 577 ++++++++---- 366 files changed, 16934 insertions(+), 14411 deletions(-) create mode 100644 .changeset/tidy-stingrays-search.md delete mode 100644 .github/workflows/regression-tests.yml delete mode 100644 core/app/[locale]/(default)/(auth)/change-password/_components/change-password-form.tsx delete mode 100644 core/app/[locale]/(default)/(auth)/login/_components/login-form.tsx delete mode 100644 core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/fragment.ts delete mode 100644 core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/index.tsx delete mode 100644 core/app/[locale]/(default)/(auth)/register/_components/register-customer-form.tsx delete mode 100644 core/app/[locale]/(default)/(faceted)/_components/faceted-search.tsx delete mode 100644 core/app/[locale]/(default)/(faceted)/_components/facets.tsx delete mode 100644 core/app/[locale]/(default)/(faceted)/_components/mobile-side-nav.tsx delete mode 100644 core/app/[locale]/(default)/(faceted)/_components/refine-by.tsx delete mode 100644 core/app/[locale]/(default)/(faceted)/_components/sort-by.tsx delete mode 100644 core/app/[locale]/(default)/(faceted)/brand/[slug]/static/page.tsx delete mode 100644 core/app/[locale]/(default)/(faceted)/category/[slug]/_components/empty-state.tsx delete mode 100644 core/app/[locale]/(default)/(faceted)/category/[slug]/_components/sub-categories.tsx delete mode 100644 core/app/[locale]/(default)/(faceted)/category/[slug]/static/page.tsx delete mode 100644 core/app/[locale]/(default)/(faceted)/types.ts create mode 100644 core/app/[locale]/(default)/_components/slideshow/index.tsx create mode 100644 core/app/[locale]/(default)/_components/slideshow/slide-bg-01.jpg create mode 100644 core/app/[locale]/(default)/_components/slideshow/slide-bg-02.jpg create mode 100644 core/app/[locale]/(default)/_components/slideshow/slide-bg-03.jpg delete mode 100644 core/app/[locale]/(default)/account/_components/tab-navigation.tsx create mode 100644 core/app/[locale]/(default)/account/addresses/_actions/address-action.ts create mode 100644 core/app/[locale]/(default)/account/addresses/_actions/create-address.ts create mode 100644 core/app/[locale]/(default)/account/addresses/_actions/update-address.ts delete mode 100644 core/app/[locale]/(default)/account/addresses/_components/address-book.tsx delete mode 100644 core/app/[locale]/(default)/account/addresses/add/_actions/add-address.ts delete mode 100644 core/app/[locale]/(default)/account/addresses/add/_components/add-address-form.tsx delete mode 100644 core/app/[locale]/(default)/account/addresses/add/page.tsx delete mode 100644 core/app/[locale]/(default)/account/addresses/edit/[slug]/_actions/update-address.ts delete mode 100644 core/app/[locale]/(default)/account/addresses/edit/[slug]/_components/edit-address-form.tsx delete mode 100644 core/app/[locale]/(default)/account/addresses/edit/[slug]/page.tsx delete mode 100644 core/app/[locale]/(default)/account/order/[slug]/_components/order-details.tsx delete mode 100644 core/app/[locale]/(default)/account/order/[slug]/page.tsx rename core/app/[locale]/(default)/account/{order/[slug] => orders/[id]}/page-data.tsx (54%) create mode 100644 core/app/[locale]/(default)/account/orders/[id]/page.tsx delete mode 100644 core/app/[locale]/(default)/account/orders/_components/orders-list.tsx delete mode 100644 core/app/[locale]/(default)/account/orders/_components/product-snippet.tsx create mode 100644 core/app/[locale]/(default)/account/orders/fragment.ts create mode 100644 core/app/[locale]/(default)/account/settings/_actions/change-password.ts delete mode 100644 core/app/[locale]/(default)/account/settings/_components/text-field.tsx delete mode 100644 core/app/[locale]/(default)/account/settings/_components/update-settings-form.tsx delete mode 100644 core/app/[locale]/(default)/account/settings/change-password/_actions/change-password.ts delete mode 100644 core/app/[locale]/(default)/account/settings/change-password/_components/change-password-form.tsx delete mode 100644 core/app/[locale]/(default)/account/settings/change-password/page.tsx delete mode 100644 core/app/[locale]/(default)/blog/[blogId]/_components/print-button.tsx delete mode 100644 core/app/[locale]/(default)/blog/[blogId]/_components/sharing-links.tsx delete mode 100644 core/app/[locale]/(default)/blog/tag/[tagId]/page.tsx create mode 100644 core/app/[locale]/(default)/cart/_actions/update-line-item.ts rename core/app/[locale]/(default)/cart/{_components/item-quantity/update-item-quantity.ts => _actions/update-quantity.ts} (71%) delete mode 100644 core/app/[locale]/(default)/cart/_components/cart-item.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/checkout-button.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/checkout-summary.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/coupon-code/apply-coupon-code.ts delete mode 100644 core/app/[locale]/(default)/cart/_components/coupon-code/fragment.ts delete mode 100644 core/app/[locale]/(default)/cart/_components/coupon-code/index.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/coupon-code/remove-coupon-code.ts delete mode 100644 core/app/[locale]/(default)/cart/_components/empty-cart.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/item-quantity/index.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/remove-from-cart-button.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/remove-item.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/shipping-estimator/fragment.ts delete mode 100644 core/app/[locale]/(default)/cart/_components/shipping-estimator/get-shipping-countries.ts delete mode 100644 core/app/[locale]/(default)/cart/_components/shipping-estimator/index.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/shipping-info/fragment.ts delete mode 100644 core/app/[locale]/(default)/cart/_components/shipping-info/index.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/shipping-info/submit-shipping-info.ts delete mode 100644 core/app/[locale]/(default)/cart/_components/shipping-options/fragment.ts delete mode 100644 core/app/[locale]/(default)/cart/_components/shipping-options/index.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/shipping-options/submit-button.tsx delete mode 100644 core/app/[locale]/(default)/cart/_components/shipping-options/submit-shipping-costs.ts create mode 100644 core/app/[locale]/(default)/cart/loading.tsx create mode 100644 core/app/[locale]/(default)/cart/page-data.ts create mode 100644 core/app/[locale]/(default)/error.tsx rename core/{components/header/_actions/logout.ts => app/[locale]/(default)/logout/route.ts} (82%) rename core/app/[locale]/(default)/product/[slug]/{_components/product-form/_actions/add-to-cart.ts => _actions/add-to-cart.tsx} (51%) delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/description.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/details.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/gallery/fragment.ts delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/gallery/index.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/checkbox-field/fragment.ts delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/checkbox-field/index.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/date-field/fragment.ts delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/date-field/index.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/multi-line-text-field/fragment.ts delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/multi-line-text-field/index.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/multiple-choice-field/fragment.ts delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/multiple-choice-field/index.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/number-field/fragment.ts delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/number-field/index.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/quantity-field.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/shared/error-message.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/text-field/fragment.ts delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fields/text-field/index.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/fragment.ts delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/index.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-form/use-product-form.ts create mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/fragment.ts rename core/app/[locale]/(default)/product/[slug]/_components/{ => product-review-schema}/product-review-schema.tsx (81%) create mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-schema/fragment.ts rename core/app/[locale]/(default)/product/[slug]/_components/{product-schema.tsx => product-schema/index.tsx} (80%) create mode 100644 core/app/[locale]/(default)/product/[slug]/_components/product-viewed/fragment.ts rename core/app/[locale]/(default)/product/[slug]/_components/{product-viewed.tsx => product-viewed/index.tsx} (67%) delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/related-products.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/review-summary.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/_components/warranty.tsx delete mode 100644 core/app/[locale]/(default)/product/[slug]/static/page.tsx delete mode 100644 core/app/[locale]/(default)/static/page.tsx create mode 100644 core/app/[locale]/(default)/webpages/_components/web-page.tsx delete mode 100644 core/app/[locale]/_components/error.tsx delete mode 100644 core/app/[locale]/store-selector/_components/locale-link.tsx delete mode 100644 core/app/[locale]/store-selector/page.tsx delete mode 100644 core/app/api/cart-quantity/route.ts create mode 100644 core/app/fonts.ts delete mode 100644 core/client/fragments/form-fields-values.ts delete mode 100644 core/client/fragments/product-item.ts delete mode 100644 core/components/blog-post-card/index.tsx delete mode 100644 core/components/breadcrumbs/index.tsx create mode 100644 core/components/featured-products-carousel/fragment.ts rename core/components/{product-card-carousel => featured-products-list}/fragment.ts (62%) delete mode 100644 core/components/form-fields/checkboxes.tsx delete mode 100644 core/components/form-fields/date.tsx delete mode 100644 core/components/form-fields/index.ts delete mode 100644 core/components/form-fields/multiline-text.tsx delete mode 100644 core/components/form-fields/numbers-only.tsx delete mode 100644 core/components/form-fields/password.tsx delete mode 100644 core/components/form-fields/picklist-or-text.tsx delete mode 100644 core/components/form-fields/picklist.tsx delete mode 100644 core/components/form-fields/radio-buttons.tsx delete mode 100644 core/components/form-fields/shared/field-handlers.ts delete mode 100644 core/components/form-fields/shared/field-wrapper.tsx delete mode 100644 core/components/form-fields/shared/parse-fields.ts delete mode 100644 core/components/form-fields/text.tsx create mode 100644 core/components/header/_actions/fragment.ts delete mode 100644 core/components/header/_actions/get-search-results.ts create mode 100644 core/components/header/_actions/search.ts delete mode 100644 core/components/header/cart-icon.tsx delete mode 100644 core/components/header/cart.tsx delete mode 100644 core/components/header/quick-search.tsx delete mode 100644 core/components/product-card-carousel/index.tsx delete mode 100644 core/components/product-card/add-to-cart/form/_actions/add-to-cart.ts delete mode 100644 core/components/product-card/add-to-cart/form/index.tsx delete mode 100644 core/components/product-card/add-to-cart/fragment.ts delete mode 100644 core/components/product-card/add-to-cart/index.tsx delete mode 100644 core/components/product-card/index.tsx delete mode 100644 core/components/slideshow/index.tsx delete mode 100644 core/components/slideshow/slideshow-bg-01.jpg create mode 100644 core/components/subscribe/_actions/subscribe.ts create mode 100644 core/components/subscribe/index.tsx delete mode 100644 core/components/ui/accordions/accordions.tsx delete mode 100644 core/components/ui/accordions/index.ts delete mode 100644 core/components/ui/badge/badge.tsx delete mode 100644 core/components/ui/badge/index.ts delete mode 100644 core/components/ui/blog-post-card/blog-post-card.tsx delete mode 100644 core/components/ui/blog-post-card/index.ts delete mode 100644 core/components/ui/breadcrumbs/breadcrumbs.tsx delete mode 100644 core/components/ui/breadcrumbs/index.ts delete mode 100644 core/components/ui/carousel/carousel.tsx delete mode 100644 core/components/ui/carousel/index.ts delete mode 100644 core/components/ui/dropdown/dropdown.tsx delete mode 100644 core/components/ui/dropdown/index.ts delete mode 100644 core/components/ui/footer/footer.tsx delete mode 100644 core/components/ui/footer/index.ts delete mode 100644 core/components/ui/footer/locale.tsx delete mode 100644 core/components/ui/gallery/gallery.tsx delete mode 100644 core/components/ui/gallery/index.ts delete mode 100644 core/components/ui/header/header.tsx delete mode 100644 core/components/ui/header/index.ts delete mode 100644 core/components/ui/header/input.tsx delete mode 100644 core/components/ui/header/locale-switcher.tsx delete mode 100644 core/components/ui/header/mobile-nav.tsx delete mode 100644 core/components/ui/header/search.tsx delete mode 100644 core/components/ui/product-card/compare.tsx delete mode 100644 core/components/ui/product-card/index.ts delete mode 100644 core/components/ui/product-card/product-card.tsx delete mode 100644 core/components/ui/sheet/index.ts delete mode 100644 core/components/ui/sheet/sheet.tsx delete mode 100644 core/components/ui/slideshow/index.ts delete mode 100644 core/components/ui/slideshow/slideshow.tsx delete mode 100644 core/components/ui/tabs/index.ts delete mode 100644 core/components/ui/tabs/tabs.tsx delete mode 100644 core/components/ui/tag/index.ts delete mode 100644 core/components/ui/tag/tag.tsx create mode 100644 core/data-transformers/facets-transformer.ts rename core/{components/form-fields => data-transformers/form-field-transformer}/fragment.ts (51%) create mode 100644 core/data-transformers/form-field-transformer/index.ts rename core/{components/form-fields => data-transformers/form-field-transformer}/utils.ts (53%) create mode 100644 core/data-transformers/order-details-transformer.ts create mode 100644 core/data-transformers/orders-transformer.ts create mode 100644 core/data-transformers/page-info-transformer.ts create mode 100644 core/data-transformers/product-card-transformer.ts create mode 100644 core/data-transformers/product-options-transformer.ts create mode 100644 core/data-transformers/search-results-transformer.ts create mode 100644 core/vibes/soul/form/button-radio-group/index.tsx create mode 100644 core/vibes/soul/form/card-radio-group/index.tsx create mode 100644 core/vibes/soul/form/checkbox-group/index.tsx create mode 100644 core/vibes/soul/form/checkbox/index.tsx create mode 100644 core/vibes/soul/form/date-picker/index.tsx create mode 100644 core/vibes/soul/form/field-error/index.tsx create mode 100644 core/vibes/soul/form/form-status/index.tsx create mode 100644 core/vibes/soul/form/input/index.tsx create mode 100644 core/vibes/soul/form/label/index.tsx create mode 100644 core/vibes/soul/form/number-input/index.tsx create mode 100644 core/vibes/soul/form/radio-group/index.tsx create mode 100644 core/vibes/soul/form/range-input/index.tsx create mode 100644 core/vibes/soul/form/select/index.tsx create mode 100644 core/vibes/soul/form/swatch-radio-group/index.tsx create mode 100644 core/vibes/soul/form/textarea/index.tsx create mode 100644 core/vibes/soul/form/toggle-group/index.tsx create mode 100644 core/vibes/soul/lib/streamable.tsx create mode 100644 core/vibes/soul/primitives/accordions/index.tsx create mode 100644 core/vibes/soul/primitives/alert/index.tsx create mode 100644 core/vibes/soul/primitives/animated-link/index.tsx create mode 100644 core/vibes/soul/primitives/badge/index.tsx create mode 100644 core/vibes/soul/primitives/banner/index.tsx create mode 100644 core/vibes/soul/primitives/blog-post-card/index.tsx create mode 100644 core/vibes/soul/primitives/breadcrumbs/index.tsx create mode 100644 core/vibes/soul/primitives/button-link/index.tsx create mode 100644 core/vibes/soul/primitives/button/index.tsx create mode 100644 core/vibes/soul/primitives/calendar.tsx create mode 100644 core/vibes/soul/primitives/carousel/index.tsx create mode 100644 core/vibes/soul/primitives/cursor-pagination/index.tsx create mode 100644 core/vibes/soul/primitives/drawer/index.tsx create mode 100644 core/vibes/soul/primitives/dynamic-form/index.tsx create mode 100644 core/vibes/soul/primitives/dynamic-form/schema.ts create mode 100644 core/vibes/soul/primitives/inline-email-form/index.tsx create mode 100644 core/vibes/soul/primitives/inline-email-form/schema.ts create mode 100644 core/vibes/soul/primitives/logo/index.tsx create mode 100644 core/vibes/soul/primitives/navigation/index.tsx create mode 100644 core/vibes/soul/primitives/navigation/schema.ts create mode 100644 core/vibes/soul/primitives/price-label/index.tsx create mode 100644 core/vibes/soul/primitives/product-card/compare.tsx create mode 100644 core/vibes/soul/primitives/product-card/index.tsx create mode 100644 core/vibes/soul/primitives/products-carousel/index.tsx create mode 100644 core/vibes/soul/primitives/products-list/compare-drawer.tsx create mode 100644 core/vibes/soul/primitives/products-list/index.tsx create mode 100644 core/vibes/soul/primitives/rating/index.tsx create mode 100644 core/vibes/soul/primitives/side-panel/index.tsx create mode 100644 core/vibes/soul/primitives/spinner/index.tsx create mode 100644 core/vibes/soul/primitives/toaster/index.tsx create mode 100644 core/vibes/soul/sections/account-layout/account-layout-link-select.tsx create mode 100644 core/vibes/soul/sections/account-layout/account-layout-link.tsx create mode 100644 core/vibes/soul/sections/account-layout/index.tsx create mode 100644 core/vibes/soul/sections/account-settings-section/change-password-form.tsx create mode 100644 core/vibes/soul/sections/account-settings-section/index.tsx create mode 100644 core/vibes/soul/sections/account-settings-section/schema.ts create mode 100644 core/vibes/soul/sections/account-settings-section/update-account-form.tsx create mode 100644 core/vibes/soul/sections/address-list-section/index.tsx create mode 100644 core/vibes/soul/sections/address-list-section/schema.ts create mode 100644 core/vibes/soul/sections/blog-post-content/index.tsx create mode 100644 core/vibes/soul/sections/blog-post-list/index.tsx create mode 100644 core/vibes/soul/sections/cart/index.tsx create mode 100644 core/vibes/soul/sections/cart/schema.ts create mode 100644 core/vibes/soul/sections/cart/types.ts create mode 100644 core/vibes/soul/sections/error/index.tsx create mode 100644 core/vibes/soul/sections/featured-blog-post-list/index.tsx create mode 100644 core/vibes/soul/sections/featured-products-carousel/index.tsx create mode 100644 core/vibes/soul/sections/featured-products-list/index.tsx create mode 100644 core/vibes/soul/sections/footer/index.tsx create mode 100644 core/vibes/soul/sections/footer/payment-icons.tsx create mode 100644 core/vibes/soul/sections/footer/social-icons.tsx create mode 100644 core/vibes/soul/sections/forgot-password-section/forgot-password-form.tsx create mode 100644 core/vibes/soul/sections/forgot-password-section/index.tsx create mode 100644 core/vibes/soul/sections/forgot-password-section/schema.ts create mode 100644 core/vibes/soul/sections/header-section/index.tsx create mode 100644 core/vibes/soul/sections/maintenance/index.tsx create mode 100644 core/vibes/soul/sections/not-found/index.tsx create mode 100644 core/vibes/soul/sections/order-details-section/index.tsx create mode 100644 core/vibes/soul/sections/order-list-section/index.tsx create mode 100644 core/vibes/soul/sections/order-list-section/order-list-item.tsx create mode 100644 core/vibes/soul/sections/order-list-section/order-list-line-item.tsx create mode 100644 core/vibes/soul/sections/order-list-section/order-list.tsx create mode 100644 core/vibes/soul/sections/product-detail/index.tsx create mode 100644 core/vibes/soul/sections/product-detail/product-detail-form.tsx create mode 100644 core/vibes/soul/sections/product-detail/product-gallery.tsx create mode 100644 core/vibes/soul/sections/product-detail/schema.ts create mode 100644 core/vibes/soul/sections/products-list-section/filter-parsers.ts create mode 100644 core/vibes/soul/sections/products-list-section/filters-panel.tsx create mode 100644 core/vibes/soul/sections/products-list-section/index.tsx create mode 100644 core/vibes/soul/sections/products-list-section/sorting.tsx create mode 100644 core/vibes/soul/sections/reset-password-section/index.tsx create mode 100644 core/vibes/soul/sections/reset-password-section/reset-password-form.tsx create mode 100644 core/vibes/soul/sections/reset-password-section/schema.ts create mode 100644 core/vibes/soul/sections/reviews/index.tsx create mode 100644 core/vibes/soul/sections/section-layout/index.tsx create mode 100644 core/vibes/soul/sections/sign-in-section/index.tsx create mode 100644 core/vibes/soul/sections/sign-in-section/schema.ts create mode 100644 core/vibes/soul/sections/sign-in-section/sign-in-form.tsx create mode 100644 core/vibes/soul/sections/sign-up-section/index.tsx create mode 100644 core/vibes/soul/sections/slideshow/index.tsx create mode 100644 core/vibes/soul/sections/sticky-sidebar-layout/index.tsx create mode 100644 core/vibes/soul/sections/subscribe/index.tsx diff --git a/.changeset/tidy-stingrays-search.md b/.changeset/tidy-stingrays-search.md new file mode 100644 index 0000000000..d2fac1b509 --- /dev/null +++ b/.changeset/tidy-stingrays-search.md @@ -0,0 +1,13 @@ +--- +"@bigcommerce/catalyst-core": major +--- + +Introduce Soul VIBE UI library to the repository. + +- Added a collection of reusable primitives with modern styles +- Prebuilt sections and page templates that are easy to use +- Fast performance and modern patterns leveraging the latest features of Next.js +- Easy customization to best represent your brand +- Utilize @conform-to/react for progressively enhanced HTML forms + +Join the discussion [here](https://github.com/bigcommerce/catalyst/discussions/1861) for more details of this major milestone for Catalyst! diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/regression-tests.yml deleted file mode 100644 index 56aaa8253f..0000000000 --- a/.github/workflows/regression-tests.yml +++ /dev/null @@ -1,228 +0,0 @@ -name: Regression Tests - -on: - deployment_status: - states: ['success'] - -env: - PLAYWRIGHT_TEST_BASE_URL: ${{ github.event.deployment_status.target_url }} - VERCEL_PROTECTION_BYPASS: ${{ secrets.VERCEL_PROTECTION_BYPASS_CATALYST_LATEST }} - BIGCOMMERCE_ACCESS_TOKEN: ${{ secrets.BIGCOMMERCE_ACCESS_TOKEN }} - BIGCOMMERCE_STORE_HASH: ${{ secrets.BIGCOMMERCE_STORE_HASH }} - -jobs: - generate-lighthouse-audit: - name: Lighthouse Audit - timeout-minutes: 30 - runs-on: ubuntu-latest - if: ${{ contains(fromJson('["Production – catalyst-latest", "Preview – catalyst-latest"]'), github.event.deployment_status.environment) }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Pull Request Details - id: pr_details - uses: actions/github-script@v7 - with: - script: | - const prData = require('./.github/workflows/pull-request-data.js'); - await prData({ github, context, core }); - - - name: Lighthouse house audit on desktop - id: lighthouse_audit_desktop - uses: treosh/lighthouse-ci-action@v11 - with: - urls: | - ${{ github.event.deployment_status.target_url }} - configPath: '.github/workflows/.lighthouserc-desktop.json' - temporaryPublicStorage: true - runs: 3 - - - name: Lighthouse audit on mobile - id: lighthouse_audit_mobile - uses: treosh/lighthouse-ci-action@v11 - with: - urls: | - ${{ github.event.deployment_status.target_url }} - temporaryPublicStorage: true - runs: 3 - - - uses: pnpm/action-setup@v3 - - name: Format lighthouse score on desktop - id: format_lighthouse_score_desktop - uses: actions/github-script@v7 - with: - script: | - const lighthouseCommentMaker = require('./.github/workflows/lighthouseCommentMaker.js'); - - const lighthouseOutputs = { - manifest: ${{ steps.lighthouse_audit_desktop.outputs.manifest }}, - links: ${{ steps.lighthouse_audit_desktop.outputs.links }}, - preset: "desktop" - }; - - const comment = lighthouseCommentMaker({ lighthouseOutputs }); - core.setOutput("comment", comment); - - - name: Format lighthouse score on mobile - id: format_lighthouse_score_mobile - uses: actions/github-script@v7 - with: - script: | - const lighthouseCommentMaker = require('./.github/workflows/lighthouseCommentMaker.js'); - - const lighthouseOutputs = { - manifest: ${{ steps.lighthouse_audit_mobile.outputs.manifest }}, - links: ${{ steps.lighthouse_audit_mobile.outputs.links }}, - preset: "mobile" - }; - - const comment = lighthouseCommentMaker({ lighthouseOutputs }); - core.setOutput("comment", comment); - - - name: Add comment to PR - id: comment_to_pr - uses: marocchino/sticky-pull-request-comment@v2.9.0 - if: ${{ steps.pr_details.outputs.pr }} - with: - recreate: true - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - number: ${{ steps.pr_details.outputs.pr }} - header: lighthouse - message: | - # ⚡️🏠 Lighthouse report - - *Lighthouse ran against ${{ github.event.deployment_status.target_url }}* - - ## 🖥️ Desktop - - ${{ steps.format_lighthouse_score_desktop.outputs.comment }} - - ## 📱 Mobile - - ${{ steps.format_lighthouse_score_mobile.outputs.comment }} - - ui-tests: - name: Playwright UI Tests - timeout-minutes: 30 - runs-on: ubuntu-latest - if: ${{ contains(fromJson('["Production – catalyst-latest", "Preview – catalyst-latest"]'), github.event.deployment_status.environment) }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v3 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Pull Request Details - id: pr_details - uses: actions/github-script@v7 - with: - script: | - const prData = require('./.github/workflows/pull-request-data.js'); - await prData({ github, context, core }); - - - name: Install Playwright Browsers - run: | - cd core - npx playwright install --with-deps - - - name: Run Playwright tests - run: | - cd core - npx playwright test tests/ui/ --project=tests-chromium - - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: playwright-report-ui - path: core/playwright-report/ - retention-days: 30 - - - name: Send slack notification - uses: slackapi/slack-github-action@v1.26.0 - if: ${{ steps.pr_details.outputs.draft != 'true' && steps.pr_details.outputs.pr && failure() }} - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - with: - payload: | - { - "Job": "${{ github.job }}", - "Status": "Failed", - "Environment": "${{ github.event.deployment_status.environment }}", - "Pull_Request": "${{ steps.pr_details.outputs.url }}", - "Commit_Message" : "${{ steps.pr_details.outputs.title }}", - "Job_Run": "https://github.com/bigcommerce/catalyst/actions/runs/${{ github.run_id }}" - } - - visual-regression-tests: - name: Playwright Visual Regression Tests - timeout-minutes: 30 - runs-on: macos-14 - if: ${{ contains(fromJson('["Production – catalyst-latest", "Preview – catalyst-latest"]'), github.event.deployment_status.environment) }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v3 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Pull Request Details - id: pr_details - uses: actions/github-script@v7 - with: - script: | - const prData = require('./.github/workflows/pull-request-data.js'); - await prData({ github, context, core }); - - - name: Install Playwright Browsers - run: | - cd core - npx playwright install chromium - - - name: Run Playwright tests - run: | - cd core - npx playwright test tests/visual-regression/components/ --project=tests-chromium - - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: playwright-report-visual-regression - path: core/playwright-report/ - retention-days: 30 - - - name: Send slack notification - uses: slackapi/slack-github-action@v1.26.0 - if: ${{ steps.pr_details.outputs.draft != 'true' && steps.pr_details.outputs.pr && failure() }} - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - with: - payload: | - { - "Job": "${{ github.job }}", - "Status": "Failed", - "Environment": "${{ github.event.deployment_status.environment }}", - "Pull_Request": "${{ steps.pr_details.outputs.url }}", - "Commit_Message" : "${{ steps.pr_details.outputs.title }}", - "Job_Run": "https://github.com/bigcommerce/catalyst/actions/runs/${{ github.run_id }}" - } diff --git a/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts b/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts index a6b3847f7c..3e660b6e3a 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts +++ b/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts @@ -1,23 +1,13 @@ 'use server'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; import { getTranslations } from 'next-intl/server'; -import { z, ZodError } from 'zod'; +import { schema } from '@/vibes/soul/sections/reset-password-section/schema'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; -const ChangePasswordFieldsSchema = z.object({ - customerId: z.string(), - customerToken: z.string(), - currentPassword: z.string().min(1), - newPassword: z.string().min(1), - confirmPassword: z.string().min(1), -}); - -const ChangePasswordSchema = ChangePasswordFieldsSchema.omit({ - currentPassword: true, -}).required(); - const ChangePasswordMutation = graphql(` mutation ChangePassword($input: ResetPasswordInput!) { customer { @@ -34,29 +24,26 @@ const ChangePasswordMutation = graphql(` } `); -interface ChangePasswordResponse { - status: 'success' | 'error'; - message: string; -} - -export const changePassword = async (formData: FormData): Promise => { +export async function changePassword( + { token, customerEntityId }: { token: string; customerEntityId: string }, + _prevState: { lastResult: SubmissionResult | null; successMessage?: string }, + formData: FormData, +) { const t = await getTranslations('ChangePassword'); + const submission = parseWithZod(formData, { schema }); - try { - const parsedData = ChangePasswordSchema.parse({ - customerId: formData.get('customer-id'), - customerToken: formData.get('customer-token'), - newPassword: formData.get('new-password'), - confirmPassword: formData.get('confirm-password'), - }); + if (submission.status !== 'success') { + return { lastResult: submission.reply({ formErrors: [t('Form.error')] }) }; + } + try { const response = await client.fetch({ document: ChangePasswordMutation, variables: { input: { - token: parsedData.customerToken, - customerEntityId: Number(parsedData.customerId), - newPassword: parsedData.newPassword, + token, + customerEntityId: Number(customerEntityId), + newPassword: submission.value.password, }, }, fetchOptions: { @@ -67,23 +54,24 @@ export const changePassword = async (formData: FormData): Promise 0) { - result.errors.forEach((error) => { - throw new Error(error.message); - }); + return { + lastResult: submission.reply({ formErrors: result.errors.map((error) => error.message) }), + }; } return { - status: 'success', - message: t('confirmChangePassword'), + lastResult: submission.reply(), + successMessage: t('Form.successMessage'), }; } catch (error: unknown) { - if (error instanceof Error || error instanceof ZodError) { + if (error instanceof Error) { return { - status: 'error', - message: error.message, + lastResult: submission.reply({ formErrors: [error.message] }), }; } - return { status: 'error', message: t('Errors.error') }; + return { + lastResult: submission.reply({ formErrors: [t('Errors.error')] }), + }; } -}; +} diff --git a/core/app/[locale]/(default)/(auth)/change-password/_components/change-password-form.tsx b/core/app/[locale]/(default)/(auth)/change-password/_components/change-password-form.tsx deleted file mode 100644 index e6b3a1484d..0000000000 --- a/core/app/[locale]/(default)/(auth)/change-password/_components/change-password-form.tsx +++ /dev/null @@ -1,136 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import { toast } from 'react-hot-toast'; - -import { Button } from '~/components/ui/button'; -import { - Field, - FieldControl, - FieldLabel, - FieldMessage, - Form, - FormSubmit, - Input, -} from '~/components/ui/form'; -import { useRouter } from '~/i18n/routing'; - -import { changePassword } from '../_actions/change-password'; - -interface Props { - customerId: string; - customerToken: string; -} - -const SubmitButton = () => { - const t = useTranslations('ChangePassword.Form'); - - const { pending } = useFormStatus(); - - return ( - - ); -}; - -export const ChangePasswordForm = ({ customerId, customerToken }: Props) => { - const t = useTranslations('ChangePassword.Form'); - - const router = useRouter(); - - const [newPassword, setNewPasssword] = useState(''); - const [isConfirmPasswordValid, setIsConfirmPasswordValid] = useState(true); - - const handleNewPasswordChange = (e: ChangeEvent) => - setNewPasssword(e.target.value); - - const handleConfirmPasswordValidation = (e: ChangeEvent) => { - const confirmPassword = e.target.value; - - setIsConfirmPasswordValid(confirmPassword === newPassword); - }; - - const handleChangePassword = async (formData: FormData) => { - const { status, message } = await changePassword(formData); - - if (status === 'error') { - toast.error(message, { - icon: , - }); - - return; - } - - toast.success(message, { - icon: , - }); - - router.push('/login'); - }; - - return ( -
- - - - - - - - - - - - - {t('newPasswordLabel')} - - - - - - - - - {t('confirmPasswordLabel')} - - - - - value !== newPassword} - > - {t('confirmPasswordValidationMessage')} - - - - - - -
- ); -}; diff --git a/core/app/[locale]/(default)/(auth)/change-password/page.tsx b/core/app/[locale]/(default)/(auth)/change-password/page.tsx index f881749983..5e0ea7416b 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/change-password/page.tsx @@ -1,8 +1,10 @@ +/* eslint-disable react/jsx-no-bind */ import { getLocale, getTranslations } from 'next-intl/server'; +import { ResetPasswordSection } from '@/vibes/soul/sections/reset-password-section'; import { redirect } from '~/i18n/routing'; -import { ChangePasswordForm } from './_components/change-password-form'; +import { changePassword } from './_actions/change-password'; export async function generateMetadata() { const t = await getTranslations('ChangePassword'); @@ -20,24 +22,20 @@ interface Props { } export default async function ChangePassword({ searchParams }: Props) { - const { c: customerId, t: customerToken } = await searchParams; + const { c: customerEntityId, t: token } = await searchParams; const t = await getTranslations('ChangePassword'); const locale = await getLocale(); - if (!customerId || !customerToken) { - redirect({ href: '/login', locale }); + if (!customerEntityId || !token) { + return redirect({ href: '/login', locale }); } - if (customerId && customerToken) { - return ( -
-

{t('heading')}

- -
- ); - } - - return null; + return ( + + ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/(auth)/login/_actions/login.ts b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts index d71be265ea..cf6c5714c5 100644 --- a/core/app/[locale]/(default)/(auth)/login/_actions/login.ts +++ b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts @@ -1,23 +1,28 @@ 'use server'; -import { unstable_rethrow as rethrow } from 'next/navigation'; -import { getLocale } from 'next-intl/server'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { getLocale, getTranslations } from 'next-intl/server'; +import { schema } from '@/vibes/soul/sections/sign-in-section/schema'; import { Credentials, signIn } from '~/auth'; import { redirect } from '~/i18n/routing'; -interface LoginResponse { - status: 'success' | 'error'; -} +export const login = async (_lastResult: SubmissionResult | null, formData: FormData) => { + const locale = await getLocale(); + const t = await getTranslations('Login'); -export const login = async (formData: FormData): Promise => { - try { - const locale = await getLocale(); + const submission = parseWithZod(formData, { schema }); + + if (submission.status !== 'success') { + return submission.reply({ formErrors: [t('Form.error')] }); + } + try { const credentials = Credentials.parse({ type: 'password', - email: formData.get('email'), - password: formData.get('password'), + email: submission.value.email, + password: submission.value.password, }); await signIn('credentials', { @@ -26,17 +31,9 @@ export const login = async (formData: FormData): Promise => { // follows basePath and trailing slash configurations. redirect: false, }); - - redirect({ href: '/account/orders', locale }); - - return { - status: 'success', - }; - } catch (error: unknown) { - rethrow(error); - - return { - status: 'error', - }; + } catch { + return submission.reply({ formErrors: [t('Form.error')] }); } + + return redirect({ href: '/account/orders', locale }); }; diff --git a/core/app/[locale]/(default)/(auth)/login/_components/login-form.tsx b/core/app/[locale]/(default)/(auth)/login/_components/login-form.tsx deleted file mode 100644 index 85df228be3..0000000000 --- a/core/app/[locale]/(default)/(auth)/login/_components/login-form.tsx +++ /dev/null @@ -1,131 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import { toast } from 'react-hot-toast'; - -import { Link } from '~/components/link'; -import { Button } from '~/components/ui/button'; -import { - Field, - FieldControl, - FieldLabel, - FieldMessage, - Form, - FormSubmit, - Input, -} from '~/components/ui/form'; - -import { login } from '../_actions/login'; - -const SubmitButton = () => { - const { pending } = useFormStatus(); - const t = useTranslations('Login'); - - return ( - - ); -}; - -export const LoginForm = () => { - const t = useTranslations('Login'); - - const [isEmailValid, setIsEmailValid] = useState(true); - const [isPasswordValid, setIsPasswordValid] = useState(true); - - const handleInputValidation = (e: ChangeEvent) => { - const validationStatus = e.target.validity.valueMissing; - - switch (e.target.name) { - case 'email': { - setIsEmailValid(!validationStatus); - - return; - } - - case 'password': { - setIsPasswordValid(!validationStatus); - } - } - }; - - const handleLogin = async (formData: FormData) => { - const { status } = await login(formData); - - if (status === 'error') { - toast.error(t('Form.error'), { - icon: , - }); - - return; - } - - toast.success(t('Form.successful'), { - icon: , - }); - }; - - return ( -
- - {t('Form.emailLabel')} - - - - - {t('Form.enterEmailMessage')} - - - - {t('Form.passwordLabel')} - - - - - {t('Form.enterPasswordMessage')} - - -
- - - - - {t('Form.forgotPassword')} - -
-
- ); -}; diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts b/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts index 0eb123c565..c6717d06f0 100644 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts +++ b/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts @@ -1,15 +1,13 @@ 'use server'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; import { getTranslations } from 'next-intl/server'; -import { z } from 'zod'; +import { schema } from '@/vibes/soul/sections/forgot-password-section/schema'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; -const ResetPasswordSchema = z.object({ - email: z.string().email(), -}); - const ResetPasswordMutation = graphql(` mutation ResetPassword($input: RequestResetPasswordInput!, $reCaptcha: ReCaptchaV2Input) { customer { @@ -26,31 +24,29 @@ const ResetPasswordMutation = graphql(` } `); -interface SubmitResetPasswordResponse { - status: 'success' | 'error'; - message: string; -} - export const resetPassword = async ( + _lastResult: { lastResult: SubmissionResult | null; successMessage?: string }, formData: FormData, - path: string, - reCaptchaToken?: string, -): Promise => { + // TODO: add recaptcha token + // reCaptchaToken, +): Promise<{ lastResult: SubmissionResult | null; successMessage?: string }> => { const t = await getTranslations('Login.ForgotPassword'); - try { - const parsedData = ResetPasswordSchema.parse({ - email: formData.get('email'), - }); + const submission = parseWithZod(formData, { schema }); + + if (submission.status !== 'success') { + return { lastResult: submission.reply({ formErrors: [t('Errors.error')] }) }; + } + try { const response = await client.fetch({ document: ResetPasswordMutation, variables: { input: { - email: parsedData.email, - path, + email: submission.value.email, + path: '/change-password', }, - ...(reCaptchaToken && { reCaptchaV2: { token: reCaptchaToken } }), + // ...(reCaptchaToken && { reCaptchaV2: { token: reCaptchaToken } }), }, fetchOptions: { cache: 'no-store', @@ -59,24 +55,21 @@ export const resetPassword = async ( const result = response.data.customer.requestResetPassword; - if (result.errors.length > 0) { - result.errors.forEach((error) => { - throw new Error(error.message); - }); + if (result.errors.length === 0) { + return { + lastResult: submission.reply(), + successMessage: t('Form.confirmResetPassword', { email: submission.value.email }), + }; } return { - status: 'success', - message: t('Form.confirmResetPassword', { email: parsedData.email }), + lastResult: submission.reply({ formErrors: result.errors.map((error) => error.message) }), }; - } catch (error: unknown) { - if (error instanceof Error || error instanceof z.ZodError) { - return { - status: 'error', - message: error.message, - }; + } catch (error) { + if (error instanceof Error) { + return { lastResult: submission.reply({ formErrors: [error.message] }) }; } - return { status: 'error', message: t('Errors.error') }; + return { lastResult: submission.reply({ formErrors: [t('Errors.error')] }) }; } }; diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/fragment.ts b/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/fragment.ts deleted file mode 100644 index c72ffe9bdd..0000000000 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/fragment.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { graphql } from '~/client/graphql'; - -export const ResetPasswordFormFragment = graphql(` - fragment ResetPasswordFormFragment on ReCaptchaSettings { - isEnabledOnStorefront - siteKey - } -`); diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/index.tsx b/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/index.tsx deleted file mode 100644 index c039c4a40b..0000000000 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/index.tsx +++ /dev/null @@ -1,155 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, useRef, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import ReCaptcha from 'react-google-recaptcha'; -import { toast } from 'react-hot-toast'; - -import { type FragmentOf } from '~/client/graphql'; -import { Button } from '~/components/ui/button'; -import { - Field, - FieldControl, - FieldLabel, - FieldMessage, - Form, - FormSubmit, - Input, -} from '~/components/ui/form'; -import { useRouter } from '~/i18n/routing'; - -import { resetPassword } from '../../_actions/reset-password'; - -import { ResetPasswordFormFragment } from './fragment'; - -interface Props { - reCaptchaSettings?: FragmentOf; -} - -const SubmitButton = () => { - const t = useTranslations('Login.ForgotPassword.Form'); - - const { pending } = useFormStatus(); - - return ( - - ); -}; - -export const ResetPasswordForm = ({ reCaptchaSettings }: Props) => { - const t = useTranslations('Login.ForgotPassword.Form'); - - const form = useRef(null); - const [isEmailValid, setIsEmailValid] = useState(true); - - const reCaptchaRef = useRef(null); - const [reCaptchaToken, setReCaptchaToken] = useState(); - const router = useRouter(); - - const isReCaptchaValid = Boolean(reCaptchaToken); - - const onReCatpchaChange = (token: string | null) => { - if (!token) { - setReCaptchaToken(undefined); - - return; - } - - setReCaptchaToken(token); - }; - - const handleEmailValidation = (e: ChangeEvent) => { - const validationStatus = e.target.validity.valueMissing || e.target.validity.typeMismatch; - - setIsEmailValid(!validationStatus); - }; - - const onSubmit = async (formData: FormData) => { - if (reCaptchaSettings?.isEnabledOnStorefront && !isReCaptchaValid) { - return; - } - - const { status, message } = await resetPassword(formData, '/change-password', reCaptchaToken); - - if (status === 'error') { - reCaptchaRef.current?.reset(); - - toast.error(message, { - icon: , - }); - - return; - } - - toast.success(message, { - icon: , - }); - - form.current?.reset(); - router.push('/login'); - }; - - return ( - <> -

{t('description')}

- -
- - {t('emailLabel')} - - - - - {t('emailValidationMessage')} - - - {t('emailValidationMessage')} - - - - {reCaptchaSettings?.isEnabledOnStorefront && ( - - - {!isReCaptchaValid && ( - - {t('recaptchaText')} - - )} - - )} - - - - -
- - ); -}; diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx b/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx index df5798f257..d30990f5d1 100644 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx @@ -1,27 +1,27 @@ import { getTranslations } from 'next-intl/server'; -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; -import { revalidate } from '~/client/revalidate-target'; -import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; - -import { ResetPasswordForm } from './_components/reset-password-form'; -import { ResetPasswordFormFragment } from './_components/reset-password-form/fragment'; - -const ResetPageQuery = graphql( - ` - query ResetPageQuery { - site { - settings { - reCaptcha { - ...ResetPasswordFormFragment - } - } - } - } - `, - [ResetPasswordFormFragment], -); +// import { client } from '~/client'; +// import { graphql } from '~/client/graphql'; +// import { revalidate } from '~/client/revalidate-target'; +// import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; + +import { ForgotPasswordSection } from '@/vibes/soul/sections/forgot-password-section'; + +import { resetPassword } from './_actions/reset-password'; + +// TODO: add recaptcha token +// const ResetPageQuery = graphql(` +// query ResetPageQuery { +// site { +// settings { +// reCaptcha { +// isEnabledOnStorefront +// siteKey +// } +// } +// } +// } +// `); export async function generateMetadata() { const t = await getTranslations('Login.ForgotPassword'); @@ -34,19 +34,18 @@ export async function generateMetadata() { export default async function Reset() { const t = await getTranslations('Login.ForgotPassword'); - const { data } = await client.fetch({ - document: ResetPageQuery, - fetchOptions: { next: { revalidate } }, - }); - - const recaptchaSettings = await bypassReCaptcha(data.site.settings?.reCaptcha); + // TODO: add recaptcha token + // const { data } = await client.fetch({ + // document: ResetPageQuery, + // fetchOptions: { next: { revalidate } }, + // }); + // const recaptchaSettings = await bypassReCaptcha(data.site.settings?.reCaptcha); return ( -
-

{t('heading')}

- -
+ ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/(auth)/login/page.tsx b/core/app/[locale]/(default)/(auth)/login/page.tsx index 59d2078ed8..a04b7e65c8 100644 --- a/core/app/[locale]/(default)/(auth)/login/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/page.tsx @@ -1,26 +1,25 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; -import { Link } from '~/components/link'; -import { Button } from '~/components/ui/button'; +import { ButtonLink } from '@/vibes/soul/primitives/button-link'; +import { SignInSection } from '@/vibes/soul/sections/sign-in-section'; -import { LoginForm } from './_components/login-form'; +import { login } from './_actions/login'; + +interface Props { + params: Promise<{ locale: string }>; +} export async function generateMetadata({ params }: Props) { const { locale } = await params; + const t = await getTranslations('Login'); setRequestLocale(locale); - const t = await getTranslations('Login'); - return { title: t('title'), }; } -interface Props { - params: Promise<{ locale: string }>; -} - export default async function Login({ params }: Props) { const { locale } = await params; @@ -29,25 +28,27 @@ export default async function Login({ params }: Props) { const t = await getTranslations('Login'); return ( -
-

{t('heading')}

-
- -
-

{t('CreateAccount.heading')}

-

{t('CreateAccount.accountBenefits')}

-
    -
  • {t('CreateAccount.fastCheckout')}
  • -
  • {t('CreateAccount.multipleAddresses')}
  • -
  • {t('CreateAccount.ordersHistory')}
  • -
  • {t('CreateAccount.ordersTracking')}
  • -
  • {t('CreateAccount.wishlists')}
  • -
- -
+ +
+

{t('CreateAccount.heading')}

+

{t('CreateAccount.accountBenefits')}

+
    +
  • {t('CreateAccount.fastCheckout')}
  • +
  • {t('CreateAccount.multipleAddresses')}
  • +
  • {t('CreateAccount.ordersHistory')}
  • +
  • {t('CreateAccount.ordersTracking')}
  • +
  • {t('CreateAccount.wishlists')}
  • +
+ + {t('CreateAccount.createLink')} +
-
+ ); } diff --git a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts index 23de2ae837..4f38bf0767 100644 --- a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts +++ b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts @@ -1,11 +1,17 @@ 'use server'; import { BigCommerceAPIError } from '@bigcommerce/catalyst-client'; -import { getTranslations } from 'next-intl/server'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { getLocale, getTranslations } from 'next-intl/server'; +import { z } from 'zod'; +import { Field, FieldGroup, schema } from '@/vibes/soul/primitives/dynamic-form/schema'; +import { signIn } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; -import { parseRegisterCustomerFormData } from '~/components/form-fields/shared/parse-fields'; +import { FieldNameToFieldId } from '~/data-transformers/form-field-transformer/utils'; +import { redirect } from '~/i18n/routing'; const RegisterCustomerMutation = graphql(` mutation RegisterCustomer($input: RegisterCustomerInput!, $reCaptchaV2: ReCaptchaV2Input) { @@ -34,74 +40,203 @@ const RegisterCustomerMutation = graphql(` } `); -type Variables = VariablesOf; -type RegisterCustomerInput = Variables['input']; - -const isRegisterCustomerInput = (data: unknown): data is RegisterCustomerInput => { - if (typeof data === 'object' && data !== null && 'email' in data) { - return true; - } - - return false; -}; - -interface RegisterCustomerResponse { - status: 'success' | 'error'; - message: string; +const stringToNumber = z.string().pipe(z.coerce.number()); + +const inputSchema = z.object({ + email: z.string(), + password: z.string(), + firstName: z.string(), + lastName: z.string(), + formFields: z.object({ + checkboxes: z.array( + z.object({ + fieldEntityId: stringToNumber, + fieldValueEntityIds: z.array(stringToNumber), + }), + ), + multipleChoices: z.array( + z.object({ + fieldEntityId: stringToNumber, + fieldValueEntityId: stringToNumber, + }), + ), + numbers: z.array( + z.object({ + fieldEntityId: stringToNumber, + number: stringToNumber, + }), + ), + dates: z.array( + z.object({ + fieldEntityId: stringToNumber, + date: z.string(), + }), + ), + passwords: z.array( + z.object({ + fieldEntityId: stringToNumber, + password: z.string(), + }), + ), + multilineTexts: z.array( + z.object({ + fieldEntityId: stringToNumber, + multilineText: z.string(), + }), + ), + texts: z.array( + z.object({ + fieldEntityId: stringToNumber, + text: z.string(), + }), + ), + }), +}); + +function parseRegisterCustomerInput( + value: Record, + fields: Array>, +): VariablesOf['input'] { + const customFields = fields + .flatMap((f) => (Array.isArray(f) ? f : [f])) + .filter( + (field) => + ![ + String(FieldNameToFieldId.email), + String(FieldNameToFieldId.password), + String(FieldNameToFieldId.confirmPassword), + String(FieldNameToFieldId.firstName), + String(FieldNameToFieldId.lastName), + ].includes(field.name), + ); + const mappedInput = { + email: value[FieldNameToFieldId.email], + password: value[FieldNameToFieldId.password], + firstName: value[FieldNameToFieldId.firstName], + lastName: value[FieldNameToFieldId.lastName], + formFields: { + checkboxes: customFields + .filter((field) => ['checkbox-group'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.name, + fieldValueEntityIds: value[field.name], + }; + }), + multipleChoices: customFields + .filter((field) => ['radio-group', 'button-radio-group'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.name, + fieldValueEntityId: value[field.name], + }; + }), + numbers: customFields + .filter((field) => ['number'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.name, + number: value[field.name], + }; + }), + dates: customFields + .filter((field) => ['date'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.name, + date: new Date(String(value[field.name])).toISOString(), + }; + }), + passwords: customFields + .filter((field) => ['password'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: field.name, + password: value[field.name], + })), + multilineTexts: customFields + .filter((field) => ['textarea'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: field.name, + multilineText: value[field.name], + })), + texts: customFields + .filter((field) => ['text'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: field.name, + text: value[field.name], + })), + }, + }; + + return inputSchema.parse(mappedInput); } -export const registerCustomer = async ( +export async function registerCustomer( + prevState: { lastResult: SubmissionResult | null; fields: Array> }, formData: FormData, - reCaptchaToken?: string, -): Promise => { +) { const t = await getTranslations('Register'); + const locale = await getLocale(); - formData.delete('customer-confirmPassword'); - - const parsedData = parseRegisterCustomerFormData(formData); + const submission = parseWithZod(formData, { schema: schema(prevState.fields) }); - if (!isRegisterCustomerInput(parsedData)) { + if (submission.status !== 'success') { return { - status: 'error', - message: t('Errors.inputError'), + lastResult: submission.reply(), + fields: prevState.fields, }; } try { + const input = parseRegisterCustomerInput(submission.value, prevState.fields); const response = await client.fetch({ document: RegisterCustomerMutation, variables: { - input: parsedData, - ...(reCaptchaToken && { reCaptchaV2: { token: reCaptchaToken } }), - }, - fetchOptions: { - cache: 'no-store', + input, + // ...(recaptchaToken && { reCaptchaV2: { token: recaptchaToken } }), }, + fetchOptions: { cache: 'no-store' }, }); const result = response.data.customer.registerCustomer; if (result.errors.length > 0) { - result.errors.forEach((error) => { - throw new Error(error.message); - }); + return { + lastResult: submission.reply({ formErrors: result.errors.map((error) => error.message) }), + fields: prevState.fields, + }; } - return { status: 'success', message: t('Form.successMessage') }; + await signIn('credentials', { + email: input.email, + password: input.password, + // We want to use next/navigation for the redirect as it + // follows basePath and trailing slash configurations. + redirect: false, + }); } catch (error) { // eslint-disable-next-line no-console console.error(error); if (error instanceof BigCommerceAPIError) { return { - status: 'error', - message: t('Errors.apiError'), + lastResult: submission.reply({ formErrors: [t('Errors.apiError')] }), + fields: prevState.fields, }; } return { - status: 'error', - message: t('Errors.error'), + lastResult: submission.reply({ formErrors: [t('Errors.error')] }), + fields: prevState.fields, }; } -}; + + return redirect({ href: '/account', locale }); +} diff --git a/core/app/[locale]/(default)/(auth)/register/_components/register-customer-form.tsx b/core/app/[locale]/(default)/(auth)/register/_components/register-customer-form.tsx deleted file mode 100644 index 573ace62fc..0000000000 --- a/core/app/[locale]/(default)/(auth)/register/_components/register-customer-form.tsx +++ /dev/null @@ -1,394 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, MouseEvent, useRef, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import ReCaptcha from 'react-google-recaptcha'; -import { toast } from 'react-hot-toast'; - -import { ExistingResultType } from '~/client/util'; -import { - Checkboxes, - createFieldName, - CUSTOMER_FIELDS_TO_EXCLUDE, - DateField, - FieldNameToFieldId, - FieldWrapper, - FULL_NAME_FIELDS, - MultilineText, - NumbersOnly, - Password, - Picklist, - RadioButtons, - Text, -} from '~/components/form-fields'; -import { - createDatesValidationHandler, - createMultilineTextValidationHandler, - createNumbersInputValidationHandler, - createPreSubmitCheckboxesValidationHandler, - createPreSubmitPicklistValidationHandler, - createRadioButtonsValidationHandler, - isAddressOrAccountFormField, -} from '~/components/form-fields/shared/field-handlers'; -import { Button } from '~/components/ui/button'; -import { Field, Form, FormSubmit } from '~/components/ui/form'; - -import { login } from '../_actions/login'; -import { registerCustomer } from '../_actions/register-customer'; -import { getRegisterCustomerQuery } from '../page-data'; - -type CustomerFields = ExistingResultType['customerFields']; -type AddressFields = ExistingResultType['addressFields']; - -interface RegisterCustomerProps { - addressFields: AddressFields; - customerFields: CustomerFields; - reCaptchaSettings?: { - isEnabledOnStorefront: boolean; - siteKey: string; - }; -} - -interface SumbitMessages { - messages: { - submit: string; - submitting: string; - }; -} - -const SubmitButton = ({ messages }: SumbitMessages) => { - const { pending } = useFormStatus(); - - return ( - - ); -}; - -export const RegisterCustomerForm = ({ - addressFields, - customerFields, - reCaptchaSettings, -}: RegisterCustomerProps) => { - const form = useRef(null); - - const [textInputValid, setTextInputValid] = useState>({}); - const [passwordValid, setPassswordValid] = useState>({ - [FieldNameToFieldId.password]: true, - [FieldNameToFieldId.confirmPassword]: true, - }); - const [numbersInputValid, setNumbersInputValid] = useState>({}); - const [datesValid, setDatesValid] = useState>({}); - const [radioButtonsValid, setRadioButtonsValid] = useState>({}); - const [picklistValid, setPicklistValid] = useState>({}); - const [checkboxesValid, setCheckboxesValid] = useState>({}); - const [multiTextValid, setMultiTextValid] = useState>({}); - - const reCaptchaRef = useRef(null); - const [reCaptchaToken, setReCaptchaToken] = useState(''); - const [isReCaptchaValid, setReCaptchaValid] = useState(true); - - const t = useTranslations('Register.Form'); - - const handleTextInputValidation = (e: ChangeEvent) => { - const fieldId = Number(e.target.id.split('-')[1]); - - const validityState = e.target.validity; - const validationStatus = validityState.valueMissing || validityState.typeMismatch; - - setTextInputValid({ ...textInputValid, [fieldId]: !validationStatus }); - }; - const handleNumbersInputValidation = createNumbersInputValidationHandler( - setNumbersInputValid, - numbersInputValid, - ); - const handleMultiTextValidation = createMultilineTextValidationHandler( - setMultiTextValid, - multiTextValid, - ); - const handleDatesValidation = createDatesValidationHandler(setDatesValid, datesValid); - const handlePasswordValidation = (e: ChangeEvent) => { - const fieldId = e.target.id.split('-')[1] ?? ''; - - switch (FieldNameToFieldId[Number(fieldId)]) { - case 'password': { - setPassswordValid((prevState) => ({ - ...prevState, - [fieldId]: !e.target.validity.valueMissing, - })); - - return; - } - - case 'confirmPassword': { - const confirmPassword = e.target.value; - const field = customerFields.find( - ({ entityId }) => entityId === Number(FieldNameToFieldId.password), - ); - - if (!isAddressOrAccountFormField(field)) { - return; - } - - const passwordFieldName = createFieldName(field, 'customer'); - const password = new FormData(e.target.form ?? undefined).get(passwordFieldName); - - setPassswordValid((prevState) => ({ - ...prevState, - [fieldId]: password === confirmPassword && !e.target.validity.valueMissing, - })); - - return; - } - - default: { - setPassswordValid((prevState) => ({ - ...prevState, - [fieldId]: !e.target.validity.valueMissing, - })); - } - } - }; - - const handleRadioButtonsChange = createRadioButtonsValidationHandler( - setRadioButtonsValid, - radioButtonsValid, - ); - const validatePicklistFields = createPreSubmitPicklistValidationHandler( - [...customerFields, ...addressFields], - setPicklistValid, - ); - const validateCheckboxFields = createPreSubmitCheckboxesValidationHandler( - [...customerFields, ...addressFields], - setCheckboxesValid, - ); - const preSubmitFieldsValidation = ( - e: MouseEvent & { target: HTMLButtonElement }, - ) => { - if (e.target.nodeName === 'BUTTON' && e.target.type === 'submit') { - validatePicklistFields(form.current); - validateCheckboxFields(form.current); - } - }; - - const onReCaptchaChange = (token: string | null) => { - if (!token) { - setReCaptchaValid(false); - - return; - } - - setReCaptchaToken(token); - setReCaptchaValid(true); - }; - - const onSubmit = async (formData: FormData) => { - if (formData.get('customer-password') !== formData.get('customer-confirmPassword')) { - toast.error(t('confirmPassword'), { - icon: , - }); - - window.scrollTo({ - top: 0, - behavior: 'smooth', - }); - - return; - } - - if (reCaptchaSettings?.isEnabledOnStorefront && !reCaptchaToken) { - setReCaptchaValid(false); - - return; - } - - setReCaptchaValid(true); - - const { status, message } = await registerCustomer(formData, reCaptchaToken); - - if (status === 'error') { - toast.error(message, { - icon: , - }); - - return; - } - - toast.success(message, { - icon: , - }); - - await login(formData); - }; - - return ( -
-
- {addressFields.map((field) => { - const fieldId = field.entityId; - const fieldName = createFieldName(field, 'customer'); - - if (field.__typename === 'TextFormField' && FULL_NAME_FIELDS.includes(fieldId)) { - return ( - - - - ); - } - - return null; - })} -
-
- {customerFields - .filter((field) => !CUSTOMER_FIELDS_TO_EXCLUDE.includes(field.entityId)) - .map((field) => { - const fieldId = field.entityId; - const fieldName = createFieldName(field, 'customer'); - - switch (field.__typename) { - case 'TextFormField': - return ( - - - - ); - - case 'PasswordFormField': - return ( - - - - ); - - case 'MultilineTextFormField': { - return ( - - - - ); - } - - case 'NumberFormField': { - return ( - - - - ); - } - - case 'DateFormField': { - return ( - - - - ); - } - - case 'RadioButtonsFormField': { - return ( - - - - ); - } - - case 'PicklistFormField': { - return ( - - - - ); - } - - case 'CheckboxesFormField': { - return ( - - - - ); - } - - default: - return null; - } - })} - {reCaptchaSettings?.isEnabledOnStorefront && ( - - - {!isReCaptchaValid && ( - - {t('recaptchaText')} - - )} - - )} -
- - - - -
- ); -}; diff --git a/core/app/[locale]/(default)/(auth)/register/page-data.ts b/core/app/[locale]/(default)/(auth)/register/page-data.ts index 919742b8fa..8789d2a4b3 100644 --- a/core/app/[locale]/(default)/(auth)/register/page-data.ts +++ b/core/app/[locale]/(default)/(auth)/register/page-data.ts @@ -3,7 +3,7 @@ import { cache } from 'react'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; -import { FormFieldsFragment } from '~/components/form-fields/fragment'; +import { FormFieldsFragment } from '~/data-transformers/form-field-transformer/fragment'; import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; const RegisterCustomerQuery = graphql( @@ -26,29 +26,12 @@ const RegisterCustomerQuery = graphql( } } settings { - contact { - country - } reCaptcha { isEnabledOnStorefront siteKey } } } - geography { - countries { - code - entityId - name - __typename - statesOrProvinces { - abbreviation - entityId - name - __typename - } - } - } } `, [FormFieldsFragment], @@ -68,7 +51,7 @@ interface Props { }; } -export const getRegisterCustomerQuery = cache(async ({ address, customer }: Props = {}) => { +export const getRegisterCustomerQuery = cache(async ({ address, customer }: Props) => { const customerAccessToken = await getSessionCustomerAccessToken(); const response = await client.fetch({ @@ -86,20 +69,15 @@ export const getRegisterCustomerQuery = cache(async ({ address, customer }: Prop const addressFields = response.data.site.settings?.formFields.shippingAddress; const customerFields = response.data.site.settings?.formFields.customer; - const countries = response.data.geography.countries; - const defaultCountry = response.data.site.settings?.contact?.country; - const reCaptchaSettings = await bypassReCaptcha(response.data.site.settings?.reCaptcha); - if (!addressFields || !customerFields || !countries) { + if (!addressFields || !customerFields) { return null; } return { addressFields, customerFields, - countries, - defaultCountry, reCaptchaSettings, }; }); diff --git a/core/app/[locale]/(default)/(auth)/register/page.tsx b/core/app/[locale]/(default)/(auth)/register/page.tsx index 3e272f9247..4ab87dc42f 100644 --- a/core/app/[locale]/(default)/(auth)/register/page.tsx +++ b/core/app/[locale]/(default)/(auth)/register/page.tsx @@ -1,9 +1,18 @@ import { notFound } from 'next/navigation'; import { getTranslations } from 'next-intl/server'; -import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; - -import { RegisterCustomerForm } from './_components/register-customer-form'; +// TODO: Add recaptcha token +// import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; + +import { SignUpSection } from '@/vibes/soul/sections/sign-up-section'; +import { formFieldTransformer } from '~/data-transformers/form-field-transformer'; +import { + CUSTOMER_FIELDS_TO_EXCLUDE, + FULL_NAME_FIELDS, +} from '~/data-transformers/form-field-transformer/utils'; +import { exists } from '~/lib/utils'; + +import { registerCustomer } from './_actions/register-customer'; import { getRegisterCustomerQuery } from './page-data'; export async function generateMetadata() { @@ -26,19 +35,24 @@ export default async function Register() { notFound(); } - const { addressFields, customerFields, reCaptchaSettings } = registerCustomerData; - const reCaptcha = await bypassReCaptcha(reCaptchaSettings); + const { addressFields, customerFields } = registerCustomerData; + // const reCaptcha = await bypassReCaptcha(reCaptchaSettings); return ( -
-

{t('heading')}

- -
+ FULL_NAME_FIELDS.includes(field.entityId)) + .map(formFieldTransformer) + .filter(exists), + ...customerFields + .filter((field) => !CUSTOMER_FIELDS_TO_EXCLUDE.includes(field.entityId)) + .map(formFieldTransformer) + .filter(exists), + ]} + submitLabel={t('Form.submit')} + title={t('heading')} + /> ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/(faceted)/_components/faceted-search.tsx b/core/app/[locale]/(default)/(faceted)/_components/faceted-search.tsx deleted file mode 100644 index 98fa69ac1d..0000000000 --- a/core/app/[locale]/(default)/(faceted)/_components/faceted-search.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useTranslations } from 'next-intl'; -import { ComponentPropsWithoutRef, PropsWithChildren } from 'react'; - -import { Props as FacetProps, Facets } from './facets'; -import { RefineBy, Props as RefineByProps } from './refine-by'; - -interface Props extends FacetProps, RefineByProps, ComponentPropsWithoutRef<'aside'> { - headingId: string; -} - -export const FacetedSearch = ({ - facets, - headingId, - pageType, - children, - ...props -}: PropsWithChildren) => { - const t = useTranslations('FacetedGroup.FacetedSearch'); - - return ( - - ); -}; diff --git a/core/app/[locale]/(default)/(faceted)/_components/facets.tsx b/core/app/[locale]/(default)/(faceted)/_components/facets.tsx deleted file mode 100644 index 96cc0a0f94..0000000000 --- a/core/app/[locale]/(default)/(faceted)/_components/facets.tsx +++ /dev/null @@ -1,332 +0,0 @@ -'use client'; - -import { useSearchParams } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { FormEvent, useRef, useTransition } from 'react'; - -import { Link } from '~/components/link'; -import { Accordions } from '~/components/ui/accordions'; -import { Button } from '~/components/ui/button'; -import { Checkbox, Input, Label } from '~/components/ui/form'; -import { Rating } from '~/components/ui/rating'; -import { usePathname, useRouter } from '~/i18n/routing'; -import { cn } from '~/lib/utils'; - -import type { Facet, PageType } from '../types'; - -interface ProductCountProps { - shouldDisplay: boolean; - count: number; -} - -const ProductCount = ({ shouldDisplay, count }: ProductCountProps) => { - if (!shouldDisplay) { - return null; - } - - return ( - - {count} products - - ); -}; - -export interface Props { - facets: Facet[]; - pageType: PageType; -} - -export const Facets = ({ facets, pageType }: Props) => { - const ref = useRef(null); - const router = useRouter(); - const pathname = usePathname(); - const [isPending, startTransition] = useTransition(); - - const searchParams = useSearchParams(); - const t = useTranslations('FacetedGroup.FacetedSearch.Facets'); - - const defaultOpenFacets = facets - .filter((facet) => !facet.isCollapsedByDefault) - .map((facet) => facet.name); - - const submitForm = () => { - ref.current?.requestSubmit(); - }; - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - - const formData = new FormData(e.currentTarget); - const sortParam = searchParams.get('sort'); - const searchParam = searchParams.get('term'); - const filteredSearchParams = Array.from(formData.entries()) - .filter((entry): entry is [string, string] => { - return !(entry instanceof File); - }) - .filter(([, value]) => value !== ''); - - const newSearchParams = new URLSearchParams(filteredSearchParams); - - // We want to keep the sort param if it exists - if (sortParam) { - newSearchParams.append('sort', sortParam); - } - - // We want to keep the search param if it exists - if (searchParam) { - newSearchParams.append('term', searchParam); - } - - startTransition(() => { - router.push(`${pathname}?${newSearchParams.toString()}`); - }); - }; - - const accordions = facets.map((facet) => { - let content = null; - - if (facet.__typename === 'BrandSearchFilter' && pageType !== 'brand') { - content = ( - <> - {facet.brands.map((brand) => { - const normalizedBrandName = brand.name.replace(/\s/g, '-').toLowerCase(); - const id = `${normalizedBrandName}-${brand.entityId}`; - const labelId = `${normalizedBrandName}-${brand.entityId}-label`; - - const key = `${brand.entityId}-${brand.isSelected.toString()}`; - - return ( -
- - -
- ); - })} - - ); - } - - if (facet.__typename === 'CategorySearchFilter' && pageType !== 'category') { - content = ( - <> - {facet.categories.map((category) => { - const normalizedCategoryName = category.name.replace(/\s/g, '-').toLowerCase(); - const id = `${normalizedCategoryName}-${category.entityId}`; - const labelId = `${normalizedCategoryName}-${category.entityId}-label`; - - const key = `${category.entityId}-${category.isSelected.toString()}`; - - return ( -
- - -
- ); - })} - - ); - } - - if (facet.__typename === 'ProductAttributeSearchFilter') { - content = ( - <> - {facet.attributes.map((attribute) => { - const normalizedFilterName = facet.filterName.replace(/\s/g, '-').toLowerCase(); - const normalizedAttributeValue = attribute.value.replace(/\s/g, '-').toLowerCase(); - const id = `${normalizedFilterName}-${attribute.value}`; - const labelId = `${normalizedFilterName}-${normalizedAttributeValue}-label`; - - const key = `${attribute.value}-${attribute.value}-${attribute.isSelected.toString()}`; - - return ( -
- - -
- ); - })} - - ); - } - - if (facet.__typename === 'RatingSearchFilter') { - content = ( - <> - {facet.ratings - .filter((rating) => rating.value !== '5') - .sort((a, b) => parseInt(b.value, 10) - parseInt(a.value, 10)) - .map((rating) => { - const key = `${facet.name}-${rating.value}-${rating.isSelected.toString()}`; - - const search = new URLSearchParams(searchParams); - - search.set('minRating', rating.value); - - return ( - -
- -
- - {t('rating', { currentRating: rating.value })} - - - - ); - })} - - ); - } - - if (facet.__typename === 'PriceSearchFilter') { - content = ( -
- - - -
- ); - } - - if (facet.__typename === 'OtherSearchFilter') { - content = ( - <> - {facet.freeShipping && ( -
- - -
- )} - {facet.isFeatured && ( -
- - -
- )} - {facet.isInStock && ( -
- - -
- )} - - ); - } - - return { - content, - title: facet.name, - }; - }); - - return ( -
- - - ); -}; diff --git a/core/app/[locale]/(default)/(faceted)/_components/mobile-side-nav.tsx b/core/app/[locale]/(default)/(faceted)/_components/mobile-side-nav.tsx deleted file mode 100644 index 11de799477..0000000000 --- a/core/app/[locale]/(default)/(faceted)/_components/mobile-side-nav.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; - -import { Filter } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { PropsWithChildren, useEffect, useState } from 'react'; - -import { Button } from '~/components/ui/button'; -import { Sheet } from '~/components/ui/sheet'; - -export const MobileSideNav = ({ children }: PropsWithChildren) => { - const [open, setOpen] = useState(false); - const t = useTranslations('FacetedGroup.MobileSideNav'); - - useEffect(() => { - setOpen(false); - }, [children]); - - return ( - - {t('showFilters')} - - } - > - {children} - - ); -}; diff --git a/core/app/[locale]/(default)/(faceted)/_components/refine-by.tsx b/core/app/[locale]/(default)/(faceted)/_components/refine-by.tsx deleted file mode 100644 index 15a55475ce..0000000000 --- a/core/app/[locale]/(default)/(faceted)/_components/refine-by.tsx +++ /dev/null @@ -1,160 +0,0 @@ -'use client'; - -import { useSearchParams } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { useTransition } from 'react'; - -import { Tag } from '~/components/ui/tag'; -import { usePathname, useRouter } from '~/i18n/routing'; - -import type { Facet, PageType, PublicParamKeys } from '../types'; - -export interface Props { - facets: Facet[]; - pageType: PageType; -} - -interface FacetProps { - key: Key; - display_name: string; - value: string; -} - -const mapFacetsToRefinements = ({ facets, pageType }: Props) => - facets - .map>>((facet) => { - switch (facet.__typename) { - case 'BrandSearchFilter': - if (pageType === 'brand') { - return []; - } - - return facet.brands - .filter((brand) => brand.isSelected) - .map>(({ name, entityId }) => ({ - key: 'brand', - display_name: name, - value: String(entityId), - })); - - case 'CategorySearchFilter': - if (pageType === 'category') { - return []; - } - - return facet.categories - .filter((category) => category.isSelected) - .map>(({ name, entityId }) => ({ - key: 'categoryIn', - display_name: name, - value: String(entityId), - })); - - case 'RatingSearchFilter': - return facet.ratings - .filter((rating) => rating.isSelected) - .map>(({ value }) => ({ - key: 'minRating', - display_name: `Rating ${value} & up`, - value, - })); - - case 'ProductAttributeSearchFilter': - return facet.attributes - .filter(({ isSelected }) => isSelected) - .map>(({ value }) => { - return { - key: `attr_${facet.filterName}`, - display_name: value, - value, - }; - }); - - case 'OtherSearchFilter': { - const { freeShipping, isFeatured, isInStock } = facet; - - const shipping: FacetProps | undefined = freeShipping?.isSelected - ? { - key: 'shipping', - display_name: 'Free Shipping', - value: 'free_shipping', - } - : undefined; - - const stock: FacetProps | undefined = isInStock?.isSelected - ? { - key: 'stock', - display_name: 'In Stock', - value: 'in_stock', - } - : undefined; - - const featured: FacetProps | undefined = isFeatured?.isSelected - ? { - key: 'isFeatured', - display_name: 'Is Featured', - value: 'on', - } - : undefined; - - return [shipping, stock, featured].filter( - (props): props is FacetProps => props !== undefined, - ); - } - - default: - return []; - } - }) - .flat(); - -export const RefineBy = (props: Props) => { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const [isPending, startTransition] = useTransition(); - - const refinements = mapFacetsToRefinements(props); - const t = useTranslations('FacetedGroup.FacetedSearch.RefineBy'); - - const removeRefinement = (refinement: FacetProps) => { - const filteredParams = Array.from(searchParams.entries()).filter( - ([key, value]) => refinement.key !== key || refinement.value !== value, - ); - - const params = new URLSearchParams(filteredParams); - - startTransition(() => { - router.push(`${pathname}?${params.toString()}`); - }); - }; - - const clearAllRefinements = () => { - startTransition(() => { - router.push(pathname); - }); - }; - - if (!refinements.length) { - return null; - } - - return ( -
-
-

{t('refineBy')}

- {/* TODO: Make subtle variant */} - -
-
    - {refinements.map((refinement) => ( -
  • - removeRefinement(refinement)} /> -
  • - ))} -
-
- ); -}; diff --git a/core/app/[locale]/(default)/(faceted)/_components/sort-by.tsx b/core/app/[locale]/(default)/(faceted)/_components/sort-by.tsx deleted file mode 100644 index 33adc1f932..0000000000 --- a/core/app/[locale]/(default)/(faceted)/_components/sort-by.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import { useSearchParams } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { useTransition } from 'react'; - -import { Select } from '~/components/ui/form'; -import { usePathname, useRouter } from '~/i18n/routing'; - -export function SortBy() { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const [isPending, startTransition] = useTransition(); - - const t = useTranslations('FacetedGroup.SortBy'); - const value = searchParams.get('sort') ?? 'featured'; - - const onSort = (sortValue: string) => { - const params = new URLSearchParams(searchParams); - - params.set('sort', sortValue); - params.delete('before'); - params.delete('after'); - - startTransition(() => { - router.push(`${pathname}?${params.toString()}`); - }); - }; - - return ( -
- - - - {isRequired && ( - <> - - {t(fieldNameById ?? 'empty')} - - - {t(fieldNameById)} - - - )} - - ); -}; diff --git a/core/app/[locale]/(default)/account/settings/_components/update-settings-form.tsx b/core/app/[locale]/(default)/account/settings/_components/update-settings-form.tsx deleted file mode 100644 index 68b778a8c8..0000000000 --- a/core/app/[locale]/(default)/account/settings/_components/update-settings-form.tsx +++ /dev/null @@ -1,381 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, MouseEvent, useRef, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import { toast } from 'react-hot-toast'; - -import { ExistingResultType } from '~/client/util'; -import { - Checkboxes, - createFieldName, - DateField, - FieldWrapper, - getPreviouslySubmittedValue, - MultilineText, - NumbersOnly, - Password, - Picklist, - RadioButtons, -} from '~/components/form-fields'; -import { - createDatesValidationHandler, - createMultilineTextValidationHandler, - createNumbersInputValidationHandler, - createPasswordValidationHandler, - createPreSubmitCheckboxesValidationHandler, - createPreSubmitPicklistValidationHandler, - createRadioButtonsValidationHandler, - createTextInputValidationHandler, -} from '~/components/form-fields/shared/field-handlers'; -import { Link } from '~/components/link'; -import { Button } from '~/components/ui/button'; -import { Form, FormSubmit } from '~/components/ui/form'; - -import { updateCustomer } from '../_actions/update-customer'; -import { getCustomerSettingsQuery } from '../page-data'; - -import { TextField } from './text-field'; - -type CustomerInfo = ExistingResultType['customerInfo']; -type CustomerFields = ExistingResultType['customerFields']; -type AddressFields = ExistingResultType['addressFields']; - -interface FormProps { - addressFields: AddressFields; - customerInfo: CustomerInfo; - customerFields: CustomerFields; -} - -interface SumbitMessages { - messages: { - submit: string; - submitting: string; - }; -} - -export enum FieldNameToFieldId { - email = 1, - firstName = 4, - lastName, -} - -type FieldUnionType = keyof typeof FieldNameToFieldId; - -const isExistedField = (name: unknown): name is FieldUnionType => { - if (typeof name === 'string' && name in FieldNameToFieldId) { - return true; - } - - return false; -}; - -const SubmitButton = ({ messages }: SumbitMessages) => { - const { pending } = useFormStatus(); - - return ( - - ); -}; - -export const UpdateSettingsForm = ({ addressFields, customerFields, customerInfo }: FormProps) => { - const form = useRef(null); - - const [textInputValid, setTextInputValid] = useState>({}); - const [multiTextValid, setMultiTextValid] = useState>({}); - const [numbersInputValid, setNumbersInputValid] = useState>({}); - const [radioButtonsValid, setRadioButtonsValid] = useState>({}); - const [picklistValid, setPicklistValid] = useState>({}); - const [checkboxesValid, setCheckboxesValid] = useState>({}); - const [datesValid, setDatesValid] = useState>({}); - const [passwordValid, setPasswordValid] = useState>({}); - - const t = useTranslations('Account.Settings'); - - const handleTextInputValidation = (e: ChangeEvent) => { - const fieldId = Number(e.target.id.split('-')[1]); - - const validityState = e.target.validity; - const validationStatus = validityState.valueMissing || validityState.typeMismatch; - - setTextInputValid({ ...textInputValid, [fieldId]: !validationStatus }); - }; - const handleMultiTextValidation = createMultilineTextValidationHandler( - setMultiTextValid, - multiTextValid, - ); - const handleNumbersInputValidation = createNumbersInputValidationHandler( - setNumbersInputValid, - numbersInputValid, - ); - const handleDatesValidation = createDatesValidationHandler(setDatesValid, datesValid); - const handleRadioButtonsChange = createRadioButtonsValidationHandler( - setRadioButtonsValid, - radioButtonsValid, - ); - const validatePicklistFields = createPreSubmitPicklistValidationHandler( - customerFields, - setPicklistValid, - ); - const validateCheckboxFields = createPreSubmitCheckboxesValidationHandler( - customerFields, - setCheckboxesValid, - ); - const handlePasswordValidation = createPasswordValidationHandler( - setPasswordValid, - customerFields, - ); - const handleCustomTextValidation = createTextInputValidationHandler( - setTextInputValid, - textInputValid, - ); - const preSubmitFieldsValidation = ( - e: MouseEvent & { target: HTMLButtonElement }, - ) => { - if (e.target.nodeName === 'BUTTON' && e.target.type === 'submit') { - validatePicklistFields(form.current); - validateCheckboxFields(form.current); - } - }; - - const onSubmit = async (formData: FormData) => { - const { status, message } = await updateCustomer(formData); - - if (status === 'error') { - toast.error(message, { - icon: , - }); - - return; - } - - toast.success(message, { - icon: , - }); - - window.scrollTo({ - top: 0, - behavior: 'smooth', - }); - }; - - return ( -
-
- {addressFields.map((field) => { - const fieldName = FieldNameToFieldId[field.entityId] ?? ''; - - if (!isExistedField(fieldName)) { - return null; - } - - return ( - - ); - })} -
- field.entityId === FieldNameToFieldId.email)?.label ?? - '' - } - name="customer-email" - onChange={handleTextInputValidation} - type="email" - /> -
- {customerFields - .filter(({ isBuiltIn }) => !isBuiltIn) - .map((field) => { - const fieldId = field.entityId; - const fieldName = createFieldName(field, 'customer'); - const previouslySubmittedField = customerInfo.formFields.find( - ({ entityId: id }) => id === fieldId, - ); - - switch (field.__typename) { - case 'NumberFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).NumberFormField; - - return ( - - - - ); - } - - case 'CheckboxesFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).CheckboxesFormField; - - return ( - - - - ); - } - - case 'MultilineTextFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).MultilineTextFormField; - - return ( - - - - ); - } - - case 'DateFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).DateFormField; - - return ( - - - - ); - } - - case 'RadioButtonsFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).MultipleChoiceFormField; - - return ( - - - - ); - } - - case 'PicklistFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).MultipleChoiceFormField; - - return ( - - - - ); - } - - case 'TextFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).TextFormField; - - return ( - - id === fieldId)?.label ?? ''} - name={fieldName} - onChange={handleCustomTextValidation} - type="text" - /> - - ); - } - - case 'PasswordFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).PasswordFormField; - - return ( - - - - ); - } - - default: - return null; - } - })} -
- - - - - - {t('changePassword')} - -
-
-
- ); -}; diff --git a/core/app/[locale]/(default)/account/settings/change-password/_actions/change-password.ts b/core/app/[locale]/(default)/account/settings/change-password/_actions/change-password.ts deleted file mode 100644 index 5676e71375..0000000000 --- a/core/app/[locale]/(default)/account/settings/change-password/_actions/change-password.ts +++ /dev/null @@ -1,94 +0,0 @@ -'use server'; - -import { getTranslations } from 'next-intl/server'; -import { z } from 'zod'; - -import { getSessionCustomerAccessToken } from '~/auth'; -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; - -const ChangePasswordFieldsSchema = z.object({ - customerId: z.string(), - customerToken: z.string(), - currentPassword: z.string().min(1), - newPassword: z.string().min(1), - confirmPassword: z.string().min(1), -}); - -const CustomerChangePasswordSchema = ChangePasswordFieldsSchema.omit({ - customerId: true, - customerToken: true, -}); - -const CustomerChangePasswordMutation = graphql(` - mutation CustomerChangePasswordMutation($input: ChangePasswordInput!) { - customer { - changePassword(input: $input) { - errors { - ... on ValidationError { - message - path - } - ... on CustomerDoesNotExistError { - message - } - ... on CustomerPasswordError { - message - } - ... on CustomerNotLoggedInError { - message - } - } - } - } - } -`); - -interface ChangePasswordResponse { - status: 'success' | 'error'; - message: string; -} - -export const changePassword = async (formData: FormData): Promise => { - const t = await getTranslations('Account.Settings.ChangePassword'); - const customerAccessToken = await getSessionCustomerAccessToken(); - - try { - const parsedData = CustomerChangePasswordSchema.parse({ - newPassword: formData.get('new-password'), - currentPassword: formData.get('current-password'), - confirmPassword: formData.get('confirm-password'), - }); - - const response = await client.fetch({ - document: CustomerChangePasswordMutation, - variables: { - input: { - currentPassword: parsedData.currentPassword, - newPassword: parsedData.newPassword, - }, - }, - customerAccessToken, - }); - - const result = response.data.customer.changePassword; - - if (result.errors.length > 0) { - result.errors.forEach((error) => { - // Throw the first error message, as we should only handle one error at a time - throw new Error(error.message); - }); - } - - return { status: 'success', message: t('confirmChangePassword') }; - } catch (error: unknown) { - if (error instanceof Error || error instanceof z.ZodError) { - return { - status: 'error', - message: error.message, - }; - } - - return { status: 'error', message: t('error') }; - } -}; diff --git a/core/app/[locale]/(default)/account/settings/change-password/_components/change-password-form.tsx b/core/app/[locale]/(default)/account/settings/change-password/_components/change-password-form.tsx deleted file mode 100644 index 78c563b7de..0000000000 --- a/core/app/[locale]/(default)/account/settings/change-password/_components/change-password-form.tsx +++ /dev/null @@ -1,241 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, useRef, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import { toast } from 'react-hot-toast'; -import { z } from 'zod'; - -import { logout } from '~/components/header/_actions/logout'; -import { Link } from '~/components/link'; -import { Button } from '~/components/ui/button'; -import { - Field, - FieldControl, - FieldLabel, - FieldMessage, - Form, - FormSubmit, - Input, -} from '~/components/ui/form'; - -import { changePassword } from '../_actions/change-password'; - -const ChangePasswordFieldsSchema = z.object({ - customerId: z.string(), - customerToken: z.string(), - currentPassword: z.string().min(1), - newPassword: z.string().min(1), - confirmPassword: z.string().min(1), -}); - -const CustomerChangePasswordSchema = ChangePasswordFieldsSchema.omit({ - customerId: true, - customerToken: true, -}); - -type Passwords = z.infer; - -const validateAgainstConfirmPassword = ({ - newPassword, - confirmPassword, -}: { - newPassword: Passwords['newPassword']; - confirmPassword: Passwords['confirmPassword']; -}): boolean => newPassword === confirmPassword; - -const validateAgainstCurrentPassword = ({ - newPassword, - currentPassword, -}: { - newPassword: Passwords['newPassword']; - currentPassword: Passwords['currentPassword']; -}): boolean => newPassword !== currentPassword; - -const validatePasswords = ( - validationField: 'new-password' | 'confirm-password', - formData?: FormData, -) => { - if (!formData) { - return false; - } - - if (validationField === 'new-password') { - return CustomerChangePasswordSchema.omit({ confirmPassword: true }) - .refine(validateAgainstCurrentPassword) - .safeParse({ - currentPassword: formData.get('current-password'), - newPassword: formData.get('new-password'), - }).success; - } - - return CustomerChangePasswordSchema.refine(validateAgainstConfirmPassword).safeParse({ - currentPassword: formData.get('current-password'), - newPassword: formData.get('new-password'), - confirmPassword: formData.get('confirm-password'), - }).success; -}; - -const SubmitButton = () => { - const { pending } = useFormStatus(); - const t = useTranslations('Account.Settings.ChangePassword'); - - return ( - - ); -}; - -export const ChangePasswordForm = () => { - const form = useRef(null); - const t = useTranslations('Account.Settings.ChangePassword'); - - const [isCurrentPasswordValid, setIsCurrentPasswordValid] = useState(true); - const [isNewPasswordValid, setIsNewPasswordValid] = useState(true); - const [isConfirmPasswordValid, setIsConfirmPasswordValid] = useState(true); - - const handleCurrentPasswordChange = (e: ChangeEvent) => - setIsCurrentPasswordValid(!e.target.validity.valueMissing); - - const validateNewAndConfirmPasswords = (formData: FormData) => { - const newPasswordValid = validatePasswords('new-password', formData); - const confirmPassword = formData.get('confirm-password'); - const confirmPasswordValid = confirmPassword - ? validatePasswords('confirm-password', formData) - : true; - - setIsNewPasswordValid(newPasswordValid); - setIsConfirmPasswordValid(confirmPasswordValid); - }; - - const handlePasswordChange = (e: ChangeEvent) => { - let formData; - - if (e.target.form) { - formData = new FormData(e.target.form); - } - - if (formData) { - validateNewAndConfirmPasswords(formData); - } - }; - - const handleChangePassword = async (formData: FormData) => { - const { status, message } = await changePassword(formData); - - if (status === 'error') { - toast.error(message, { - icon: , - }); - - return; - } - - toast.success(message, { - icon: , - }); - - await logout(); - }; - - return ( -
- - - {t('currentPasswordLabel')} - - - - - - {t('notEmptyMessage')} - - - - - {t('newPasswordLabel')} - - - - - - {t('notEmptyMessage')} - - {!isNewPasswordValid && ( - - {t('newPasswordValidationMessage')} - - )} - - - - {t('confirmPasswordLabel')} - - - - - - {t('notEmptyMessage')} - - {!isConfirmPasswordValid && ( - - {t('confirmPasswordValidationMessage')} - - )} - -
- - - - -
-
- ); -}; diff --git a/core/app/[locale]/(default)/account/settings/change-password/page.tsx b/core/app/[locale]/(default)/account/settings/change-password/page.tsx deleted file mode 100644 index c89fd80410..0000000000 --- a/core/app/[locale]/(default)/account/settings/change-password/page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { getTranslations, setRequestLocale } from 'next-intl/server'; - -import { TabHeading } from '../../_components/tab-heading'; - -import { ChangePasswordForm } from './_components/change-password-form'; - -export async function generateMetadata() { - const t = await getTranslations('Account.Settings.ChangePassword'); - - return { - title: t('title'), - }; -} - -interface Props { - params: Promise<{ locale: string }>; -} - -export default async function ChangePassword({ params }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - - return ( -
- - -
- ); -} - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/account/settings/page-data.tsx b/core/app/[locale]/(default)/account/settings/page-data.tsx index bb5c76ffc4..43dfa5d323 100644 --- a/core/app/[locale]/(default)/account/settings/page-data.tsx +++ b/core/app/[locale]/(default)/account/settings/page-data.tsx @@ -1,13 +1,10 @@ -import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; -import { FormFieldValuesFragment } from '~/client/fragments/form-fields-values'; -import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql, VariablesOf } from '~/client/graphql'; import { TAGS } from '~/client/tags'; -import { FormFieldsFragment } from '~/components/form-fields/fragment'; +import { FormFieldsFragment } from '~/data-transformers/form-field-transformer/fragment'; const CustomerSettingsQuery = graphql( ` @@ -19,41 +16,10 @@ const CustomerSettingsQuery = graphql( ) { customer { entityId - company email firstName lastName - phone - formFields { - entityId - name - __typename - ... on CheckboxesFormFieldValue { - valueEntityIds - values - } - ... on DateFormFieldValue { - date { - utc - } - } - ... on MultipleChoiceFormFieldValue { - valueEntityId - value - } - ... on NumberFormFieldValue { - number - } - ... on PasswordFormFieldValue { - password - } - ... on TextFormFieldValue { - text - } - ... on MultilineTextFormFieldValue { - multilineText - } - } + company } site { settings { @@ -97,7 +63,7 @@ export const getCustomerSettingsQuery = cache(async ({ address, customer }: Prop customerFilters: customer?.filters, customerSortBy: customer?.sortBy, }, - fetchOptions: { cache: 'no-store' }, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, customerAccessToken, }); @@ -115,72 +81,3 @@ export const getCustomerSettingsQuery = cache(async ({ address, customer }: Prop customerInfo, }; }); - -const GetCustomerAddressesQuery = graphql( - ` - query GetCustomerAddresses($after: String, $before: String, $first: Int, $last: Int) { - customer { - entityId - addresses(before: $before, after: $after, first: $first, last: $last) { - pageInfo { - ...PaginationFragment - } - collectionInfo { - totalItems - } - edges { - node { - entityId - firstName - lastName - address1 - address2 - city - stateOrProvince - countryCode - phone - postalCode - company - formFields { - ...FormFieldValuesFragment - } - } - } - } - } - } - `, - [PaginationFragment, FormFieldValuesFragment], -); - -export interface CustomerAddressesArgs { - after?: string; - before?: string; - limit?: number; -} - -export const getCustomerAddresses = cache( - async ({ before = '', after = '', limit = 9 }: CustomerAddressesArgs) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; - - const response = await client.fetch({ - document: GetCustomerAddressesQuery, - variables: { ...paginationArgs }, - customerAccessToken, - fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, - }); - - const addresses = response.data.customer?.addresses; - - if (!addresses) { - return undefined; - } - - return { - pageInfo: addresses.pageInfo, - addressesCount: addresses.collectionInfo?.totalItems ?? 0, - addresses: removeEdgesAndNodes({ edges: addresses.edges }), - }; - }, -); diff --git a/core/app/[locale]/(default)/account/settings/page.tsx b/core/app/[locale]/(default)/account/settings/page.tsx index c5eda14ae4..2709169ab2 100644 --- a/core/app/[locale]/(default)/account/settings/page.tsx +++ b/core/app/[locale]/(default)/account/settings/page.tsx @@ -1,9 +1,10 @@ import { notFound } from 'next/navigation'; import { getTranslations } from 'next-intl/server'; -import { TabHeading } from '../_components/tab-heading'; +import { AccountSettingsSection } from '../../../../../vibes/soul/sections/account-settings-section'; -import { UpdateSettingsForm } from './_components/update-settings-form'; +import { changePassword } from './_actions/change-password'; +import { updateCustomer } from './_actions/update-customer'; import { getCustomerSettingsQuery } from './page-data'; export async function generateMetadata() { @@ -15,20 +16,17 @@ export async function generateMetadata() { } export default async function Settings() { - const customerSettings = await getCustomerSettingsQuery({ - address: { filters: { entityIds: [4, 5, 6, 7] } }, - }); + const customerSettings = await getCustomerSettingsQuery(); if (!customerSettings) { notFound(); } return ( -
- - -
+ ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/blog/[blogId]/_components/print-button.tsx b/core/app/[locale]/(default)/blog/[blogId]/_components/print-button.tsx deleted file mode 100644 index 54ad5bf9e8..0000000000 --- a/core/app/[locale]/(default)/blog/[blogId]/_components/print-button.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import { Printer } from 'lucide-react'; -import { useTranslations } from 'next-intl'; - -export const PrintButton = () => { - const t = useTranslations('Blog.SharingLinks'); - - return ( - - ); -}; diff --git a/core/app/[locale]/(default)/blog/[blogId]/_components/sharing-links.tsx b/core/app/[locale]/(default)/blog/[blogId]/_components/sharing-links.tsx deleted file mode 100644 index 7ebfa30934..0000000000 --- a/core/app/[locale]/(default)/blog/[blogId]/_components/sharing-links.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { SiFacebook, SiLinkedin, SiPinterest, SiX } from '@icons-pack/react-simple-icons'; -import { Mail } from 'lucide-react'; -import { useTranslations } from 'next-intl'; - -import { FragmentOf, graphql } from '~/client/graphql'; - -import { PrintButton } from './print-button'; - -export const SharingLinksFragment = graphql(` - fragment SharingLinksFragment on Site { - content { - blog { - post(entityId: $entityId) { - entityId - thumbnailImage { - url: urlTemplate(lossy: true) - } - seo { - pageTitle - } - } - } - } - settings { - url { - vanityUrl - } - } - } -`); - -interface Props { - data: FragmentOf; -} - -export const SharingLinks = ({ data }: Props) => { - const t = useTranslations('Blog.SharingLinks'); - - const blogPost = data.content.blog?.post; - - if (!blogPost) { - return null; - } - - const encodedTitle = encodeURIComponent(blogPost.seo.pageTitle); - const encodedUrl = encodeURIComponent( - `${data.settings?.url.vanityUrl || ''}/blog/${blogPost.entityId}/`, - ); - - return ( - - ); -}; diff --git a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts index 6b25c564ed..fdc607c76e 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts +++ b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts @@ -4,40 +4,36 @@ import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; -import { SharingLinksFragment } from './_components/sharing-links'; - -const BlogPageQuery = graphql( - ` - query BlogPageQuery($entityId: Int!) { - site { - content { - blog { - post(entityId: $entityId) { - author - htmlBody - name - publishedDate { - utc - } - tags - thumbnailImage { - altText - url: urlTemplate(lossy: true) - } - seo { - pageTitle - metaDescription - metaKeywords - } +const BlogPageQuery = graphql(` + query BlogPageQuery($entityId: Int!) { + site { + content { + blog { + name + path + post(entityId: $entityId) { + author + htmlBody + name + publishedDate { + utc + } + tags + thumbnailImage { + altText + url: urlTemplate(lossy: true) + } + seo { + pageTitle + metaDescription + metaKeywords } } } - ...SharingLinksFragment } } - `, - [SharingLinksFragment], -); + } +`); export const getBlogPageData = cache(async ({ entityId }: { entityId: number }) => { const response = await client.fetch({ @@ -52,5 +48,5 @@ export const getBlogPageData = cache(async ({ entityId }: { entityId: number }) return null; } - return response.data.site; + return blog; }); diff --git a/core/app/[locale]/(default)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index 2513469518..22afd5dd43 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page.tsx +++ b/core/app/[locale]/(default)/blog/[blogId]/page.tsx @@ -2,24 +2,19 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getFormatter } from 'next-intl/server'; -import { Image } from '~/components/image'; -import { Link } from '~/components/link'; -import { Tag } from '~/components/ui/tag'; +import { Breadcrumb } from '@/vibes/soul/primitives/breadcrumbs'; +import { BlogPostContent, BlogPostContentBlogPost } from '@/vibes/soul/sections/blog-post-content'; -import { SharingLinks } from './_components/sharing-links'; import { getBlogPageData } from './page-data'; interface Props { - params: Promise<{ - blogId: string; - }>; + params: Promise<{ blogId: string }>; } export async function generateMetadata({ params }: Props): Promise { const { blogId } = await params; - - const data = await getBlogPageData({ entityId: Number(blogId) }); - const blogPost = data?.content.blog?.post; + const blog = await getBlogPageData({ entityId: Number(blogId) }); + const blogPost = blog?.post; if (!blogPost) { return {}; @@ -34,64 +29,63 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function Blog({ params }: Props) { - const { blogId } = await params; - +async function getBlogPost(entityId: number): Promise { const format = await getFormatter(); + const blog = await getBlogPageData({ entityId }); + const blogPost = blog?.post; - const data = await getBlogPageData({ entityId: Number(blogId) }); - const blogPost = data?.content.blog?.post; - - if (!blogPost) { + if (!blog || !blogPost) { return notFound(); } - return ( -
-

{blogPost.name}

+ return { + author: blogPost.author ?? undefined, + title: blogPost.name, + content: blogPost.htmlBody, + date: format.dateTime(new Date(blogPost.publishedDate.utc)), + image: blogPost.thumbnailImage + ? { alt: blogPost.thumbnailImage.altText, src: blogPost.thumbnailImage.url } + : undefined, + tags: blogPost.tags.map((tag) => ({ + label: tag, + link: { + href: `${blog.path}?tag=${tag}`, + }, + })), + }; +} -
- - {format.dateTime(new Date(blogPost.publishedDate.utc))} - +async function getBlogPostBreadcrumbs(entityId: number): Promise { + const blog = await getBlogPageData({ entityId }); + const blogPost = blog?.post; - {Boolean(blogPost.author) && ( - , by {blogPost.author} - )} -
+ if (!blog || !blogPost) { + return notFound(); + } - {blogPost.thumbnailImage ? ( -
- {blogPost.thumbnailImage.altText} -
- ) : ( -
-

- {blogPost.name} -

- - {format.dateTime(new Date(blogPost.publishedDate.utc))} - -
- )} + return [ + { + label: 'Home', + href: '/', + }, + { + label: blog.name, + href: blog.path, + }, + { + label: blogPost.name, + href: '#', + }, + ]; +} -
-
- {blogPost.tags.map((tag) => ( - - - - ))} -
- -
+export default async function Blog({ params }: Props) { + const { blogId } = await params; + + return ( + ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/blog/page-data.ts b/core/app/[locale]/(default)/blog/page-data.ts index ed98dcb0df..092b7f8973 100644 --- a/core/app/[locale]/(default)/blog/page-data.ts +++ b/core/app/[locale]/(default)/blog/page-data.ts @@ -1,4 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { cache } from 'react'; import { client } from '~/client'; @@ -7,6 +8,20 @@ import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; import { BlogPostCardFragment } from '~/components/blog-post-card/fragment'; +const BlogQuery = graphql(` + query BlogQuery { + site { + content { + blog { + name + description + path + } + } + } + } +`); + const BlogPostsPageQuery = graphql( ` query BlogPostsPageQuery( @@ -19,8 +34,6 @@ const BlogPostsPageQuery = graphql( site { content { blog { - name - description posts(first: $first, after: $after, last: $last, before: $before, filters: $filters) { edges { node { @@ -40,19 +53,28 @@ const BlogPostsPageQuery = graphql( [BlogPostCardFragment, PaginationFragment], ); -interface BlogPostsFiltersInput { - tagId?: string; +export interface BlogPostsFiltersInput { + tag: string | null; } interface Pagination { - limit?: number; - before?: string; - after?: string; + limit: number; + before: string | null; + after: string | null; } +export const getBlog = cache(async () => { + const response = await client.fetch({ + document: BlogQuery, + fetchOptions: { next: { revalidate } }, + }); + + return response.data.site.content.blog; +}); + export const getBlogPosts = cache( - async ({ tagId, limit = 9, before, after }: BlogPostsFiltersInput & Pagination) => { - const filterArgs = tagId ? { filters: { tags: [tagId] } } : {}; + async ({ tag, limit = 9, before, after }: BlogPostsFiltersInput & Pagination) => { + const filterArgs = tag ? { filters: { tags: [tag] } } : {}; const paginationArgs = before ? { last: limit, before } : { first: limit, after }; const response = await client.fetch({ @@ -67,12 +89,37 @@ export const getBlogPosts = cache( return null; } + const format = await getFormatter(); + return { - ...blog, - posts: { - pageInfo: blog.posts.pageInfo, - items: removeEdgesAndNodes(blog.posts), - }, + pageInfo: blog.posts.pageInfo, + posts: removeEdgesAndNodes(blog.posts).map((post) => ({ + id: String(post.entityId), + author: post.author, + content: post.plainTextSummary, + date: format.dateTime(new Date(post.publishedDate.utc)), + image: post.thumbnailImage + ? { + src: post.thumbnailImage.url, + alt: post.thumbnailImage.altText, + } + : undefined, + href: post.path, + title: post.name, + })), }; }, ); + +export async function getBlogMetaData() { + const t = await getTranslations('Blog'); + const blog = await getBlog(); + + return { + title: blog?.name ?? t('title'), + description: + blog?.description && blog.description.length > 150 + ? `${blog.description.substring(0, 150)}...` + : blog?.description, + }; +} diff --git a/core/app/[locale]/(default)/blog/page.tsx b/core/app/[locale]/(default)/blog/page.tsx index be7d9dc068..8f7da13355 100644 --- a/core/app/[locale]/(default)/blog/page.tsx +++ b/core/app/[locale]/(default)/blog/page.tsx @@ -1,61 +1,90 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getTranslations } from 'next-intl/server'; +import { SearchParams } from 'nuqs'; +import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'; -import { BlogPostCard } from '~/components/blog-post-card'; -import { Pagination } from '~/components/ui/pagination'; +import { FeaturedBlogPostList } from '@/vibes/soul/sections/featured-blog-post-list'; +import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; -import { getBlogPosts } from './page-data'; +import { getBlog, getBlogMetaData, getBlogPosts } from './page-data'; interface Props { params: Promise<{ locale: string }>; - searchParams: Promise>; + searchParams: Promise; } -export async function generateMetadata(props: Props): Promise { - const searchParams = await props.searchParams; - const t = await getTranslations('Blog'); - const blogPosts = await getBlogPosts(searchParams); - - return { - title: blogPosts?.name ?? t('title'), - description: - blogPosts?.description && blogPosts.description.length > 150 - ? `${blogPosts.description.substring(0, 150)}...` - : blogPosts?.description, - }; +const defaultPostLimit = 9; + +const searchParamsCache = createSearchParamsCache({ + tag: parseAsString, + before: parseAsString, + after: parseAsString, + limit: parseAsInteger.withDefault(defaultPostLimit), +}); + +export async function generateMetadata(): Promise { + return await getBlogMetaData(); +} + +async function listBlogPosts(searchParamsPromise: Promise) { + const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); + const blogPosts = await getBlogPosts(searchParamsParsed); + const posts = blogPosts?.posts ?? []; + + return posts; +} + +async function getEmptyStateTitle(): Promise { + const t = await getTranslations('Blog.Empty'); + + return t('title'); +} + +async function getEmptyStateSubtitle(): Promise { + const t = await getTranslations('Blog.Empty'); + + return t('subtitle'); +} + +async function getPaginationInfo(searchParamsPromise: Promise) { + const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); + const blogPosts = await getBlogPosts(searchParamsParsed); + + return pageInfoTransformer(blogPosts?.pageInfo ?? defaultPageInfo); } export default async function Blog(props: Props) { - const searchParams = await props.searchParams; - const blogPosts = await getBlogPosts(searchParams); + const searchParamsParsed = searchParamsCache.parse(await props.searchParams); + const { tag } = searchParamsParsed; + const blog = await getBlog(); - if (!blogPosts) { + if (!blog) { return notFound(); } + const tagCrumb = tag ? [{ label: tag, href: '#' }] : []; + return ( -
-

{blogPosts.name}

- -
    - {blogPosts.posts.items.map((post) => { - return ( -
  • - -
  • - ); - })} -
- - -
+ ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/blog/tag/[tagId]/page.tsx b/core/app/[locale]/(default)/blog/tag/[tagId]/page.tsx deleted file mode 100644 index 8d58d1c282..0000000000 --- a/core/app/[locale]/(default)/blog/tag/[tagId]/page.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { notFound } from 'next/navigation'; - -import { BlogPostCard } from '~/components/blog-post-card'; -import { Pagination } from '~/components/ui/pagination'; - -import { getBlogPosts } from '../../page-data'; - -interface Props { - params: Promise<{ - tagId: string; - }>; - searchParams: Promise>; -} - -export default async function Tag(props: Props) { - const searchParams = await props.searchParams; - const { tagId } = await props.params; - - const blogPosts = await getBlogPosts({ tagId, ...searchParams }); - - if (!blogPosts) { - return notFound(); - } - - return ( -
-

{blogPosts.name}

- -
- {blogPosts.posts.items.map((post) => { - return ; - })} -
- - -
- ); -} - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts b/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts index a1362c5c95..f4ac28b8b9 100644 --- a/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts +++ b/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts @@ -1,6 +1,9 @@ 'use server'; -import { getLocale } from 'next-intl/server'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { cookies } from 'next/headers'; +import { getLocale, getTranslations } from 'next-intl/server'; import { z } from 'zod'; import { getSessionCustomerAccessToken } from '~/auth'; @@ -20,23 +23,46 @@ const CheckoutRedirectMutation = graphql(` } `); -export const redirectToCheckout = async (formData: FormData) => { +export const redirectToCheckout = async ( + _lastResult: SubmissionResult | null, + formData: FormData, +): Promise => { const locale = await getLocale(); - const cartId = z.string().parse(formData.get('cartId')); + const t = await getTranslations('Cart.Errors'); + const cookieStore = await cookies(); + const customerAccessToken = await getSessionCustomerAccessToken(); - const { data } = await client.fetch({ - document: CheckoutRedirectMutation, - variables: { cartId }, - fetchOptions: { cache: 'no-store' }, - customerAccessToken, - }); + const submission = parseWithZod(formData, { schema: z.object({}) }); + + const cartId = cookieStore.get('cartId')?.value; + + if (!cartId) { + return submission.reply({ formErrors: [t('cartNotFound')] }); + } + + let url; - const url = data.cart.createCartRedirectUrls.redirectUrls?.redirectedCheckoutUrl; + try { + const { data } = await client.fetch({ + document: CheckoutRedirectMutation, + variables: { cartId }, + fetchOptions: { cache: 'no-store' }, + customerAccessToken, + }); + + url = data.cart.createCartRedirectUrls.redirectUrls?.redirectedCheckoutUrl; + } catch (error) { + if (error instanceof Error) { + return submission.reply({ formErrors: [error.message] }); + } + + return submission.reply({ formErrors: [String(error)] }); + } if (!url) { - throw new Error('Invalid checkout url.'); + return submission.reply({ formErrors: [t('failedToRedirectToCheckout')] }); } - redirect({ href: url, locale }); + return redirect({ href: url, locale }); }; diff --git a/core/app/[locale]/(default)/cart/_actions/remove-item.ts b/core/app/[locale]/(default)/cart/_actions/remove-item.ts index a28b9a57ab..217d140537 100644 --- a/core/app/[locale]/(default)/cart/_actions/remove-item.ts +++ b/core/app/[locale]/(default)/cart/_actions/remove-item.ts @@ -1,7 +1,8 @@ 'use server'; -import { revalidateTag } from 'next/cache'; +import { unstable_expireTag } from 'next/cache'; import { cookies } from 'next/headers'; +import { getTranslations } from 'next-intl/server'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; @@ -26,6 +27,8 @@ type DeleteCartLineItemInput = Variables['input']; export async function removeItem({ lineItemEntityId, }: Omit) { + const t = await getTranslations('Cart.Errors'); + const customerAccessToken = await getSessionCustomerAccessToken(); try { @@ -33,11 +36,11 @@ export async function removeItem({ const cartId = cookieStore.get('cartId')?.value; if (!cartId) { - return { status: 'error', error: 'No cartId cookie found' }; + throw new Error(t('cartNotFound')); } if (!lineItemEntityId) { - return { status: 'error', error: 'No lineItemEntityId found' }; + throw new Error(t('lineItemNotFound')); } const response = await client.fetch({ @@ -61,14 +64,14 @@ export async function removeItem({ cookieStore.delete('cartId'); } - revalidateTag(TAGS.cart); + unstable_expireTag(TAGS.cart); - return { status: 'success', data: cart }; + return cart; } catch (error: unknown) { if (error instanceof Error) { - return { status: 'error', error: error.message }; + throw new Error(error.message); } - return { status: 'error' }; + throw new Error(t('somethingWentWrong')); } } diff --git a/core/app/[locale]/(default)/cart/_actions/update-line-item.ts b/core/app/[locale]/(default)/cart/_actions/update-line-item.ts new file mode 100644 index 0000000000..c6a616a298 --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/update-line-item.ts @@ -0,0 +1,369 @@ +'use server'; + +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { FragmentOf } from 'gql.tada'; +import { getTranslations } from 'next-intl/server'; + +import { cartLineItemActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; +import { CartLineItem } from '@/vibes/soul/sections/cart/types'; + +import { DigitalItemFragment, PhysicalItemFragment } from '../page-data'; + +import { removeItem } from './remove-item'; +import { CartSelectedOptionsInput, updateQuantity } from './update-quantity'; + +type LineItem = { + selectedOptions: + | FragmentOf['selectedOptions'] + | FragmentOf['selectedOptions']; + productEntityId: number; + variantEntityId: number | null; +} & CartLineItem; + +export const updateLineItem = async ( + prevState: Awaited<{ + lineItems: LineItem[]; + lastResult: SubmissionResult | null; + }>, + formData: FormData, +): Promise<{ + lineItems: LineItem[]; + lastResult: SubmissionResult | null; +}> => { + const t = await getTranslations('Cart.Errors'); + + const submission = parseWithZod(formData, { schema: cartLineItemActionFormDataSchema }); + + if (submission.status !== 'success') { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }), + }; + } + + const cartLineItem = prevState.lineItems.find((item) => item.id === submission.value.id); + + if (!cartLineItem) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('lineItemNotFound')] }), + }; + } + + switch (submission.value.intent) { + case 'increment': { + const parsedSelectedOptions = cartLineItem.selectedOptions.reduce( + (accum, option) => { + let multipleChoicesOptionInput; + let checkboxOptionInput; + let numberFieldOptionInput; + let textFieldOptionInput; + let multiLineTextFieldOptionInput; + let dateFieldOptionInput; + + switch (option.__typename) { + case 'CartSelectedMultipleChoiceOption': + multipleChoicesOptionInput = { + optionEntityId: option.entityId, + optionValueEntityId: option.valueEntityId, + }; + + if (accum.multipleChoices) { + return { + ...accum, + multipleChoices: [...accum.multipleChoices, multipleChoicesOptionInput], + }; + } + + return { + ...accum, + multipleChoices: [multipleChoicesOptionInput], + }; + + case 'CartSelectedCheckboxOption': + checkboxOptionInput = { + optionEntityId: option.entityId, + optionValueEntityId: option.valueEntityId, + }; + + if (accum.checkboxes) { + return { + ...accum, + checkboxes: [...accum.checkboxes, checkboxOptionInput], + }; + } + + return { ...accum, checkboxes: [checkboxOptionInput] }; + + case 'CartSelectedNumberFieldOption': + numberFieldOptionInput = { + optionEntityId: option.entityId, + number: option.number, + }; + + if (accum.numberFields) { + return { + ...accum, + numberFields: [...accum.numberFields, numberFieldOptionInput], + }; + } + + return { ...accum, numberFields: [numberFieldOptionInput] }; + + case 'CartSelectedTextFieldOption': + textFieldOptionInput = { + optionEntityId: option.entityId, + text: option.text, + }; + + if (accum.textFields) { + return { + ...accum, + textFields: [...accum.textFields, textFieldOptionInput], + }; + } + + return { ...accum, textFields: [textFieldOptionInput] }; + + case 'CartSelectedMultiLineTextFieldOption': + multiLineTextFieldOptionInput = { + optionEntityId: option.entityId, + text: option.text, + }; + + if (accum.multiLineTextFields) { + return { + ...accum, + multiLineTextFields: [ + ...accum.multiLineTextFields, + multiLineTextFieldOptionInput, + ], + }; + } + + return { + ...accum, + multiLineTextFields: [multiLineTextFieldOptionInput], + }; + + case 'CartSelectedDateFieldOption': + dateFieldOptionInput = { + optionEntityId: option.entityId, + date: new Date(String(option.date.utc)).toISOString(), + }; + + if (accum.dateFields) { + return { + ...accum, + dateFields: [...accum.dateFields, dateFieldOptionInput], + }; + } + + return { ...accum, dateFields: [dateFieldOptionInput] }; + } + + return accum; + }, + {}, + ); + + try { + await updateQuantity({ + lineItemEntityId: cartLineItem.id, + productEntityId: cartLineItem.productEntityId, + variantEntityId: cartLineItem.variantEntityId, + selectedOptions: parsedSelectedOptions, + quantity: cartLineItem.quantity + 1, + }); + } catch (error) { + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + const item = submission.value; + + return { + lineItems: prevState.lineItems.map((lineItem) => + lineItem.id === item.id ? { ...lineItem, quantity: lineItem.quantity + 1 } : lineItem, + ), + lastResult: submission.reply({ resetForm: true }), + }; + } + + case 'decrement': { + const parsedSelectedOptions = cartLineItem.selectedOptions.reduce( + (accum, option) => { + let multipleChoicesOptionInput; + let checkboxOptionInput; + let numberFieldOptionInput; + let textFieldOptionInput; + let multiLineTextFieldOptionInput; + let dateFieldOptionInput; + + switch (option.__typename) { + case 'CartSelectedMultipleChoiceOption': + multipleChoicesOptionInput = { + optionEntityId: option.entityId, + optionValueEntityId: option.valueEntityId, + }; + + if (accum.multipleChoices) { + return { + ...accum, + multipleChoices: [...accum.multipleChoices, multipleChoicesOptionInput], + }; + } + + return { + ...accum, + multipleChoices: [multipleChoicesOptionInput], + }; + + case 'CartSelectedCheckboxOption': + checkboxOptionInput = { + optionEntityId: option.entityId, + optionValueEntityId: option.valueEntityId, + }; + + if (accum.checkboxes) { + return { + ...accum, + checkboxes: [...accum.checkboxes, checkboxOptionInput], + }; + } + + return { ...accum, checkboxes: [checkboxOptionInput] }; + + case 'CartSelectedNumberFieldOption': + numberFieldOptionInput = { + optionEntityId: option.entityId, + number: option.number, + }; + + if (accum.numberFields) { + return { + ...accum, + numberFields: [...accum.numberFields, numberFieldOptionInput], + }; + } + + return { ...accum, numberFields: [numberFieldOptionInput] }; + + case 'CartSelectedTextFieldOption': + textFieldOptionInput = { + optionEntityId: option.entityId, + text: option.text, + }; + + if (accum.textFields) { + return { + ...accum, + textFields: [...accum.textFields, textFieldOptionInput], + }; + } + + return { ...accum, textFields: [textFieldOptionInput] }; + + case 'CartSelectedMultiLineTextFieldOption': + multiLineTextFieldOptionInput = { + optionEntityId: option.entityId, + text: option.text, + }; + + if (accum.multiLineTextFields) { + return { + ...accum, + multiLineTextFields: [ + ...accum.multiLineTextFields, + multiLineTextFieldOptionInput, + ], + }; + } + + return { + ...accum, + multiLineTextFields: [multiLineTextFieldOptionInput], + }; + + case 'CartSelectedDateFieldOption': + dateFieldOptionInput = { + optionEntityId: option.entityId, + date: new Date(String(option.date.utc)).toISOString(), + }; + + if (accum.dateFields) { + return { + ...accum, + dateFields: [...accum.dateFields, dateFieldOptionInput], + }; + } + + return { ...accum, dateFields: [dateFieldOptionInput] }; + } + + return accum; + }, + {}, + ); + + try { + await updateQuantity({ + lineItemEntityId: cartLineItem.id, + productEntityId: cartLineItem.productEntityId, + variantEntityId: cartLineItem.variantEntityId, + selectedOptions: parsedSelectedOptions, + quantity: cartLineItem.quantity - 1, + }); + } catch (error) { + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + const item = submission.value; + + return { + lineItems: prevState.lineItems.map((lineItem) => + lineItem.id === item.id ? { ...lineItem, quantity: lineItem.quantity - 1 } : lineItem, + ), + lastResult: submission.reply({ resetForm: true }), + }; + } + + case 'delete': { + try { + await removeItem({ lineItemEntityId: submission.value.id }); + } catch (error) { + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + const deletedItem = submission.value; + + // TODO: add bodl + // bodl.cart.productRemoved({ + // currency, + // product_value: product.listPrice.value * product.quantity, + // line_items: [lineItemTransform(product)], + // }); + + return { + lineItems: prevState.lineItems.filter((item) => item.id !== deletedItem.id), + lastResult: submission.reply({ resetForm: true }), + }; + } + + default: { + return prevState; + } + } +}; diff --git a/core/app/[locale]/(default)/cart/_components/item-quantity/update-item-quantity.ts b/core/app/[locale]/(default)/cart/_actions/update-quantity.ts similarity index 71% rename from core/app/[locale]/(default)/cart/_components/item-quantity/update-item-quantity.ts rename to core/app/[locale]/(default)/cart/_actions/update-quantity.ts index 9ff6f64118..560152231f 100644 --- a/core/app/[locale]/(default)/cart/_components/item-quantity/update-item-quantity.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-quantity.ts @@ -1,13 +1,14 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { unstable_expirePath } from 'next/cache'; import { cookies } from 'next/headers'; +import { getTranslations } from 'next-intl/server'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; -import { removeItem } from '../../_actions/remove-item'; +import { removeItem } from './remove-item'; const UpdateCartLineItemMutation = graphql(` mutation UpdateCartLineItem($input: UpdateCartLineItemInput!) { @@ -22,20 +23,25 @@ const UpdateCartLineItemMutation = graphql(` `); type CartLineItemInput = ReturnType>; +export type CartSelectedOptionsInput = ReturnType< + typeof graphql.scalar<'CartSelectedOptionsInput'> +>; type Variables = VariablesOf; type UpdateCartLineItemInput = Variables['input']; -interface UpdateProductQuantityParams extends CartLineItemInput { +export interface UpdateProductQuantityParams extends CartLineItemInput { lineItemEntityId: UpdateCartLineItemInput['lineItemEntityId']; } -export async function updateItemQuantity({ +export const updateQuantity = async ({ lineItemEntityId, productEntityId, quantity, variantEntityId, selectedOptions, -}: UpdateProductQuantityParams) { +}: UpdateProductQuantityParams) => { + const t = await getTranslations('Cart.Errors'); + const customerAccessToken = await getSessionCustomerAccessToken(); try { @@ -43,11 +49,11 @@ export async function updateItemQuantity({ const cartId = cookieStore.get('cartId')?.value; if (!cartId) { - return { status: 'error', error: 'No cartId cookie found' }; + throw new Error(t('cartNotFound')); } if (!lineItemEntityId) { - return { status: 'error', error: 'No lineItemEntityId found' }; + throw new Error(t('lineItemNotFound')); } if (quantity === 0) { @@ -80,17 +86,17 @@ export async function updateItemQuantity({ const cart = response.data.cart.updateCartLineItem?.cart; if (!cart) { - return { status: 'error', error: 'Failed to change product quantity in Cart' }; + throw new Error(t('failedToUpdateQuantity')); } - revalidatePath('/cart'); + unstable_expirePath('/cart'); - return { status: 'success', data: cart }; + return cart; } catch (error: unknown) { if (error instanceof Error) { - return { status: 'error', error: error.message }; + throw new Error(error.message); } - return { status: 'error' }; + throw new Error(t('somethingWentWrong')); } -} +}; diff --git a/core/app/[locale]/(default)/cart/_components/cart-item.tsx b/core/app/[locale]/(default)/cart/_components/cart-item.tsx deleted file mode 100644 index bb35121f1d..0000000000 --- a/core/app/[locale]/(default)/cart/_components/cart-item.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { useFormatter } from 'next-intl'; - -import { FragmentOf, graphql } from '~/client/graphql'; -import { Image } from '~/components/image'; - -import { ItemQuantity } from './item-quantity'; -import { RemoveItem } from './remove-item'; - -const PhysicalItemFragment = graphql(` - fragment PhysicalItemFragment on CartPhysicalItem { - name - brand - sku - image { - url: urlTemplate(lossy: true) - } - entityId - quantity - productEntityId - variantEntityId - extendedListPrice { - currencyCode - value - } - extendedSalePrice { - currencyCode - value - } - originalPrice { - currencyCode - value - } - listPrice { - currencyCode - value - } - selectedOptions { - __typename - entityId - name - ... on CartSelectedMultipleChoiceOption { - value - valueEntityId - } - ... on CartSelectedCheckboxOption { - value - valueEntityId - } - ... on CartSelectedNumberFieldOption { - number - } - ... on CartSelectedMultiLineTextFieldOption { - text - } - ... on CartSelectedTextFieldOption { - text - } - ... on CartSelectedDateFieldOption { - date { - utc - } - } - } - } -`); - -const DigitalItemFragment = graphql(` - fragment DigitalItemFragment on CartDigitalItem { - name - brand - sku - image { - url: urlTemplate(lossy: true) - } - entityId - quantity - productEntityId - variantEntityId - extendedListPrice { - currencyCode - value - } - extendedSalePrice { - currencyCode - value - } - originalPrice { - currencyCode - value - } - listPrice { - currencyCode - value - } - selectedOptions { - __typename - entityId - name - ... on CartSelectedMultipleChoiceOption { - value - valueEntityId - } - ... on CartSelectedCheckboxOption { - value - valueEntityId - } - ... on CartSelectedNumberFieldOption { - number - } - ... on CartSelectedMultiLineTextFieldOption { - text - } - ... on CartSelectedTextFieldOption { - text - } - ... on CartSelectedDateFieldOption { - date { - utc - } - } - } - } -`); - -export const CartItemFragment = graphql( - ` - fragment CartItemFragment on CartLineItems { - physicalItems { - ...PhysicalItemFragment - } - digitalItems { - ...DigitalItemFragment - } - } - `, - [PhysicalItemFragment, DigitalItemFragment], -); - -type FragmentResult = FragmentOf; -type PhysicalItem = FragmentResult['physicalItems'][number]; -type DigitalItem = FragmentResult['digitalItems'][number]; - -export type Product = PhysicalItem | DigitalItem; - -interface Props { - product: Product; - currencyCode: string; -} - -export const CartItem = ({ currencyCode, product }: Props) => { - const format = useFormatter(); - - return ( -
  • -
    -
    - {product.image?.url ? ( - {product.name} - ) : ( -
    - )} -
    - -
    -

    {product.brand}

    -
    -
    -

    {product.name}

    - - {product.selectedOptions.length > 0 && ( -
    - {product.selectedOptions.map((selectedOption) => { - switch (selectedOption.__typename) { - case 'CartSelectedMultipleChoiceOption': - return ( -
    - {selectedOption.name}:{' '} - {selectedOption.value} -
    - ); - - case 'CartSelectedCheckboxOption': - return ( -
    - {selectedOption.name}:{' '} - {selectedOption.value} -
    - ); - - case 'CartSelectedNumberFieldOption': - return ( -
    - {selectedOption.name}:{' '} - {selectedOption.number} -
    - ); - - case 'CartSelectedMultiLineTextFieldOption': - return ( -
    - {selectedOption.name}:{' '} - {selectedOption.text} -
    - ); - - case 'CartSelectedTextFieldOption': - return ( -
    - {selectedOption.name}:{' '} - {selectedOption.text} -
    - ); - - case 'CartSelectedDateFieldOption': - return ( -
    - {selectedOption.name}:{' '} - - {format.dateTime(new Date(selectedOption.date.utc))} - -
    - ); - } - - return null; - })} -
    - )} - -
    - -
    -
    - -
    -
    - {product.originalPrice.value && - product.originalPrice.value !== product.listPrice.value ? ( -

    - {format.number(product.originalPrice.value * product.quantity, { - style: 'currency', - currency: currencyCode, - })} -

    - ) : null} -

    - {format.number(product.extendedSalePrice.value, { - style: 'currency', - currency: currencyCode, - })} -

    -
    - - -
    -
    - -
    - -
    -
    -
    -
  • - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/cart-viewed.tsx b/core/app/[locale]/(default)/cart/_components/cart-viewed.tsx index 06d7edba0d..b678c7064c 100644 --- a/core/app/[locale]/(default)/cart/_components/cart-viewed.tsx +++ b/core/app/[locale]/(default)/cart/_components/cart-viewed.tsx @@ -5,16 +5,14 @@ import { useEffect } from 'react'; import { FragmentOf } from '~/client/graphql'; import { bodl } from '~/lib/bodl'; -import { CartItemFragment } from './cart-item'; -import { CheckoutSummaryFragment } from './checkout-summary'; +import { DigitalItemFragment, PhysicalItemFragment } from '../page-data'; -type FragmentResult = FragmentOf; -type PhysicalItem = FragmentResult['physicalItems'][number]; -type DigitalItem = FragmentResult['digitalItems'][number]; +type PhysicalItem = FragmentOf; +type DigitalItem = FragmentOf; type lineItem = PhysicalItem | DigitalItem; interface Props { - checkout: FragmentOf | null; + subtotal?: number; currencyCode: string; lineItems: lineItem[]; } @@ -35,14 +33,14 @@ const lineItemTransform = (item: lineItem) => { }; }; -export const CartViewed = ({ checkout, currencyCode, lineItems }: Props) => { +export const CartViewed = ({ subtotal, currencyCode, lineItems }: Props) => { useEffect(() => { bodl.cart.cartViewed({ currency: currencyCode, - cart_value: checkout?.grandTotal?.value ?? 0, + cart_value: subtotal ?? 0, line_items: lineItems.map(lineItemTransform), }); - }, [currencyCode, lineItems, checkout]); + }, [currencyCode, lineItems, subtotal]); return null; }; diff --git a/core/app/[locale]/(default)/cart/_components/checkout-button.tsx b/core/app/[locale]/(default)/cart/_components/checkout-button.tsx deleted file mode 100644 index 91b23f213e..0000000000 --- a/core/app/[locale]/(default)/cart/_components/checkout-button.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import { useTranslations } from 'next-intl'; -import { useFormStatus } from 'react-dom'; - -import { Button } from '~/components/ui/button'; - -import { redirectToCheckout } from '../_actions/redirect-to-checkout'; - -const InternalButton = () => { - const t = useTranslations('Cart'); - const { pending } = useFormStatus(); - - return ( - - ); -}; - -export const CheckoutButton = ({ cartId }: { cartId: string }) => { - return ( -
    - - - - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/checkout-summary.tsx b/core/app/[locale]/(default)/cart/_components/checkout-summary.tsx deleted file mode 100644 index 743a0f5124..0000000000 --- a/core/app/[locale]/(default)/cart/_components/checkout-summary.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { getFormatter, getTranslations } from 'next-intl/server'; - -import { FragmentOf, graphql } from '~/client/graphql'; - -import { CouponCode } from './coupon-code'; -import { CouponCodeFragment } from './coupon-code/fragment'; -import { ShippingEstimator } from './shipping-estimator'; -import { GeographyFragment, ShippingEstimatorFragment } from './shipping-estimator/fragment'; -import { getShippingCountries } from './shipping-estimator/get-shipping-countries'; - -const MoneyFieldsFragment = graphql(` - fragment MoneyFields on Money { - currencyCode - value - } -`); - -export const CheckoutSummaryFragment = graphql( - ` - fragment CheckoutSummaryFragment on Checkout { - ...ShippingEstimatorFragment - ...CouponCodeFragment - subtotal { - ...MoneyFields - } - grandTotal { - ...MoneyFields - } - taxTotal { - ...MoneyFields - } - cart { - currencyCode - discountedAmount { - ...MoneyFields - } - } - } - `, - [MoneyFieldsFragment, ShippingEstimatorFragment, CouponCodeFragment], -); - -interface Props { - checkout: FragmentOf; - geography: FragmentOf; -} - -export const CheckoutSummary = async ({ checkout, geography }: Props) => { - const t = await getTranslations('Cart.CheckoutSummary'); - const format = await getFormatter(); - - const { cart, grandTotal, subtotal, taxTotal } = checkout; - - const shippingCountries = await getShippingCountries({ geography }); - - return ( - <> -
    - {t('subTotal')} - - {format.number(subtotal?.value || 0, { - style: 'currency', - currency: cart?.currencyCode, - })} - -
    - - - - {cart?.discountedAmount && ( -
    - {t('discounts')} - - - - {format.number(cart.discountedAmount.value, { - style: 'currency', - currency: cart.currencyCode, - })} - -
    - )} - - - - {taxTotal && ( -
    - {t('tax')} - - {format.number(taxTotal.value, { - style: 'currency', - currency: cart?.currencyCode, - })} - -
    - )} - -
    - {t('grandTotal')} - - {format.number(grandTotal?.value || 0, { - style: 'currency', - currency: cart?.currencyCode, - })} - -
    - - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/coupon-code/apply-coupon-code.ts b/core/app/[locale]/(default)/cart/_components/coupon-code/apply-coupon-code.ts deleted file mode 100644 index 0443593a0a..0000000000 --- a/core/app/[locale]/(default)/cart/_components/coupon-code/apply-coupon-code.ts +++ /dev/null @@ -1,67 +0,0 @@ -'use server'; - -import { revalidateTag } from 'next/cache'; -import { z } from 'zod'; - -import { getSessionCustomerAccessToken } from '~/auth'; -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; -import { TAGS } from '~/client/tags'; - -const ApplyCouponCodeSchema = z.object({ - checkoutEntityId: z.string(), - couponCode: z.string(), -}); - -const ApplyCheckoutCouponMutation = graphql(` - mutation ApplyCheckoutCouponMutation($applyCheckoutCouponInput: ApplyCheckoutCouponInput!) { - checkout { - applyCheckoutCoupon(input: $applyCheckoutCouponInput) { - checkout { - entityId - } - } - } - } -`); - -export const applyCouponCode = async (formData: FormData) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - - try { - const parsedData = ApplyCouponCodeSchema.parse({ - checkoutEntityId: formData.get('checkoutEntityId'), - couponCode: formData.get('couponCode'), - }); - - const response = await client.fetch({ - document: ApplyCheckoutCouponMutation, - variables: { - applyCheckoutCouponInput: { - checkoutEntityId: parsedData.checkoutEntityId, - data: { - couponCode: parsedData.couponCode, - }, - }, - }, - customerAccessToken, - fetchOptions: { cache: 'no-store' }, - }); - - const checkout = response.data.checkout.applyCheckoutCoupon?.checkout; - - if (!checkout?.entityId) { - return { status: 'error', error: 'Coupon code is invalid.' }; - } - - revalidateTag(TAGS.checkout); - - return { status: 'success', data: checkout }; - } catch (error: unknown) { - if (error instanceof Error || error instanceof z.ZodError) { - return { status: 'error', error: error.message }; - } - - return { status: 'error' }; - } -}; diff --git a/core/app/[locale]/(default)/cart/_components/coupon-code/fragment.ts b/core/app/[locale]/(default)/cart/_components/coupon-code/fragment.ts deleted file mode 100644 index a5b45bda0e..0000000000 --- a/core/app/[locale]/(default)/cart/_components/coupon-code/fragment.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { graphql } from '~/client/graphql'; - -export const CouponCodeFragment = graphql(` - fragment CouponCodeFragment on Checkout { - entityId - coupons { - code - discountedAmount { - value - } - } - cart { - currencyCode - } - } -`); diff --git a/core/app/[locale]/(default)/cart/_components/coupon-code/index.tsx b/core/app/[locale]/(default)/cart/_components/coupon-code/index.tsx deleted file mode 100644 index f9d6b3e1ee..0000000000 --- a/core/app/[locale]/(default)/cart/_components/coupon-code/index.tsx +++ /dev/null @@ -1,151 +0,0 @@ -'use client'; - -import { AlertCircle } from 'lucide-react'; -import { useFormatter, useTranslations } from 'next-intl'; -import { useEffect, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import { toast } from 'react-hot-toast'; - -import { FragmentOf } from '~/client/graphql'; -import { Button } from '~/components/ui/button'; -import { Field, FieldControl, FieldMessage, Form, FormSubmit, Input } from '~/components/ui/form'; - -import { applyCouponCode } from './apply-coupon-code'; -import { CouponCodeFragment } from './fragment'; -import { removeCouponCode } from './remove-coupon-code'; - -const SubmitButton = () => { - const t = useTranslations('Cart.SubmitCouponCode'); - const { pending } = useFormStatus(); - - return ( - - ); -}; - -export const RemoveButton = () => { - const t = useTranslations('Cart.CheckoutSummary'); - const { pending } = useFormStatus(); - - return ( - - ); -}; - -interface Props { - checkout: FragmentOf; -} - -export const CouponCode = ({ checkout }: Props) => { - const t = useTranslations('Cart.CheckoutSummary'); - const format = useFormatter(); - - const [showAddCoupon, setShowAddCoupon] = useState(false); - const [selectedCoupon, setSelectedCoupon] = useState(checkout.coupons.at(0) || null); - - useEffect(() => { - if (checkout.coupons[0]) { - setSelectedCoupon(checkout.coupons[0]); - setShowAddCoupon(false); - - return; - } - - setSelectedCoupon(null); - }, [checkout]); - - const onSubmitApplyCouponCode = async (formData: FormData) => { - const { status } = await applyCouponCode(formData); - - if (status === 'error') { - toast.error(t('couponCodeInvalid'), { - icon: , - }); - } - }; - - const onSubmitRemoveCouponCode = async (formData: FormData) => { - const { status } = await removeCouponCode(formData); - - if (status === 'error') { - toast.error(t('couponCodeRemoveFailed'), { - icon: , - }); - } - }; - - return selectedCoupon ? ( -
    -
    - - {t('coupon')} ({selectedCoupon.code}) - - - {format.number(selectedCoupon.discountedAmount.value * -1, { - style: 'currency', - currency: checkout.cart?.currencyCode, - })} - -
    -
    - - - - -
    - ) : ( -
    -
    - {t('couponCode')} - -
    - {showAddCoupon && ( -
    - - - - - - - {t('couponCodeRequired')} - - - - - -
    - )} -
    - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/coupon-code/remove-coupon-code.ts b/core/app/[locale]/(default)/cart/_components/coupon-code/remove-coupon-code.ts deleted file mode 100644 index 2a3621db51..0000000000 --- a/core/app/[locale]/(default)/cart/_components/coupon-code/remove-coupon-code.ts +++ /dev/null @@ -1,67 +0,0 @@ -'use server'; - -import { revalidateTag } from 'next/cache'; -import { z } from 'zod'; - -import { getSessionCustomerAccessToken } from '~/auth'; -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; -import { TAGS } from '~/client/tags'; - -const RemoveCouponCodeSchema = z.object({ - checkoutEntityId: z.string(), - couponCode: z.string(), -}); - -const UnapplyCheckoutCouponMutation = graphql(` - mutation UnapplyCheckoutCouponMutation($unapplyCheckoutCouponInput: UnapplyCheckoutCouponInput!) { - checkout { - unapplyCheckoutCoupon(input: $unapplyCheckoutCouponInput) { - checkout { - entityId - } - } - } - } -`); - -export const removeCouponCode = async (formData: FormData) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - - try { - const parsedData = RemoveCouponCodeSchema.parse({ - checkoutEntityId: formData.get('checkoutEntityId'), - couponCode: formData.get('couponCode'), - }); - - const response = await client.fetch({ - document: UnapplyCheckoutCouponMutation, - variables: { - unapplyCheckoutCouponInput: { - checkoutEntityId: parsedData.checkoutEntityId, - data: { - couponCode: parsedData.couponCode, - }, - }, - }, - customerAccessToken, - fetchOptions: { cache: 'no-store' }, - }); - - const checkout = response.data.checkout.unapplyCheckoutCoupon?.checkout; - - if (!checkout?.entityId) { - return { status: 'error', error: 'Error ocurred removing coupon.' }; - } - - revalidateTag(TAGS.checkout); - - return { status: 'success', data: checkout }; - } catch (error: unknown) { - if (error instanceof Error || error instanceof z.ZodError) { - return { status: 'error', error: error.message }; - } - - return { status: 'error' }; - } -}; diff --git a/core/app/[locale]/(default)/cart/_components/empty-cart.tsx b/core/app/[locale]/(default)/cart/_components/empty-cart.tsx deleted file mode 100644 index 7339d2a84b..0000000000 --- a/core/app/[locale]/(default)/cart/_components/empty-cart.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useTranslations } from 'next-intl'; - -export const EmptyCart = () => { - const t = useTranslations('Cart'); - - return ( -
    -

    {t('heading')}

    -
    -

    {t('empty')}

    -

    {t('emptyDetails')}

    -
    -
    - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/item-quantity/index.tsx b/core/app/[locale]/(default)/cart/_components/item-quantity/index.tsx deleted file mode 100644 index a7f2531044..0000000000 --- a/core/app/[locale]/(default)/cart/_components/item-quantity/index.tsx +++ /dev/null @@ -1,199 +0,0 @@ -'use client'; - -import { AlertCircle, Minus, Plus, Loader2 as Spinner } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ComponentPropsWithoutRef, useEffect, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import { toast } from 'react-hot-toast'; - -import { graphql } from '~/client/graphql'; - -import { Product } from '../cart-item'; - -import { updateItemQuantity } from './update-item-quantity'; - -type CartSelectedOptionsInput = ReturnType>; - -const parseSelectedOptions = (selectedOptions: Product['selectedOptions']) => { - return selectedOptions.reduce((accum, option) => { - let multipleChoicesOptionInput; - let checkboxOptionInput; - let numberFieldOptionInput; - let textFieldOptionInput; - let multiLineTextFieldOptionInput; - let dateFieldOptionInput; - - switch (option.__typename) { - case 'CartSelectedMultipleChoiceOption': - multipleChoicesOptionInput = { - optionEntityId: option.entityId, - optionValueEntityId: option.valueEntityId, - }; - - if (accum.multipleChoices) { - return { - ...accum, - multipleChoices: [...accum.multipleChoices, multipleChoicesOptionInput], - }; - } - - return { ...accum, multipleChoices: [multipleChoicesOptionInput] }; - - case 'CartSelectedCheckboxOption': - checkboxOptionInput = { - optionEntityId: option.entityId, - optionValueEntityId: option.valueEntityId, - }; - - if (accum.checkboxes) { - return { ...accum, checkboxes: [...accum.checkboxes, checkboxOptionInput] }; - } - - return { ...accum, checkboxes: [checkboxOptionInput] }; - - case 'CartSelectedNumberFieldOption': - numberFieldOptionInput = { - optionEntityId: option.entityId, - number: option.number, - }; - - if (accum.numberFields) { - return { ...accum, numberFields: [...accum.numberFields, numberFieldOptionInput] }; - } - - return { ...accum, numberFields: [numberFieldOptionInput] }; - - case 'CartSelectedTextFieldOption': - textFieldOptionInput = { - optionEntityId: option.entityId, - text: option.text, - }; - - if (accum.textFields) { - return { - ...accum, - textFields: [...accum.textFields, textFieldOptionInput], - }; - } - - return { ...accum, textFields: [textFieldOptionInput] }; - - case 'CartSelectedMultiLineTextFieldOption': - multiLineTextFieldOptionInput = { - optionEntityId: option.entityId, - text: option.text, - }; - - if (accum.multiLineTextFields) { - return { - ...accum, - multiLineTextFields: [...accum.multiLineTextFields, multiLineTextFieldOptionInput], - }; - } - - return { ...accum, multiLineTextFields: [multiLineTextFieldOptionInput] }; - - case 'CartSelectedDateFieldOption': - dateFieldOptionInput = { - optionEntityId: option.entityId, - date: new Date(String(option.date.utc)).toISOString(), - }; - - if (accum.dateFields) { - return { - ...accum, - dateFields: [...accum.dateFields, dateFieldOptionInput], - }; - } - - return { ...accum, dateFields: [dateFieldOptionInput] }; - } - - return accum; - }, {}); -}; - -const SubmitButton = ({ children, ...props }: ComponentPropsWithoutRef<'button'>) => { - const { pending } = useFormStatus(); - const t = useTranslations('Cart.SubmitItemQuantity'); - - return ( - - ); -}; - -const Quantity = ({ value }: { value: number }) => { - const { pending } = useFormStatus(); - const t = useTranslations('Cart.SubmitItemQuantity'); - - return ( - - {pending ? ( - <> - - ); -}; - -export const ItemQuantity = ({ product }: { product: Product }) => { - const t = useTranslations('Cart.SubmitItemQuantity'); - - const { quantity, entityId, productEntityId, variantEntityId, selectedOptions } = product; - const [productQuantity, setProductQuantity] = useState(quantity); - - useEffect(() => { - setProductQuantity(quantity); - }, [quantity]); - - const onSubmit = async (formData: FormData) => { - const { status } = await updateItemQuantity({ - lineItemEntityId: entityId, - productEntityId, - quantity: Number(formData.get('quantity')), - selectedOptions: parseSelectedOptions(selectedOptions), - variantEntityId, - }); - - if (status === 'error') { - toast.error(t('errorMessage'), { - icon: , - }); - - setProductQuantity(quantity); - } - }; - - return ( -
    -
    - setProductQuantity(productQuantity - 1)}> - - {t('submitReduceText')} - - - - - - - setProductQuantity(productQuantity + 1)}> - - {t('submitIncreaseText')} - - - -
    - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/remove-from-cart-button.tsx b/core/app/[locale]/(default)/cart/_components/remove-from-cart-button.tsx deleted file mode 100644 index 41b8663892..0000000000 --- a/core/app/[locale]/(default)/cart/_components/remove-from-cart-button.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -import { useTranslations } from 'next-intl'; -import { useFormStatus } from 'react-dom'; - -import { Button } from '~/components/ui/button'; - -export const RemoveFromCartButton = () => { - const { pending } = useFormStatus(); - const t = useTranslations('Cart.SubmitRemoveItem'); - - return ( - - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/remove-item.tsx b/core/app/[locale]/(default)/cart/_components/remove-item.tsx deleted file mode 100644 index d0fc01baf1..0000000000 --- a/core/app/[locale]/(default)/cart/_components/remove-item.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'use client'; - -import { AlertCircle } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { toast } from 'react-hot-toast'; - -import { FragmentOf } from '~/client/graphql'; -import { bodl } from '~/lib/bodl'; - -import { removeItem } from '../_actions/remove-item'; - -import { CartItemFragment } from './cart-item'; -import { RemoveFromCartButton } from './remove-from-cart-button'; - -type FragmentResult = FragmentOf; -type PhysicalItem = FragmentResult['physicalItems'][number]; -type DigitalItem = FragmentResult['digitalItems'][number]; - -export type Product = PhysicalItem | DigitalItem; - -interface Props { - currency: string; - product: Product; -} - -const lineItemTransform = (item: Product) => { - return { - product_id: item.productEntityId.toString(), - product_name: item.name, - brand_name: item.brand ?? undefined, - sku: item.sku ?? undefined, - sale_price: item.extendedSalePrice.value, - purchase_price: item.listPrice.value, - base_price: item.originalPrice.value, - retail_price: item.listPrice.value, - currency: item.listPrice.currencyCode, - variant_id: item.variantEntityId ? [item.variantEntityId] : undefined, - quantity: item.quantity, - }; -}; - -export const RemoveItem = ({ currency, product }: Props) => { - const t = useTranslations('Cart.SubmitRemoveItem'); - - const onSubmitRemoveItem = async () => { - const { status } = await removeItem({ - lineItemEntityId: product.entityId, - }); - - if (status === 'error') { - toast.error(t('errorMessage'), { - icon: , - }); - - return; - } - - bodl.cart.productRemoved({ - currency, - product_value: product.listPrice.value * product.quantity, - line_items: [lineItemTransform(product)], - }); - }; - - return ( -
    - - - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/shipping-estimator/fragment.ts b/core/app/[locale]/(default)/cart/_components/shipping-estimator/fragment.ts deleted file mode 100644 index 549a9a1573..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-estimator/fragment.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { graphql } from '~/client/graphql'; - -import { ShippingInfoFragment } from '../shipping-info/fragment'; -import { ShippingOptionsFragment } from '../shipping-options/fragment'; - -export const ShippingEstimatorFragment = graphql( - ` - fragment ShippingEstimatorFragment on Checkout { - ...ShippingInfoFragment - entityId - shippingConsignments { - ...ShippingOptionsFragment - selectedShippingOption { - entityId - description - } - } - handlingCostTotal { - value - } - shippingCostTotal { - currencyCode - value - } - cart { - currencyCode - } - } - `, - [ShippingOptionsFragment, ShippingInfoFragment], -); - -export const GeographyFragment = graphql( - ` - fragment GeographyFragment on Geography { - countries { - entityId - name - code - statesOrProvinces { - entityId - name - abbreviation - } - } - } - `, - [], -); diff --git a/core/app/[locale]/(default)/cart/_components/shipping-estimator/get-shipping-countries.ts b/core/app/[locale]/(default)/cart/_components/shipping-estimator/get-shipping-countries.ts deleted file mode 100644 index e0ca487d69..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-estimator/get-shipping-countries.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { FragmentOf } from 'gql.tada'; - -import { getShippingZones } from '~/client/management/get-shipping-zones'; - -import { GeographyFragment } from './fragment'; - -interface GetShippingCountries { - geography: FragmentOf; -} - -export const getShippingCountries = async ({ geography }: GetShippingCountries) => { - const hasAccessToken = Boolean(process.env.BIGCOMMERCE_ACCESS_TOKEN); - const shippingZones = hasAccessToken ? await getShippingZones() : []; - const countries = geography.countries ?? []; - - const uniqueCountryZones = shippingZones.reduce((zones, item) => { - item.locations.forEach(({ country_iso2 }) => { - if (zones.length === 0) { - zones.push(country_iso2); - - return zones; - } - - const isAvailable = zones.length > 0 && zones.some((zone) => zone === country_iso2); - - if (!isAvailable) { - zones.push(country_iso2); - } - - return zones; - }); - - return zones; - }, []); - - return countries.filter((countryDetails) => { - const isCountryInTheList = uniqueCountryZones.includes(countryDetails.code); - - return isCountryInTheList || !hasAccessToken; - }); -}; diff --git a/core/app/[locale]/(default)/cart/_components/shipping-estimator/index.tsx b/core/app/[locale]/(default)/cart/_components/shipping-estimator/index.tsx deleted file mode 100644 index 7199863ac8..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-estimator/index.tsx +++ /dev/null @@ -1,127 +0,0 @@ -'use client'; - -import { useFormatter, useTranslations } from 'next-intl'; -import { useEffect, useRef, useState } from 'react'; - -import { FragmentOf } from '~/client/graphql'; -import { ExistingResultType } from '~/client/util'; -import { Button } from '~/components/ui/button'; - -import { ShippingInfo } from '../shipping-info'; -import { ShippingOptions } from '../shipping-options'; - -import { ShippingEstimatorFragment } from './fragment'; -import { getShippingCountries } from './get-shipping-countries'; - -interface Props { - checkout: FragmentOf; - shippingCountries: ExistingResultType; -} - -export const ShippingEstimator = ({ checkout, shippingCountries }: Props) => { - const t = useTranslations('Cart.CheckoutSummary'); - const format = useFormatter(); - - const [showShippingInfo, setShowShippingInfo] = useState(false); - const [showShippingOptions, setShowShippingOptions] = useState(false); - - const selectedShippingConsignment = checkout.shippingConsignments?.find( - (shippingConsignment) => shippingConsignment.selectedShippingOption, - ); - - const prevCheckout = useRef(checkout); - - useEffect(() => { - const checkoutChanged = !Object.is(prevCheckout.current, checkout); - - if (checkoutChanged && showShippingInfo) { - setShowShippingOptions(true); - } - - if (!showShippingInfo) { - setShowShippingOptions(false); - } - - if (checkoutChanged && selectedShippingConsignment && showShippingInfo && showShippingOptions) { - setShowShippingInfo(false); - setShowShippingOptions(false); - } - - prevCheckout.current = checkout; - }, [checkout, selectedShippingConsignment, showShippingInfo, showShippingOptions]); - - return ( - <> -
    -
    - {t('shippingCost')} - {selectedShippingConsignment ? ( - - {format.number(checkout.shippingCostTotal?.value || 0, { - style: 'currency', - currency: checkout.cart?.currencyCode, - })} - - ) : ( - - )} -
    - - {selectedShippingConsignment && ( -
    - {selectedShippingConsignment.selectedShippingOption?.description} - -
    - )} - - setShowShippingOptions(false)} - isVisible={showShippingInfo} - shippingCountries={shippingCountries} - /> - - {showShippingOptions && checkout.shippingConsignments && ( -
    - {checkout.shippingConsignments.map((consignment) => { - return ( - - ); - })} -
    - )} -
    - - {Boolean(checkout.handlingCostTotal?.value) && ( -
    - {t('handlingCost')} - - {format.number(checkout.handlingCostTotal?.value || 0, { - style: 'currency', - currency: checkout.cart?.currencyCode, - })} - -
    - )} - - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/shipping-info/fragment.ts b/core/app/[locale]/(default)/cart/_components/shipping-info/fragment.ts deleted file mode 100644 index be53ee48a0..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-info/fragment.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { graphql } from '~/client/graphql'; - -import { ShippingOptionsFragment } from '../shipping-options/fragment'; - -export const ShippingInfoFragment = graphql( - ` - fragment ShippingInfoFragment on Checkout { - entityId - shippingConsignments { - entityId - ...ShippingOptionsFragment - selectedShippingOption { - entityId - description - } - address { - city - countryCode - stateOrProvince - postalCode - } - } - cart { - lineItems { - physicalItems { - entityId - quantity - } - } - } - } - `, - [ShippingOptionsFragment], -); diff --git a/core/app/[locale]/(default)/cart/_components/shipping-info/index.tsx b/core/app/[locale]/(default)/cart/_components/shipping-info/index.tsx deleted file mode 100644 index a541374db5..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-info/index.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { AlertCircle } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { useEffect, useReducer } from 'react'; -import { useFormStatus } from 'react-dom'; -import { toast } from 'react-hot-toast'; - -import { getShippingCountries } from '~/app/[locale]/(default)/cart/_components/shipping-estimator/get-shipping-countries'; -import { FragmentOf } from '~/client/graphql'; -import { ExistingResultType } from '~/client/util'; -import { Button } from '~/components/ui/button'; -import { - Field, - FieldControl, - FieldLabel, - Form, - FormSubmit, - Input, - Select, -} from '~/components/ui/form'; -import { cn } from '~/lib/utils'; - -import { ShippingInfoFragment } from './fragment'; -import { submitShippingInfo } from './submit-shipping-info'; - -interface FormValues { - country: string; - state: string; - city: string; - postcode: string; -} - -const SubmitButton = () => { - const { pending } = useFormStatus(); - const t = useTranslations('Cart.SubmitShippingInfo'); - - return ( - - ); -}; - -export const ShippingInfo = ({ - checkout, - shippingCountries, - isVisible, - hideShippingOptions, -}: { - checkout: FragmentOf; - shippingCountries: ExistingResultType; - isVisible: boolean; - hideShippingOptions: () => void; -}) => { - const t = useTranslations('Cart.ShippingInfo'); - - const shippingConsignment = - checkout.shippingConsignments?.find((consignment) => consignment.selectedShippingOption) || - checkout.shippingConsignments?.[0]; - - const [formValues, setFormValues] = useReducer( - (currentValues: FormValues, newValues: Partial) => ({ - ...currentValues, - ...newValues, - }), - { - country: shippingConsignment?.address.countryCode ?? '', - state: shippingConsignment?.address.stateOrProvince ?? '', - city: shippingConsignment?.address.city ?? '', - postcode: shippingConsignment?.address.postalCode ?? '', - }, - ); - - const selectedCountry = shippingCountries.find(({ code }) => code === formValues.country); - - // Preselect first state when states array changes and state is empty - useEffect(() => { - if (!!selectedCountry?.statesOrProvinces && !formValues.state) { - setFormValues({ state: selectedCountry.statesOrProvinces[0]?.name || '' }); - } - }, [formValues.state, selectedCountry?.statesOrProvinces]); - - const onSubmit = async (formData: FormData) => { - const { status } = await submitShippingInfo(formData, { - checkoutId: checkout.entityId, - lineItems: - checkout.cart?.lineItems.physicalItems.map((item) => ({ - lineItemEntityId: item.entityId, - quantity: item.quantity, - })) || [], - shippingId: shippingConsignment?.entityId ?? '', - }); - - if (status === 'error') { - toast.error(t('errorMessage'), { - icon: , - }); - } - }; - - return ( - - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/shipping-info/submit-shipping-info.ts b/core/app/[locale]/(default)/cart/_components/shipping-info/submit-shipping-info.ts deleted file mode 100644 index e34099b949..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-info/submit-shipping-info.ts +++ /dev/null @@ -1,132 +0,0 @@ -'use server'; - -import { revalidateTag } from 'next/cache'; -import { z } from 'zod'; - -import { getSessionCustomerAccessToken } from '~/auth'; -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; -import { TAGS } from '~/client/tags'; - -const UpdateCheckoutShippingConsignmentMutation = graphql(` - mutation UpdateCheckoutShippingConsignment($input: UpdateCheckoutShippingConsignmentInput!) { - checkout { - updateCheckoutShippingConsignment(input: $input) { - checkout { - entityId - } - } - } - } -`); - -const AddCheckoutShippingConsignmentsMutation = graphql(` - mutation AddCheckoutShippingConsignments($input: AddCheckoutShippingConsignmentsInput!) { - checkout { - addCheckoutShippingConsignments(input: $input) { - checkout { - entityId - } - } - } - } -`); - -const ShippingInfoSchema = z.object({ - country: z.string(), - state: z.string().optional(), - city: z.string().optional(), - zipcode: z.string().optional(), -}); - -export const submitShippingInfo = async ( - formData: FormData, - checkoutData: { - checkoutId: string; - shippingId: string | null; - lineItems: Array<{ quantity: number; lineItemEntityId: string }>; - }, -) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - - try { - const parsedData = ShippingInfoSchema.parse({ - country: formData.get('country'), - state: formData.get('state'), - city: formData.get('city'), - zipcode: formData.get('zip'), - }); - const { checkoutId, lineItems, shippingId } = checkoutData; - - let result; - - if (shippingId) { - const response = await client.fetch({ - document: UpdateCheckoutShippingConsignmentMutation, - variables: { - input: { - checkoutEntityId: checkoutId, - consignmentEntityId: shippingId, - data: { - consignment: { - address: { - countryCode: parsedData.country.split('-')[0] ?? '', - city: parsedData.city, - stateOrProvince: parsedData.state, - shouldSaveAddress: false, - postalCode: parsedData.zipcode, - }, - lineItems, - }, - }, - }, - }, - customerAccessToken, - fetchOptions: { cache: 'no-store' }, - }); - - result = response.data.checkout.updateCheckoutShippingConsignment?.checkout; - } else { - const response = await client.fetch({ - document: AddCheckoutShippingConsignmentsMutation, - variables: { - input: { - checkoutEntityId: checkoutId, - data: { - consignments: [ - { - address: { - countryCode: parsedData.country.split('-')[0] ?? '', - city: parsedData.city, - stateOrProvince: parsedData.state, - shouldSaveAddress: false, - postalCode: parsedData.zipcode, - }, - lineItems, - }, - ], - }, - }, - }, - customerAccessToken, - fetchOptions: { cache: 'no-store' }, - }); - - result = response.data.checkout.addCheckoutShippingConsignments?.checkout; - } - - if (!result?.entityId) { - return { status: 'error', error: 'Failed to submit shipping info.' }; - } - - revalidateTag(TAGS.checkout); - - return { status: 'success', data: result }; - } catch (error: unknown) { - if (error instanceof Error || error instanceof z.ZodError) { - return { status: 'error', error: error.message }; - } - - return { status: 'error', error: 'Failed to submit shipping info.' }; - } -}; diff --git a/core/app/[locale]/(default)/cart/_components/shipping-options/fragment.ts b/core/app/[locale]/(default)/cart/_components/shipping-options/fragment.ts deleted file mode 100644 index 87d8642195..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-options/fragment.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { graphql } from '~/client/graphql'; - -export const ShippingOptionsFragment = graphql(` - fragment ShippingOptionsFragment on CheckoutShippingConsignment { - entityId - availableShippingOptions { - cost { - value - } - description - entityId - isRecommended - } - } -`); diff --git a/core/app/[locale]/(default)/cart/_components/shipping-options/index.tsx b/core/app/[locale]/(default)/cart/_components/shipping-options/index.tsx deleted file mode 100644 index d23fc5bdfd..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-options/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { AlertCircle } from 'lucide-react'; -import { useFormatter, useTranslations } from 'next-intl'; -import { toast } from 'react-hot-toast'; - -import { FragmentOf } from '~/client/graphql'; -import { Field, FieldLabel, Form, FormSubmit, RadioGroup } from '~/components/ui/form'; -import { Message } from '~/components/ui/message'; - -import { ShippingOptionsFragment } from './fragment'; -import { SubmitButton } from './submit-button'; -import { submitShippingCosts } from './submit-shipping-costs'; - -interface Props { - data: FragmentOf; - checkoutEntityId: string; - currencyCode?: string; -} - -export const ShippingOptions = ({ data, checkoutEntityId, currencyCode }: Props) => { - const t = useTranslations('Cart.ShippingCost'); - const format = useFormatter(); - const { availableShippingOptions, entityId } = data; - - const shippingOptions = availableShippingOptions?.map( - ({ cost, description, entityId: shippingOptionEntityId, isRecommended }) => ({ - cost: cost.value, - description, - shippingOptionEntityId, - isDefault: isRecommended, - }), - ); - - const onSubmit = async (formData: FormData) => { - const { status } = await submitShippingCosts(formData, checkoutEntityId, entityId); - - if (status === 'error') { - toast.error(t('errorMessage'), { - icon: , - }); - } - }; - - const items = shippingOptions?.map((option) => ({ - value: option.shippingOptionEntityId, - label: `${option.description} - ${format.number(option.cost, { style: 'currency', currency: currencyCode })}`, - })); - - const defaultValue = shippingOptions?.find((option) => option.isDefault)?.shippingOptionEntityId; - - return items && items.length > 0 ? ( -
    - - {t('shippingOptions')} - - - - - -
    - ) : ( - -

    {t('noAvailableOptions')}

    -
    - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/shipping-options/submit-button.tsx b/core/app/[locale]/(default)/cart/_components/shipping-options/submit-button.tsx deleted file mode 100644 index 3780b1c656..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-options/submit-button.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useTranslations } from 'next-intl'; -import { useFormStatus } from 'react-dom'; - -import { Button } from '~/components/ui/button'; - -export const SubmitButton = () => { - const t = useTranslations('Cart.SubmitShippingCost'); - const { pending } = useFormStatus(); - - return ( - - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/shipping-options/submit-shipping-costs.ts b/core/app/[locale]/(default)/cart/_components/shipping-options/submit-shipping-costs.ts deleted file mode 100644 index 0dc7719b1d..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-options/submit-shipping-costs.ts +++ /dev/null @@ -1,70 +0,0 @@ -'use server'; - -import { revalidateTag } from 'next/cache'; -import { z } from 'zod'; - -import { getSessionCustomerAccessToken } from '~/auth'; -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; -import { TAGS } from '~/client/tags'; - -const SelectCheckoutShippingOptionMutation = graphql(` - mutation SelectCheckoutShippingOption($input: SelectCheckoutShippingOptionInput!) { - checkout { - selectCheckoutShippingOption(input: $input) { - checkout { - entityId - } - } - } - } -`); - -const ShippingCostSchema = z.object({ - shippingOption: z.string(), -}); - -export const submitShippingCosts = async ( - formData: FormData, - checkoutEntityId: string, - consignmentEntityId: string, -) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - - try { - const parsedData = ShippingCostSchema.parse({ - shippingOption: formData.get('shippingOption'), - }); - - const response = await client.fetch({ - document: SelectCheckoutShippingOptionMutation, - variables: { - input: { - checkoutEntityId, - consignmentEntityId, - data: { - shippingOptionEntityId: parsedData.shippingOption, - }, - }, - }, - customerAccessToken, - fetchOptions: { cache: 'no-store' }, - }); - - const shippingCost = response.data.checkout.selectCheckoutShippingOption?.checkout; - - if (!shippingCost?.entityId) { - return { status: 'error', error: 'Failed to submit shipping cost.' }; - } - - revalidateTag(TAGS.checkout); - - return { status: 'success', data: shippingCost }; - } catch (error: unknown) { - if (error instanceof Error || error instanceof z.ZodError) { - return { status: 'error', error: error.message }; - } - - return { status: 'error', error: 'Failed to submit shipping cost.' }; - } -}; diff --git a/core/app/[locale]/(default)/cart/loading.tsx b/core/app/[locale]/(default)/cart/loading.tsx new file mode 100644 index 0000000000..1f0e533b4f --- /dev/null +++ b/core/app/[locale]/(default)/cart/loading.tsx @@ -0,0 +1,9 @@ +import { useTranslations } from 'next-intl'; + +import { CartSkeleton } from '@/vibes/soul/sections/cart'; + +export default function Loading() { + const t = useTranslations('Cart'); + + return ; +} diff --git a/core/app/[locale]/(default)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts new file mode 100644 index 0000000000..67bc135cde --- /dev/null +++ b/core/app/[locale]/(default)/cart/page-data.ts @@ -0,0 +1,187 @@ +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; + +export const PhysicalItemFragment = graphql(` + fragment PhysicalItemFragment on CartPhysicalItem { + name + brand + sku + image { + url: urlTemplate(lossy: true) + } + entityId + quantity + productEntityId + variantEntityId + extendedListPrice { + currencyCode + value + } + extendedSalePrice { + currencyCode + value + } + originalPrice { + currencyCode + value + } + listPrice { + currencyCode + value + } + selectedOptions { + __typename + entityId + name + ... on CartSelectedMultipleChoiceOption { + value + valueEntityId + } + ... on CartSelectedCheckboxOption { + value + valueEntityId + } + ... on CartSelectedNumberFieldOption { + number + } + ... on CartSelectedMultiLineTextFieldOption { + text + } + ... on CartSelectedTextFieldOption { + text + } + ... on CartSelectedDateFieldOption { + date { + utc + } + } + } + url + } +`); + +export const DigitalItemFragment = graphql(` + fragment DigitalItemFragment on CartDigitalItem { + name + brand + sku + image { + url: urlTemplate(lossy: true) + } + entityId + quantity + productEntityId + variantEntityId + extendedListPrice { + currencyCode + value + } + extendedSalePrice { + currencyCode + value + } + originalPrice { + currencyCode + value + } + listPrice { + currencyCode + value + } + selectedOptions { + __typename + entityId + name + ... on CartSelectedMultipleChoiceOption { + value + valueEntityId + } + ... on CartSelectedCheckboxOption { + value + valueEntityId + } + ... on CartSelectedNumberFieldOption { + number + } + ... on CartSelectedMultiLineTextFieldOption { + text + } + ... on CartSelectedTextFieldOption { + text + } + ... on CartSelectedDateFieldOption { + date { + utc + } + } + } + url + } +`); + +const MoneyFieldsFragment = graphql(` + fragment MoneyFields on Money { + currencyCode + value + } +`); + +const CartPageQuery = graphql( + ` + query CartPageQuery($cartId: String) { + site { + cart(entityId: $cartId) { + entityId + currencyCode + lineItems { + physicalItems { + ...PhysicalItemFragment + } + digitalItems { + ...DigitalItemFragment + } + totalQuantity + } + } + checkout(entityId: $cartId) { + subtotal { + ...MoneyFields + } + grandTotal { + ...MoneyFields + } + taxTotal { + ...MoneyFields + } + cart { + currencyCode + discountedAmount { + ...MoneyFields + } + } + } + } + } + `, + [PhysicalItemFragment, DigitalItemFragment, MoneyFieldsFragment], +); + +export const getCart = async (cartId: string) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const { data } = await client.fetch({ + document: CartPageQuery, + variables: { cartId }, + customerAccessToken, + fetchOptions: { + cache: 'no-store', + next: { + tags: [TAGS.cart, TAGS.checkout], + }, + }, + }); + + return data; +}; diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index 5f357f4aaf..a1a6ff5085 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -1,40 +1,12 @@ import { cookies } from 'next/headers'; -import { getTranslations } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; -import { getSessionCustomerAccessToken } from '~/auth'; -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; -import { TAGS } from '~/client/tags'; +import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart'; -import { CartItem, CartItemFragment } from './_components/cart-item'; +import { redirectToCheckout } from './_actions/redirect-to-checkout'; +import { updateLineItem } from './_actions/update-line-item'; import { CartViewed } from './_components/cart-viewed'; -import { CheckoutButton } from './_components/checkout-button'; -import { CheckoutSummary, CheckoutSummaryFragment } from './_components/checkout-summary'; -import { EmptyCart } from './_components/empty-cart'; -import { GeographyFragment } from './_components/shipping-estimator/fragment'; - -const CartPageQuery = graphql( - ` - query CartPageQuery($cartId: String) { - site { - cart(entityId: $cartId) { - entityId - currencyCode - lineItems { - ...CartItemFragment - } - } - checkout(entityId: $cartId) { - ...CheckoutSummaryFragment - } - } - geography { - ...GeographyFragment - } - } - `, - [CartItemFragment, CheckoutSummaryFragment, GeographyFragment], -); +import { getCart } from './page-data'; export async function generateMetadata() { const t = await getTranslations('Cart'); @@ -45,59 +17,116 @@ export async function generateMetadata() { } export default async function Cart() { - const cookieStore = await cookies(); - - const cartId = cookieStore.get('cartId')?.value; + const t = await getTranslations('Cart'); + const format = await getFormatter(); + const cartId = (await cookies()).get('cartId')?.value; if (!cartId) { - return ; + return ( + + ); } - const t = await getTranslations('Cart'); - - const customerAccessToken = await getSessionCustomerAccessToken(); - - const { data } = await client.fetch({ - document: CartPageQuery, - variables: { cartId }, - customerAccessToken, - fetchOptions: { - cache: 'no-store', - next: { - tags: [TAGS.cart, TAGS.checkout], - }, - }, - }); + const data = await getCart(cartId); const cart = data.site.cart; const checkout = data.site.checkout; - const geography = data.geography; if (!cart) { - return ; + return ( + + ); } const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems]; + const formattedLineItems = lineItems.map((item) => ({ + id: item.entityId, + quantity: item.quantity, + price: format.number(item.listPrice.value, { + style: 'currency', + currency: item.listPrice.currencyCode, + }), + subtitle: item.selectedOptions + .map((option) => { + switch (option.__typename) { + case 'CartSelectedMultipleChoiceOption': + case 'CartSelectedCheckboxOption': + return `${option.name}: ${option.value}`; + + case 'CartSelectedNumberFieldOption': + return `${option.name}: ${option.number}`; + + case 'CartSelectedMultiLineTextFieldOption': + case 'CartSelectedTextFieldOption': + return `${option.name}: ${option.text}`; + + case 'CartSelectedDateFieldOption': + return `${option.name}: ${format.dateTime(new Date(option.date.utc))}`; + + default: + return ''; + } + }) + .join(', '), + title: item.name, + image: { src: item.image?.url || '', alt: item.name }, + href: new URL(item.url).pathname, + selectedOptions: item.selectedOptions, + productEntityId: item.productEntityId, + variantEntityId: item.variantEntityId, + })); + return ( -
    -

    {t('heading')}

    -
    -
      - {lineItems.map((product) => ( - - ))} -
    - -
    - {checkout && } - - -
    -
    - -
    + <> + + + ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts b/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts index 2233635968..cc13ef8f45 100644 --- a/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts +++ b/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts @@ -1,6 +1,6 @@ 'use server'; -import { revalidateTag } from 'next/cache'; +import { unstable_expireTag } from 'next/cache'; import { cookies } from 'next/headers'; import { @@ -40,7 +40,7 @@ export const addToCart = async (data: FormData) => { return { status: 'error', error: 'Failed to add product to cart.' }; } - revalidateTag(TAGS.cart); + unstable_expireTag(TAGS.cart); return { status: 'success', data: cart }; } @@ -64,7 +64,7 @@ export const addToCart = async (data: FormData) => { path: '/', }); - revalidateTag(TAGS.cart); + unstable_expireTag(TAGS.cart); return { status: 'success', data: cart }; } catch (error: unknown) { diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index 46f2803daa..46dbbcddd7 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -372,5 +372,3 @@ export default async function Compare(props: Props) { ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/error.tsx b/core/app/[locale]/(default)/error.tsx new file mode 100644 index 0000000000..12f1d61278 --- /dev/null +++ b/core/app/[locale]/(default)/error.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +import { Error as ErrorSection } from '@/vibes/soul/sections/error'; + +interface Props { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function Error({ reset }: Props) { + const t = useTranslations('Error'); + + return ; +} diff --git a/core/app/[locale]/(default)/layout.tsx b/core/app/[locale]/(default)/layout.tsx index a1dc8354ac..2ff605f0b1 100644 --- a/core/app/[locale]/(default)/layout.tsx +++ b/core/app/[locale]/(default)/layout.tsx @@ -1,9 +1,8 @@ import { setRequestLocale } from 'next-intl/server'; -import { PropsWithChildren, Suspense } from 'react'; +import { PropsWithChildren } from 'react'; import { Footer } from '~/components/footer/footer'; -import { Header, HeaderSkeleton } from '~/components/header'; -import { Cart } from '~/components/header/cart'; +import { Header } from '~/components/header'; interface Props extends PropsWithChildren { params: Promise<{ locale: string }>; @@ -16,17 +15,13 @@ export default async function DefaultLayout({ params, children }: Props) { return ( <> - }> -
    } /> - +
    -
    - {children} -
    +
    {children}
    - -