diff --git a/.changeset/beige-feet-shake.md b/.changeset/beige-feet-shake.md deleted file mode 100644 index 0b224c71f..000000000 --- a/.changeset/beige-feet-shake.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": minor ---- - -Add dynamic robots.txt from control panel settings diff --git a/.changeset/chilled-rocks-sneeze.md b/.changeset/chilled-rocks-sneeze.md new file mode 100644 index 000000000..1319c8358 --- /dev/null +++ b/.changeset/chilled-rocks-sneeze.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Change the rest of the auth pages to use toasts. diff --git a/.changeset/fifty-queens-provide.md b/.changeset/fifty-queens-provide.md deleted file mode 100644 index b6c5b2eeb..000000000 --- a/.changeset/fifty-queens-provide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Breadcrumbs for top level category pages are no longer rendered diff --git a/.changeset/forty-points-cross.md b/.changeset/forty-points-cross.md deleted file mode 100644 index 8b54268f3..000000000 --- a/.changeset/forty-points-cross.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Fixes an issue when a numeric product option set to a minimum <= 0 breaks the counter component. diff --git a/.changeset/gentle-experts-remember.md b/.changeset/gentle-experts-remember.md new file mode 100644 index 000000000..792f87bca --- /dev/null +++ b/.changeset/gentle-experts-remember.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Converts the change password messages over to using a toast. This should provide a better DX and UX. diff --git a/.changeset/great-turtles-breathe.md b/.changeset/great-turtles-breathe.md deleted file mode 100644 index 198fa4f5f..000000000 --- a/.changeset/great-turtles-breathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Updates the with-routes middleware to fallback on locale based rewrite logic if the redirect is a dynamic entity redirect. diff --git a/.changeset/grumpy-roses-attack.md b/.changeset/grumpy-roses-attack.md new file mode 100644 index 000000000..b52e1ff66 --- /dev/null +++ b/.changeset/grumpy-roses-attack.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Update the register customer page to use toasts for messaging. diff --git a/.changeset/late-boats-own.md b/.changeset/late-boats-own.md new file mode 100644 index 000000000..4a52c68a6 --- /dev/null +++ b/.changeset/late-boats-own.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Converts the reset password messages over to using a toast. diff --git a/.changeset/little-plants-think.md b/.changeset/little-plants-think.md deleted file mode 100644 index b96612c50..000000000 --- a/.changeset/little-plants-think.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Fixes the product quantity reseting back to the previous value when adjusting the quantity fails. diff --git a/.changeset/old-bananas-destroy.md b/.changeset/old-bananas-destroy.md new file mode 100644 index 000000000..c5469d527 --- /dev/null +++ b/.changeset/old-bananas-destroy.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Remove the account state provider components diff --git a/.changeset/proud-queens-serve.md b/.changeset/proud-queens-serve.md new file mode 100644 index 000000000..9dd2cb6d1 --- /dev/null +++ b/.changeset/proud-queens-serve.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Converts the login messages over to using a toast. diff --git a/.changeset/rich-flies-hang.md b/.changeset/rich-flies-hang.md deleted file mode 100644 index 606392048..000000000 --- a/.changeset/rich-flies-hang.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -adds an empty state to category pages diff --git a/.changeset/serious-rice-cough.md b/.changeset/serious-rice-cough.md deleted file mode 100644 index 41e1ad327..000000000 --- a/.changeset/serious-rice-cough.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Add no-store to mutations that are rate limited. diff --git a/.changeset/silly-balloons-stare.md b/.changeset/silly-balloons-stare.md new file mode 100644 index 000000000..7e5d325e6 --- /dev/null +++ b/.changeset/silly-balloons-stare.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +After login, redirect to orders page instead of an account overview page. This also removes the account overview page. diff --git a/.changeset/silly-queens-explode.md b/.changeset/silly-queens-explode.md deleted file mode 100644 index 085a299b7..000000000 --- a/.changeset/silly-queens-explode.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-client": minor ---- - -Support Trusted Proxy in client to support higher-traffic stores diff --git a/.changeset/soft-years-guess.md b/.changeset/soft-years-guess.md deleted file mode 100644 index 8f3ac4a87..000000000 --- a/.changeset/soft-years-guess.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/create-catalyst": minor ---- - -Update OAuth scopes to future needs diff --git a/.changeset/translations-patch-276d7650.md b/.changeset/translations-patch-276d7650.md deleted file mode 100644 index ad17b2636..000000000 --- a/.changeset/translations-patch-276d7650.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Update translations. diff --git a/.changeset/yellow-cougars-allow.md b/.changeset/yellow-cougars-allow.md new file mode 100644 index 000000000..b7e09178a --- /dev/null +++ b/.changeset/yellow-cougars-allow.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +If a customer is already logged in, we want to redirect them back to their account pages if they are trying to hit one of the non-logged-in customer auth routes. The prevents any side effects that may occur trying to re-auth the client. This is done by providing a root layout.tsx page under the (auth) route group. diff --git a/.changeset/young-pugs-dream.md b/.changeset/young-pugs-dream.md new file mode 100644 index 000000000..f38398b1a --- /dev/null +++ b/.changeset/young-pugs-dream.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Converts the change/forgot password messages over to using a toast. diff --git a/.env.example b/.env.example index 25a154837..bacc5581f 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,9 @@ # The control panel URL is of the form `https://store-{hash}.mybigcommerce.com`. BIGCOMMERCE_STORE_HASH= -# A bearer token that authorizes server-to-server requests to the GraphQL Storefront API -# See https://developer.bigcommerce.com/docs/rest-authentication/tokens/customer-impersonation-token -BIGCOMMERCE_CUSTOMER_IMPERSONATION_TOKEN= +# A JWT Token for accessing the Storefront API. Enables server-to-server requests if allowed_cors_origins is omitted. +# See https://developer.bigcommerce.com/docs/rest-authentication/tokens#storefront-tokens +BIGCOMMERCE_STOREFRONT_TOKEN= # The Channel ID for the selling channel being serviced by this Catalyst storefront. # Channel ID 1 will allow you to load the same data being used on the default Stencil storefront on your store, diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ea1a103dc..870d5ddca 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,3 +17,6 @@ updates: update-types: ['version-update:semver-major'] - dependency-name: 'react-day-picker' update-types: ['version-update:semver-major'] + # We are using the latest pre-releases for react and react-dom. + - dependency-name: 'react' + - dependency-name: 'react-dom' diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index 53b973964..965c030e6 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -13,7 +13,7 @@ env: TURBO_TEAM: ${{ vars.TURBO_TEAM }} TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} BIGCOMMERCE_STORE_HASH: ${{ secrets.BIGCOMMERCE_STORE_HASH }} - BIGCOMMERCE_CUSTOMER_IMPERSONATION_TOKEN: ${{ secrets.BIGCOMMERCE_CUSTOMER_IMPERSONATION_TOKEN }} + BIGCOMMERCE_STOREFRONT_TOKEN: ${{ secrets.BIGCOMMERCE_STOREFRONT_TOKEN }} BIGCOMMERCE_CHANNEL_ID: ${{ secrets.BIGCOMMERCE_CHANNEL_ID }} jobs: diff --git a/.github/workflows/changesets-release.yml b/.github/workflows/changesets-release.yml index 1de90fcd9..2f6f7f693 100644 --- a/.github/workflows/changesets-release.yml +++ b/.github/workflows/changesets-release.yml @@ -29,6 +29,8 @@ jobs: - name: Build Packages run: pnpm --filter "./packages/**" build + env: + CLI_SEGMENT_WRITE_KEY: ${{ secrets.CLI_SEGMENT_WRITE_KEY }} - name: Create Release Pull Request or Publish to npm id: changesets diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/regression-tests.yml index b54553523..56aaa8253 100644 --- a/.github/workflows/regression-tests.yml +++ b/.github/workflows/regression-tests.yml @@ -140,7 +140,7 @@ jobs: - name: Run Playwright tests run: | cd core - npx playwright test tests/ui/ --project=tests-chromium + npx playwright test tests/ui/ --project=tests-chromium - uses: actions/upload-artifact@v4 if: failure() @@ -151,7 +151,7 @@ jobs: - name: Send slack notification uses: slackapi/slack-github-action@v1.26.0 - if: ${{ steps.pr_details.outputs.draft != 'true' && failure() }} + if: ${{ steps.pr_details.outputs.draft != 'true' && steps.pr_details.outputs.pr && failure() }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} with: @@ -213,7 +213,7 @@ jobs: - name: Send slack notification uses: slackapi/slack-github-action@v1.26.0 - if: ${{ steps.pr_details.outputs.draft != 'true' && failure() }} + if: ${{ steps.pr_details.outputs.draft != 'true' && steps.pr_details.outputs.pr && failure() }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} with: diff --git a/SECURITY.md b/SECURITY.md index bc6e641af..0095d631d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,11 @@ # Reporting security issues -If you have found a security vulnerability in an active open-source repository created and owned by BigCommerce, please report it to our [public bug bounty program](https://bugcrowd.com/bigcommerce). If you would prefer to submit via email, please send your report to [security@bigcommerce.com](mailto:security@bigcommerce.com) +BigCommerce is dedicated to the responsible disclosure of security vulnerabilities. +If you have found a security vulnerability in an active open-source repository created and owned by BigCommerce, please report it to our [public bug bounty program](https://bugcrowd.com/bigcommerce). If you would prefer to submit via email, please send your report to [security@bigcommerce.com](mailto:security@bigcommerce.com). -*Note: Only submissions to our bounty program on BugCrowd will be eligible for bounties. Bounty eligibility and amounts are determined according to the program guidelines.* +We ask that you **do not** open a public GitHub issue to report security concerns. -Please ***do not*** use public issues to report security vulnerabilities. +_Note: Only submissions to our bounty program on BugCrowd will be eligible for bounties. Bounty eligibility and amounts are determined according to the program guidelines._ -Bugs in 3rd-party modules should be reported to those modules’ maintainers. +_Note: Bugs in 3rd-party modules and/or dependencies should be reported to the owners/maintainers or those modules and/or dependencies, BigCommerce has no control or authority over third party content._ + +Thank you in advance for collaborating with us to help protect us and our customers. diff --git a/core/.env.example b/core/.env.example index 25a154837..bacc5581f 100644 --- a/core/.env.example +++ b/core/.env.example @@ -2,9 +2,9 @@ # The control panel URL is of the form `https://store-{hash}.mybigcommerce.com`. BIGCOMMERCE_STORE_HASH= -# A bearer token that authorizes server-to-server requests to the GraphQL Storefront API -# See https://developer.bigcommerce.com/docs/rest-authentication/tokens/customer-impersonation-token -BIGCOMMERCE_CUSTOMER_IMPERSONATION_TOKEN= +# A JWT Token for accessing the Storefront API. Enables server-to-server requests if allowed_cors_origins is omitted. +# See https://developer.bigcommerce.com/docs/rest-authentication/tokens#storefront-tokens +BIGCOMMERCE_STOREFRONT_TOKEN= # The Channel ID for the selling channel being serviced by this Catalyst storefront. # Channel ID 1 will allow you to load the same data being used on the default Stencil storefront on your store, diff --git a/core/.eslintrc.cjs b/core/.eslintrc.cjs index 6bc34066c..ce4806257 100644 --- a/core/.eslintrc.cjs +++ b/core/.eslintrc.cjs @@ -27,6 +27,12 @@ const config = { name: 'next/link', message: "Please import 'Link' from '~/components/Link' instead.", }, + { + name: 'next/image', + importNames: ['default'], + message: + "Please import 'Image' from '~/components/image' instead. This component handles CDN and static image optimization.", + }, { name: '~/i18n/routing', importNames: ['Link'], diff --git a/core/.gitignore b/core/.gitignore index d96c1399f..26db3633a 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -43,3 +43,6 @@ client/generated # secrets .catalyst + +# Build config +build-config.json diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 62a0a6a3b..9b80a6272 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,5 +1,180 @@ # Changelog +## 0.23.0 + +### Minor Changes + +- [#1639](https://github.com/bigcommerce/catalyst/pull/1639) [`ae2c6cd`](https://github.com/bigcommerce/catalyst/commit/ae2c6cd76b2ccc5c994bd298983cb1665c571d02) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - Add orders for customer account. Now customer can open orders history or move to specific order details. + +- [#1729](https://github.com/bigcommerce/catalyst/pull/1729) [`d52affe`](https://github.com/bigcommerce/catalyst/commit/d52affe56dee23a81263392030fe635c824fb182) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Removed ReCaptcha validation when you are logged in and making account changes. We have already validated a customer is human at the loggin screen. + +- [#1728](https://github.com/bigcommerce/catalyst/pull/1728) [`d7dbd7a`](https://github.com/bigcommerce/catalyst/commit/d7dbd7a04fc8cb87cf223fb5a17af8d59c6431ea) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Convert the messages that were displayed when deleting an address over to using the toast functionality. + +### Patch Changes + +- [#1727](https://github.com/bigcommerce/catalyst/pull/1727) [`d3c6dbc`](https://github.com/bigcommerce/catalyst/commit/d3c6dbc25c16901f694e053ccdee8193647f5760) Thanks [@migueloller](https://github.com/migueloller)! - Ignore empty strings when parsing array URL search parameters in faceted search. + +- [#1730](https://github.com/bigcommerce/catalyst/pull/1730) [`ad8c86d`](https://github.com/bigcommerce/catalyst/commit/ad8c86d574474eb5ed18d99265fe4001d267fb5f) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Fixes the inventory handling to handle some options being out of stock. + +## 0.22.1 + +### Patch Changes + +- [#1649](https://github.com/bigcommerce/catalyst/pull/1649) [`d38f164`](https://github.com/bigcommerce/catalyst/commit/d38f164d3e87ca87d3e792f8058a74c1f13e4220) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - improve account forms submit errors message + +- [#1651](https://github.com/bigcommerce/catalyst/pull/1651) [`1a222cb`](https://github.com/bigcommerce/catalyst/commit/1a222cb09dfc65b440090f868b01291e644bec4a) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - refresh the entire list of addresses after deleting an address + +- [#1722](https://github.com/bigcommerce/catalyst/pull/1722) [`1f0c2ef`](https://github.com/bigcommerce/catalyst/commit/1f0c2ef9212be079630f64a15a2f121ed7a358f9) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Remove `--turbo` from `pnpm dev` as it has some issues with the latest dependency bump, along with others. + +## 0.22.0 + +### Minor Changes + +- [#1717](https://github.com/bigcommerce/catalyst/pull/1717) [`12fea79`](https://github.com/bigcommerce/catalyst/commit/12fea7962c25c395b550717343300561fb8d6a4c) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Add a check for variant stock levels on add to cart button + +- [#1674](https://github.com/bigcommerce/catalyst/pull/1674) [`512c338`](https://github.com/bigcommerce/catalyst/commit/512c338e4abcb3cdb7f457e4012e0c90c6a8391a) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Uses the API responses to show better errors when adding a product to the cart. + +- [#1710](https://github.com/bigcommerce/catalyst/pull/1710) [`15edf31`](https://github.com/bigcommerce/catalyst/commit/15edf311f5508a85f09acd8135fbf2b4aae09ff0) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Rename `BcImage` to `Image` + +- [#1703](https://github.com/bigcommerce/catalyst/pull/1703) [`7b598ff`](https://github.com/bigcommerce/catalyst/commit/7b598ff012ce40fe4b34be780c01cdbbe61e9b7e) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Adds localized data fetching withing the beforeRequest client helper. If information is translated (currently possible to update via the Admin GraphQL API) then we will return the translated product data. See https://developer.bigcommerce.com/docs/store-operations/catalog/graphql-admin/product-basic-info for more information on how to use overrides. + +- [#1710](https://github.com/bigcommerce/catalyst/pull/1710) [`15edf31`](https://github.com/bigcommerce/catalyst/commit/15edf311f5508a85f09acd8135fbf2b4aae09ff0) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Force usage of the `` component. This component should fallback to using the default image loader if the url doesn't come from the BigCommerce CDN. + +- [#1672](https://github.com/bigcommerce/catalyst/pull/1672) [`ffefc61`](https://github.com/bigcommerce/catalyst/commit/ffefc6151b0fb09bf83e7556736452a3138ef9c4) Thanks [@chanceaclark](https://github.com/chanceaclark)! - If a string is not provided in the selected locale, the translation system will fallback to "en" for that specific entry. + +### Patch Changes + +- [#1661](https://github.com/bigcommerce/catalyst/pull/1661) [`93d9984`](https://github.com/bigcommerce/catalyst/commit/93d99844ed4957a5a4611970589a2246b1dffb16) Thanks [@bookernath](https://github.com/bookernath)! - Remove webpack chunk plugin + +- [#1688](https://github.com/bigcommerce/catalyst/pull/1688) [`3267840`](https://github.com/bigcommerce/catalyst/commit/3267840981ebb6ed62e0b87f60623d0c4352309d) Thanks [@thebigrick](https://github.com/thebigrick)! - Added aria label for compare button + +- [#1617](https://github.com/bigcommerce/catalyst/pull/1617) [`c852961`](https://github.com/bigcommerce/catalyst/commit/c852961063fb090907b23074301fcbc41e75b8ec) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - UX improvements for account pages + +- [#1690](https://github.com/bigcommerce/catalyst/pull/1690) [`ee6bbb9`](https://github.com/bigcommerce/catalyst/commit/ee6bbb96e9c357af249fb881f5de503f9e164fb1) Thanks [@thebigrick](https://github.com/thebigrick)! - Added localization to hardcoded strings + +- [#1647](https://github.com/bigcommerce/catalyst/pull/1647) [`ad5ed3f`](https://github.com/bigcommerce/catalyst/commit/ad5ed3f50f6d3025bf299cc04f51bf0864afd3a2) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - update submit create account errors message + +- [#1715](https://github.com/bigcommerce/catalyst/pull/1715) [`2960a70`](https://github.com/bigcommerce/catalyst/commit/2960a708084030b484de945e725b5bd0c32462ee) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. + +- [#1694](https://github.com/bigcommerce/catalyst/pull/1694) [`07f8463`](https://github.com/bigcommerce/catalyst/commit/07f84634000c4d1dac6f89037d9501bc056537c9) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. + +## 0.21.0 + +### Minor Changes + +- [#1631](https://github.com/bigcommerce/catalyst/pull/1631) [`58d9e7c`](https://github.com/bigcommerce/catalyst/commit/58d9e7ccb7915593cd012cce6d9f4bdf66cb381f) Thanks [@deini](https://github.com/deini)! - fetch available locales at build time + +### Patch Changes + +- [#1636](https://github.com/bigcommerce/catalyst/pull/1636) [`23abacf`](https://github.com/bigcommerce/catalyst/commit/23abacfb8ff4ff9d269e51821a6a992a9cb2d4f5) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Remove console.error when falling back to defaultChannelId + +- [#1636](https://github.com/bigcommerce/catalyst/pull/1636) [`23abacf`](https://github.com/bigcommerce/catalyst/commit/23abacfb8ff4ff9d269e51821a6a992a9cb2d4f5) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Clean up login error handling. + +- Updated dependencies [[`23abacf`](https://github.com/bigcommerce/catalyst/commit/23abacfb8ff4ff9d269e51821a6a992a9cb2d4f5)]: + - @bigcommerce/catalyst-client@0.14.0 + +## 0.20.0 + +### Minor Changes + +- [#1623](https://github.com/bigcommerce/catalyst/pull/1623) [`16e3a76`](https://github.com/bigcommerce/catalyst/commit/16e3a763571324dccd9031a79e400409eff9ee0c) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Next 15 upgrade + +### Patch Changes + +- [#1629](https://github.com/bigcommerce/catalyst/pull/1629) [`72a30a8`](https://github.com/bigcommerce/catalyst/commit/72a30a84193f7ed8a09b770d16dd2c9a8a7d1347) Thanks [@deini](https://github.com/deini)! - Use Typescript on Next Config + +- [#1618](https://github.com/bigcommerce/catalyst/pull/1618) [`d60e916`](https://github.com/bigcommerce/catalyst/commit/d60e916661385fab211f7e8b1342dbda2fd504b9) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. + +- Updated dependencies [[`16e3a76`](https://github.com/bigcommerce/catalyst/commit/16e3a763571324dccd9031a79e400409eff9ee0c)]: + - @bigcommerce/catalyst-client@0.13.0 + +## 0.19.0 + +### Minor Changes + +- [#1262](https://github.com/bigcommerce/catalyst/pull/1262) [`0c2023b`](https://github.com/bigcommerce/catalyst/commit/0c2023bae650039cd79ba51b1161b5c8c16f0b8d) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Removes all usages of the customer impersonation token. Also updates the docs to correspond with the Storefront API Token. + +- [#1262](https://github.com/bigcommerce/catalyst/pull/1262) [`0c2023b`](https://github.com/bigcommerce/catalyst/commit/0c2023bae650039cd79ba51b1161b5c8c16f0b8d) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Allows the ability to consume a [storefront token](https://developer.bigcommerce.com/docs/rest-authentication/tokens#storefront-tokens). This new token will allow Catalyst to create `customerAccessToken`'s whenever a user logs into their account. This change doesn't include consuming the either token, only adding the ability to pass it in. + +### Patch Changes + +- Updated dependencies [[`0c2023b`](https://github.com/bigcommerce/catalyst/commit/0c2023bae650039cd79ba51b1161b5c8c16f0b8d), [`0c2023b`](https://github.com/bigcommerce/catalyst/commit/0c2023bae650039cd79ba51b1161b5c8c16f0b8d)]: + - @bigcommerce/catalyst-client@0.12.0 + +## 0.18.1 + +### Patch Changes + +- [#1525](https://github.com/bigcommerce/catalyst/pull/1525) [`e751319`](https://github.com/bigcommerce/catalyst/commit/e751319728359a2e72d48072a4b68055ed4dbb1e) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - fix warning for using the same keys on items + +- [#1521](https://github.com/bigcommerce/catalyst/pull/1521) [`fd83a78`](https://github.com/bigcommerce/catalyst/commit/fd83a78f94b170dcf6e8aed14c61e3791b64c5de) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - fix styles for active account tab + +- [#1520](https://github.com/bigcommerce/catalyst/pull/1520) [`c898792`](https://github.com/bigcommerce/catalyst/commit/c898792a0ed3ee9849cdfeda7018245e491e8016) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - improve error message on reset password page + +- [#1524](https://github.com/bigcommerce/catalyst/pull/1524) [`f08883c`](https://github.com/bigcommerce/catalyst/commit/f08883c8fa559f0b6015321e2396606d77fa0ad6) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - improve behaviour for change password page for logged in user + +- [#1529](https://github.com/bigcommerce/catalyst/pull/1529) [`22426b2`](https://github.com/bigcommerce/catalyst/commit/22426b256e29b6c3dd145fd6df9ed57c5a99bd75) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - fix validation message for email on account settings page + +- [#1516](https://github.com/bigcommerce/catalyst/pull/1516) [`41270c2`](https://github.com/bigcommerce/catalyst/commit/41270c29a6e21217622c29b18e91f9a24d58ea8b) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. + +- [#1534](https://github.com/bigcommerce/catalyst/pull/1534) [`de48618`](https://github.com/bigcommerce/catalyst/commit/de486186acfec2604d749b9f6d2b4656a9e9280a) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. + +## 0.18.0 + +### Minor Changes + +- [#1491](https://github.com/bigcommerce/catalyst/pull/1491) [`313a591`](https://github.com/bigcommerce/catalyst/commit/313a5913181a144b53cb12208132f4a9924e2256) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Bump `next-intl` which includes [some minor changes and updated APIs](<(https://next-intl-docs.vercel.app/blog/next-intl-3-22)>): + + - Use new `createNavigation` api. + - Pass `locale` to redirects. + - `setRequestLocale` is no longer unstable. + +### Patch Changes + +- [#1505](https://github.com/bigcommerce/catalyst/pull/1505) [`691ec2b`](https://github.com/bigcommerce/catalyst/commit/691ec2bcbb8839446463e292856080cc9b16c584) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - update login page & error message styles + +- [#1506](https://github.com/bigcommerce/catalyst/pull/1506) [`ac83d3e`](https://github.com/bigcommerce/catalyst/commit/ac83d3eb98e19307a3a82fa94c222cff3c0806f0) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - remove unnecessary fields from account settings form and update confirmation message + +- [#1499](https://github.com/bigcommerce/catalyst/pull/1499) [`b5aea9b`](https://github.com/bigcommerce/catalyst/commit/b5aea9b36159d11a77d090fee62cb1736bc794be) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Bumps next-intl to fix issue with hashes and query params in urls. + +- [#1511](https://github.com/bigcommerce/catalyst/pull/1511) [`370d0b1`](https://github.com/bigcommerce/catalyst/commit/370d0b18f0f47100d7e520fcf9f209f6e41f34e9) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - update styles for reset password validation + +- [#1454](https://github.com/bigcommerce/catalyst/pull/1454) [`53599e6`](https://github.com/bigcommerce/catalyst/commit/53599e6e02988ab63d158c5c9f587669a5581402) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - remove unnecessary fields from create account form + +- [#1487](https://github.com/bigcommerce/catalyst/pull/1487) [`a22233f`](https://github.com/bigcommerce/catalyst/commit/a22233f8fc94c5ad602fa734cadbb892af34fe6b) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. + +## 0.17.1 + +### Patch Changes + +- Updated dependencies [[`d4120d3`](https://github.com/bigcommerce/catalyst/commit/d4120d39c10398e842a7ebe14ada685ec8aae3a8)]: + - @bigcommerce/catalyst-client@0.11.0 + +## 0.17.0 + +### Minor Changes + +- [#1401](https://github.com/bigcommerce/catalyst/pull/1401) [`3095002`](https://github.com/bigcommerce/catalyst/commit/3095002d7a10b9c4058016076deb7a45fc8ae7bb) Thanks [@bookernath](https://github.com/bookernath)! - Add dynamic robots.txt from control panel settings + +### Patch Changes + +- [#1477](https://github.com/bigcommerce/catalyst/pull/1477) [`79e705f`](https://github.com/bigcommerce/catalyst/commit/79e705f151a733a811effed40757030aba6b6300) Thanks [@deini](https://github.com/deini)! - Breadcrumbs for top level category pages are no longer rendered + +- [#1467](https://github.com/bigcommerce/catalyst/pull/1467) [`e763a83`](https://github.com/bigcommerce/catalyst/commit/e763a83bcd4b8b5311586247291338eb65fbc476) Thanks [@deini](https://github.com/deini)! - Fixes an issue when a numeric product option set to a minimum <= 0 breaks the counter component. + +- [#1459](https://github.com/bigcommerce/catalyst/pull/1459) [`b4485c7`](https://github.com/bigcommerce/catalyst/commit/b4485c76de8c83546c68a7b50fcb7991603dbf6e) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Updates the with-routes middleware to fallback on locale based rewrite logic if the redirect is a dynamic entity redirect. + +- [#1469](https://github.com/bigcommerce/catalyst/pull/1469) [`8e9e7f3`](https://github.com/bigcommerce/catalyst/commit/8e9e7f3d40545004b080146b4dbb42f4ac7cf17c) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Fixes the product quantity reseting back to the previous value when adjusting the quantity fails. + +- [#1476](https://github.com/bigcommerce/catalyst/pull/1476) [`d47e3ac`](https://github.com/bigcommerce/catalyst/commit/d47e3aceb244713bc996287319357e6af3d865ed) Thanks [@deini](https://github.com/deini)! - adds an empty state to category pages + +- [#1458](https://github.com/bigcommerce/catalyst/pull/1458) [`3d67f8d`](https://github.com/bigcommerce/catalyst/commit/3d67f8d0d1776d747e9aa485b0b29a738eeacf3c) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Add no-store to mutations that are rate limited. + +- [#1453](https://github.com/bigcommerce/catalyst/pull/1453) [`1c8b042`](https://github.com/bigcommerce/catalyst/commit/1c8b04278074eb55358a5515f330a011de9561b5) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. + +- Updated dependencies [[`2d1526a`](https://github.com/bigcommerce/catalyst/commit/2d1526a50402b2eb677abd55f19fb904234d1a84)]: + - @bigcommerce/catalyst-client@0.10.0 + ## 0.16.0 ### Minor Changes 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 c59383109..a6b3847f7 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 @@ -34,7 +34,12 @@ const ChangePasswordMutation = graphql(` } `); -export const changePassword = async (_previousState: unknown, formData: FormData) => { +interface ChangePasswordResponse { + status: 'success' | 'error'; + message: string; +} + +export const changePassword = async (formData: FormData): Promise => { const t = await getTranslations('ChangePassword'); try { @@ -61,25 +66,18 @@ export const changePassword = async (_previousState: unknown, formData: FormData const result = response.data.customer.resetPassword; - if (result.errors.length === 0) { - return { status: 'success', message: '' }; + if (result.errors.length > 0) { + result.errors.forEach((error) => { + throw new Error(error.message); + }); } return { - status: 'error', - message: result.errors.map((error) => error.message).join('\n'), + status: 'success', + message: t('confirmChangePassword'), }; } catch (error: unknown) { - if (error instanceof ZodError) { - return { - status: 'error', - message: error.issues - .map(({ path, message }) => `${path.toString()}: ${message}.`) - .join('\n'), - }; - } - - if (error instanceof Error) { + if (error instanceof Error || error instanceof ZodError) { return { status: 'error', message: error.message, 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 index b4a238b03..e6b3a1484 100644 --- 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 @@ -1,8 +1,10 @@ 'use client'; +import { AlertCircle, Check } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { ChangeEvent, useRef, useState } from 'react'; -import { useFormState, useFormStatus } from 'react-dom'; +import { ChangeEvent, useState } from 'react'; +import { useFormStatus } from 'react-dom'; +import { toast } from 'react-hot-toast'; import { Button } from '~/components/ui/button'; import { @@ -14,10 +16,8 @@ import { FormSubmit, Input, } from '~/components/ui/form'; -import { Message } from '~/components/ui/message'; import { useRouter } from '~/i18n/routing'; -import { useAccountStatusContext } from '../../../account/(tabs)/_components/account-status-provider'; import { changePassword } from '../_actions/change-password'; interface Props { @@ -46,98 +46,91 @@ const SubmitButton = () => { export const ChangePasswordForm = ({ customerId, customerToken }: Props) => { const t = useTranslations('ChangePassword.Form'); - const form = useRef(null); const router = useRouter(); - const [state, formAction] = useFormState(changePassword, { - status: 'idle', - message: '', - }); const [newPassword, setNewPasssword] = useState(''); const [isConfirmPasswordValid, setIsConfirmPasswordValid] = useState(true); - const { setAccountState } = useAccountStatusContext(); - - let messageText = ''; - - if (state.status === 'error') { - messageText = state.message; - } const handleNewPasswordChange = (e: ChangeEvent) => setNewPasssword(e.target.value); + const handleConfirmPasswordValidation = (e: ChangeEvent) => { const confirmPassword = e.target.value; setIsConfirmPasswordValid(confirmPassword === newPassword); }; - if (state.status === 'success') { - setAccountState({ status: 'success', message: t('confirmChangePassword') }); + 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 ( - <> - {state.status === 'error' && ( - -

{messageText}

-
- )} - -
- - - - - - - - - - - - - {t('newPasswordLabel')} - - - - - - - - - {t('confirmPasswordLabel')} - - - - - value !== newPassword} - > - {t('confirmPasswordValidationMessage')} - - - - - - -
- +
+ + + + + + + + + + + + + {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 fc6e97af2..f88174998 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/change-password/page.tsx @@ -1,5 +1,4 @@ -import { useTranslations } from 'next-intl'; -import { getTranslations } from 'next-intl/server'; +import { getLocale, getTranslations } from 'next-intl/server'; import { redirect } from '~/i18n/routing'; @@ -14,20 +13,19 @@ export async function generateMetadata() { } interface Props { - searchParams: { + searchParams: Promise<{ c?: string; t?: string; - }; + }>; } -export default function ChangePassword({ searchParams }: Props) { - const t = useTranslations('ChangePassword'); - - const customerId = searchParams.c; - const customerToken = searchParams.t; +export default async function ChangePassword({ searchParams }: Props) { + const { c: customerId, t: customerToken } = await searchParams; + const t = await getTranslations('ChangePassword'); + const locale = await getLocale(); if (!customerId || !customerToken) { - redirect('/login'); + redirect({ href: '/login', locale }); } if (customerId && customerToken) { diff --git a/core/app/[locale]/(default)/(auth)/layout.tsx b/core/app/[locale]/(default)/(auth)/layout.tsx new file mode 100644 index 000000000..7a1de0e3d --- /dev/null +++ b/core/app/[locale]/(default)/(auth)/layout.tsx @@ -0,0 +1,19 @@ +import { PropsWithChildren } from 'react'; + +import { auth } from '~/auth'; +import { redirect } from '~/i18n/routing'; + +interface Props extends PropsWithChildren { + params: Promise<{ locale: string }>; +} + +export default async function Layout({ children, params }: Props) { + const session = await auth(); + const { locale } = await params; + + if (session) { + redirect({ href: '/account/orders', locale }); + } + + return children; +} diff --git a/core/app/[locale]/(default)/(auth)/login/_actions/login.ts b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts index ec5830fdf..cde7caf6a 100644 --- a/core/app/[locale]/(default)/(auth)/login/_actions/login.ts +++ b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts @@ -1,12 +1,19 @@ 'use server'; -import { isRedirectError } from 'next/dist/client/components/redirect'; +import { unstable_rethrow as rethrow } from 'next/navigation'; +import { getLocale } from 'next-intl/server'; import { Credentials, signIn } from '~/auth'; import { redirect } from '~/i18n/routing'; -export const login = async (_previousState: unknown, formData: FormData) => { +interface LoginResponse { + status: 'success' | 'error'; +} + +export const login = async (formData: FormData): Promise => { try { + const locale = await getLocale(); + const credentials = Credentials.parse({ email: formData.get('email'), password: formData.get('password'), @@ -19,12 +26,13 @@ export const login = async (_previousState: unknown, formData: FormData) => { redirect: false, }); - redirect('/account'); + redirect({ href: '/account/orders', locale }); + + return { + status: 'success', + }; } catch (error: unknown) { - // We need to throw this error to trigger the redirect as Next.js uses error boundaries to redirect. - if (isRedirectError(error)) { - throw error; - } + rethrow(error); return { status: 'error', diff --git a/core/app/[locale]/(default)/(auth)/login/_components/login-form.tsx b/core/app/[locale]/(default)/(auth)/login/_components/login-form.tsx index c4d6f6186..85df228be 100644 --- a/core/app/[locale]/(default)/(auth)/login/_components/login-form.tsx +++ b/core/app/[locale]/(default)/(auth)/login/_components/login-form.tsx @@ -1,8 +1,10 @@ 'use client'; +import { AlertCircle, Check } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { ChangeEvent, useState } from 'react'; -import { useFormState, useFormStatus } from 'react-dom'; +import { useFormStatus } from 'react-dom'; +import { toast } from 'react-hot-toast'; import { Link } from '~/components/link'; import { Button } from '~/components/ui/button'; @@ -15,9 +17,7 @@ import { FormSubmit, Input, } from '~/components/ui/form'; -import { Message } from '~/components/ui/message'; -import { useAccountStatusContext } from '../../../account/(tabs)/_components/account-status-provider'; import { login } from '../_actions/login'; const SubmitButton = () => { @@ -41,10 +41,6 @@ export const LoginForm = () => { const [isEmailValid, setIsEmailValid] = useState(true); const [isPasswordValid, setIsPasswordValid] = useState(true); - const [state, formAction] = useFormState(login, { status: 'idle' }); - const { accountState } = useAccountStatusContext(); - - const isFormInvalid = state?.status === 'error'; const handleInputValidation = (e: ChangeEvent) => { const validationStatus = e.target.validity.valueMissing; @@ -62,71 +58,74 @@ export const LoginForm = () => { } }; + 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 ( - <> - {accountState.status === 'success' && ( - -

{accountState.message}

-
- )} - - {isFormInvalid && ( - -

{t('Form.error')}

-
- )} -
- - {t('Form.emailLabel')} - - - - - {t('Form.enterEmailMessage')} - - - - {t('Form.passwordLabel')} - - - - - {t('Form.entePasswordMessage')} - - -
- - - - - {t('Form.forgotPassword')} - -
-
- +
+ + {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 79622ae50..0eb123c56 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 @@ -26,17 +26,16 @@ const ResetPasswordMutation = graphql(` } `); -interface SubmitResetPasswordForm { - formData: FormData; - path: string; - reCaptchaToken: string; +interface SubmitResetPasswordResponse { + status: 'success' | 'error'; + message: string; } -export const resetPassword = async ({ - formData, - path, - reCaptchaToken, -}: SubmitResetPasswordForm) => { +export const resetPassword = async ( + formData: FormData, + path: string, + reCaptchaToken?: string, +): Promise => { const t = await getTranslations('Login.ForgotPassword'); try { @@ -60,19 +59,24 @@ export const resetPassword = async ({ const result = response.data.customer.requestResetPassword; - if (result.errors.length === 0) { - return { status: 'success', data: parsedData }; + if (result.errors.length > 0) { + result.errors.forEach((error) => { + throw new Error(error.message); + }); } return { - status: 'error', - error: result.errors.map((error) => error.message).join('\n'), + status: 'success', + message: t('Form.confirmResetPassword', { email: parsedData.email }), }; } catch (error: unknown) { if (error instanceof Error || error instanceof z.ZodError) { - return { status: 'error', error: error.message }; + return { + status: 'error', + message: error.message, + }; } - return { status: 'error', error: t('Errors.error') }; + return { status: 'error', message: t('Errors.error') }; } }; 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 index b84e55abd..c039c4a40 100644 --- 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 @@ -1,11 +1,12 @@ 'use client'; +import { AlertCircle, Check } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { ChangeEvent, useEffect, useRef, useState } from 'react'; +import { ChangeEvent, useRef, useState } from 'react'; import { useFormStatus } from 'react-dom'; import ReCaptcha from 'react-google-recaptcha'; +import { toast } from 'react-hot-toast'; -import { useAccountStatusContext } from '~/app/[locale]/(default)/account/(tabs)/_components/account-status-provider'; import { type FragmentOf } from '~/client/graphql'; import { Button } from '~/components/ui/button'; import { @@ -17,7 +18,6 @@ import { FormSubmit, Input, } from '~/components/ui/form'; -import { Message } from '~/components/ui/message'; import { useRouter } from '~/i18n/routing'; import { resetPassword } from '../../_actions/reset-password'; @@ -28,11 +28,6 @@ interface Props { reCaptchaSettings?: FragmentOf; } -interface FormStatus { - status: 'success' | 'error'; - message: string; -} - const SubmitButton = () => { const t = useTranslations('Login.ForgotPassword.Form'); @@ -55,78 +50,57 @@ export const ResetPasswordForm = ({ reCaptchaSettings }: Props) => { const t = useTranslations('Login.ForgotPassword.Form'); const form = useRef(null); - const [formStatus, setFormStatus] = useState(null); const [isEmailValid, setIsEmailValid] = useState(true); const reCaptchaRef = useRef(null); - const [reCaptchaToken, setReCaptchaToken] = useState(''); - const [isReCaptchaValid, setReCaptchaValid] = useState(true); - const { setAccountState } = useAccountStatusContext(); + const [reCaptchaToken, setReCaptchaToken] = useState(); const router = useRouter(); - useEffect(() => { - setAccountState({ status: 'idle' }); - }, [setAccountState]); + const isReCaptchaValid = Boolean(reCaptchaToken); const onReCatpchaChange = (token: string | null) => { if (!token) { - setReCaptchaValid(false); + setReCaptchaToken(undefined); return; } setReCaptchaToken(token); - setReCaptchaValid(true); }; const handleEmailValidation = (e: ChangeEvent) => { - const validationStatus = e.target.validity.valueMissing; + const validationStatus = e.target.validity.valueMissing || e.target.validity.typeMismatch; setIsEmailValid(!validationStatus); }; const onSubmit = async (formData: FormData) => { - if (reCaptchaSettings?.isEnabledOnStorefront && !reCaptchaToken) { - setReCaptchaValid(false); - + if (reCaptchaSettings?.isEnabledOnStorefront && !isReCaptchaValid) { return; } - setReCaptchaValid(true); - - const submit = await resetPassword({ - formData, - reCaptchaToken, - path: '/change-password', - }); - - if (submit.status === 'success') { - form.current?.reset(); + const { status, message } = await resetPassword(formData, '/change-password', reCaptchaToken); - const customerEmail = formData.get('email'); + if (status === 'error') { + reCaptchaRef.current?.reset(); - setAccountState({ - status: 'success', - message: t('confirmResetPassword', { email: customerEmail?.toString() }), + toast.error(message, { + icon: , }); - router.push('/login'); - } - if (submit.status === 'error') { - setFormStatus({ status: 'error', message: submit.error ?? '' }); + return; } - reCaptchaRef.current?.reset(); + toast.success(message, { + icon: , + }); + + form.current?.reset(); + router.push('/login'); }; return ( <> - {formStatus?.status === 'error' && ( - -

{formStatus.message}

-
- )} -

{t('description')}

@@ -144,11 +118,17 @@ export const ResetPasswordForm = ({ reCaptchaSettings }: Props) => { /> {t('emailValidationMessage')} + + {t('emailValidationMessage')} + {reCaptchaSettings?.isEnabledOnStorefront && ( @@ -159,7 +139,7 @@ export const ResetPasswordForm = ({ reCaptchaSettings }: Props) => { sitekey={reCaptchaSettings.siteKey} /> {!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 46ccd5a6b..df5798f25 100644 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx @@ -39,10 +39,12 @@ export default async function Reset() { fetchOptions: { next: { revalidate } }, }); + const recaptchaSettings = await bypassReCaptcha(data.site.settings?.reCaptcha); + return (

{t('heading')}

- +
); } diff --git a/core/app/[locale]/(default)/(auth)/login/page.tsx b/core/app/[locale]/(default)/(auth)/login/page.tsx index 26fe533c0..59d2078ed 100644 --- a/core/app/[locale]/(default)/(auth)/login/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/page.tsx @@ -1,13 +1,15 @@ -import { useTranslations } from 'next-intl'; -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Link } from '~/components/link'; import { Button } from '~/components/ui/button'; -import { locales, LocaleType } from '~/i18n/routing'; import { LoginForm } from './_components/login-form'; -export async function generateMetadata() { +export async function generateMetadata({ params }: Props) { + const { locale } = await params; + + setRequestLocale(locale); + const t = await getTranslations('Login'); return { @@ -16,23 +18,25 @@ export async function generateMetadata() { } interface Props { - params: { locale: LocaleType }; + params: Promise<{ locale: string }>; } -export default function Login({ params: { locale } }: Props) { - unstable_setRequestLocale(locale); +export default async function Login({ params }: Props) { + const { locale } = await params; - const t = useTranslations('Login'); + setRequestLocale(locale); + + const t = await getTranslations('Login'); return (
-

{t('heading')}

+

{t('heading')}

-

{t('CreateAccount.heading')}

+

{t('CreateAccount.heading')}

{t('CreateAccount.accountBenefits')}

-
    +
    • {t('CreateAccount.fastCheckout')}
    • {t('CreateAccount.multipleAddresses')}
    • {t('CreateAccount.ordersHistory')}
    • @@ -47,9 +51,3 @@ export default function Login({ params: { locale } }: Props) {
); } - -export function generateStaticParams() { - return locales.map((locale) => ({ locale })); -} - -export const dynamic = 'force-static'; diff --git a/core/app/[locale]/(default)/(auth)/register/_actions/login.ts b/core/app/[locale]/(default)/(auth)/register/_actions/login.ts index 434282291..2b6ebd652 100644 --- a/core/app/[locale]/(default)/(auth)/register/_actions/login.ts +++ b/core/app/[locale]/(default)/(auth)/register/_actions/login.ts @@ -1,12 +1,15 @@ 'use server'; -import { isRedirectError } from 'next/dist/client/components/redirect'; +import { unstable_rethrow as rethrow } from 'next/navigation'; +import { getLocale } from 'next-intl/server'; import { Credentials, signIn } from '~/auth'; import { redirect } from '~/i18n/routing'; export const login = async (formData: FormData) => { try { + const locale = await getLocale(); + const credentials = Credentials.parse({ email: formData.get('customer-email'), password: formData.get('customer-password'), @@ -19,11 +22,9 @@ export const login = async (formData: FormData) => { redirect: false, }); - redirect('/account'); + redirect({ href: '/account/orders', locale }); } catch (error: unknown) { - if (isRedirectError(error)) { - throw error; - } + rethrow(error); return { status: 'error', 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 ac496e11e..23de2ae83 100644 --- a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts +++ b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts @@ -37,20 +37,23 @@ const RegisterCustomerMutation = graphql(` type Variables = VariablesOf; type RegisterCustomerInput = Variables['input']; -interface RegisterCustomerForm { - formData: FormData; - reCaptchaToken?: string; -} - const isRegisterCustomerInput = (data: unknown): data is RegisterCustomerInput => { - if (typeof data === 'object' && data !== null && 'email' in data && 'address' in data) { + if (typeof data === 'object' && data !== null && 'email' in data) { return true; } return false; }; -export const registerCustomer = async ({ formData, reCaptchaToken }: RegisterCustomerForm) => { +interface RegisterCustomerResponse { + status: 'success' | 'error'; + message: string; +} + +export const registerCustomer = async ( + formData: FormData, + reCaptchaToken?: string, +): Promise => { const t = await getTranslations('Register'); formData.delete('customer-confirmPassword'); @@ -60,7 +63,7 @@ export const registerCustomer = async ({ formData, reCaptchaToken }: RegisterCus if (!isRegisterCustomerInput(parsedData)) { return { status: 'error', - error: t('Errors.inputError'), + message: t('Errors.inputError'), }; } @@ -78,14 +81,13 @@ export const registerCustomer = async ({ formData, reCaptchaToken }: RegisterCus const result = response.data.customer.registerCustomer; - if (result.errors.length === 0) { - return { status: 'success', data: parsedData }; + if (result.errors.length > 0) { + result.errors.forEach((error) => { + throw new Error(error.message); + }); } - return { - status: 'error', - error: result.errors.map((error) => error.message).join('\n'), - }; + return { status: 'success', message: t('Form.successMessage') }; } catch (error) { // eslint-disable-next-line no-console console.error(error); @@ -93,13 +95,13 @@ export const registerCustomer = async ({ formData, reCaptchaToken }: RegisterCus if (error instanceof BigCommerceAPIError) { return { status: 'error', - error: t('Errors.apiError'), + message: t('Errors.apiError'), }; } return { status: 'error', - error: t('Errors.error'), + message: t('Errors.error'), }; } }; 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 index b0fff75e1..573ace62f 100644 --- a/core/app/[locale]/(default)/(auth)/register/_components/register-customer-form.tsx +++ b/core/app/[locale]/(default)/(auth)/register/_components/register-customer-form.tsx @@ -1,11 +1,12 @@ '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 { useAccountStatusContext } from '~/app/[locale]/(default)/account/(tabs)/_components/account-status-provider'; import { ExistingResultType } from '~/client/util'; import { Checkboxes, @@ -14,11 +15,11 @@ import { DateField, FieldNameToFieldId, FieldWrapper, + FULL_NAME_FIELDS, MultilineText, NumbersOnly, Password, Picklist, - PicklistOrText, RadioButtons, Text, } from '~/components/form-fields'; @@ -33,32 +34,17 @@ import { } from '~/components/form-fields/shared/field-handlers'; import { Button } from '~/components/ui/button'; import { Field, Form, FormSubmit } from '~/components/ui/form'; -import { Message } from '~/components/ui/message'; import { login } from '../_actions/login'; import { registerCustomer } from '../_actions/register-customer'; import { getRegisterCustomerQuery } from '../page-data'; -interface FormStatus { - status: 'success' | 'error'; - message: string; -} - type CustomerFields = ExistingResultType['customerFields']; type AddressFields = ExistingResultType['addressFields']; -type Countries = ExistingResultType['countries']; -type CountryCode = Countries[number]['code']; -type CountryStates = Countries[number]['statesOrProvinces']; interface RegisterCustomerProps { addressFields: AddressFields; - countries: Countries; customerFields: CustomerFields; - defaultCountry: { - entityId: number; - code: CountryCode; - states: CountryStates; - }; reCaptchaSettings?: { isEnabledOnStorefront: boolean; siteKey: string; @@ -89,13 +75,10 @@ const SubmitButton = ({ messages }: SumbitMessages) => { export const RegisterCustomerForm = ({ addressFields, - countries, customerFields, - defaultCountry, reCaptchaSettings, }: RegisterCustomerProps) => { const form = useRef(null); - const [formStatus, setFormStatus] = useState(null); const [textInputValid, setTextInputValid] = useState>({}); const [passwordValid, setPassswordValid] = useState>({ @@ -104,7 +87,6 @@ export const RegisterCustomerForm = ({ }); const [numbersInputValid, setNumbersInputValid] = useState>({}); const [datesValid, setDatesValid] = useState>({}); - const [countryStates, setCountryStates] = useState(defaultCountry.states); const [radioButtonsValid, setRadioButtonsValid] = useState>({}); const [picklistValid, setPicklistValid] = useState>({}); const [checkboxesValid, setCheckboxesValid] = useState>({}); @@ -114,8 +96,6 @@ export const RegisterCustomerForm = ({ const [reCaptchaToken, setReCaptchaToken] = useState(''); const [isReCaptchaValid, setReCaptchaValid] = useState(true); - const { setAccountState } = useAccountStatusContext(); - const t = useTranslations('Register.Form'); const handleTextInputValidation = (e: ChangeEvent) => { @@ -178,11 +158,6 @@ export const RegisterCustomerForm = ({ } }; - const handleCountryChange = (value: string) => { - const states = countries.find(({ code }) => code === value)?.statesOrProvinces; - - setCountryStates(states ?? []); - }; const handleRadioButtonsChange = createRadioButtonsValidationHandler( setRadioButtonsValid, radioButtonsValid, @@ -217,9 +192,8 @@ export const RegisterCustomerForm = ({ const onSubmit = async (formData: FormData) => { if (formData.get('customer-password') !== formData.get('customer-confirmPassword')) { - setFormStatus({ - status: 'error', - message: t('confirmPassword'), + toast.error(t('confirmPassword'), { + icon: , }); window.scrollTo({ @@ -238,159 +212,55 @@ export const RegisterCustomerForm = ({ setReCaptchaValid(true); - const submit = await registerCustomer({ formData, reCaptchaToken }); + const { status, message } = await registerCustomer(formData, reCaptchaToken); - if (submit.status === 'success') { - setAccountState({ status: 'success' }); - - await login(formData); - } + if (status === 'error') { + toast.error(message, { + icon: , + }); - if (submit.status === 'error') { - setFormStatus({ status: 'error', message: submit.error ?? '' }); + return; } - window.scrollTo({ - top: 0, - behavior: 'smooth', + toast.success(message, { + icon: , }); + + await login(formData); }; return ( - <> - {formStatus && ( - -

{formStatus.message}

-
- )} - -
- {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 'MultilineTextFormField': { - return ( - - - - ); - } - - case 'NumberFormField': { - return ( - - - - ); - } - - case 'DateFormField': { - return ( - - - - ); - } - - case 'RadioButtonsFormField': { - return ( - - - - ); - } - - case 'PicklistFormField': { - return ( - - - - ); - } - - case 'CheckboxesFormField': { - return ( - - - - ); - } - - case 'PasswordFormField': { - return ( - - - - ); - } - - default: - return null; - } - })} -
-
- {addressFields.map((field) => { + +
+ {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, 'address'); + const fieldName = createFieldName(field, 'customer'); switch (field.__typename) { - case 'TextFormField': { + case 'TextFormField': return ( + + ); + + case 'PasswordFormField': + return ( + + ); - } case 'MultilineTextFormField': { return ( @@ -457,21 +339,14 @@ export const RegisterCustomerForm = ({ } case 'PicklistFormField': { - const isCountrySelector = fieldId === FieldNameToFieldId.countryCode; - const picklistOptions = isCountrySelector - ? countries.map(({ name, code }) => ({ label: name, entityId: code })) - : field.options; - return ( ); @@ -491,62 +366,29 @@ export const RegisterCustomerForm = ({ ); } - case 'PicklistOrTextFormField': { - return ( - - { - return { entityId: name, label: name }; - })} - /> - - ); - } - - case 'PasswordFormField': { - return ( - - - - ); - } - default: return null; } })} - {reCaptchaSettings?.isEnabledOnStorefront && ( - - - {!isReCaptchaValid && ( - - {t('recaptchaText')} - - )} - - )} -
- - - - - - + {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 977aa4595..919742b8f 100644 --- a/core/app/[locale]/(default)/(auth)/register/page-data.ts +++ b/core/app/[locale]/(default)/(auth)/register/page-data.ts @@ -1,6 +1,6 @@ import { cache } from 'react'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { FormFieldsFragment } from '~/components/form-fields/fragment'; @@ -69,7 +69,7 @@ interface Props { } export const getRegisterCustomerQuery = cache(async ({ address, customer }: Props = {}) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const response = await client.fetch({ document: RegisterCustomerQuery, @@ -80,7 +80,7 @@ export const getRegisterCustomerQuery = cache(async ({ address, customer }: Prop customerSortBy: customer?.sortBy, }, fetchOptions: { cache: 'no-store' }, - customerId, + customerAccessToken, }); const addressFields = response.data.site.settings?.formFields.shippingAddress; @@ -89,7 +89,7 @@ export const getRegisterCustomerQuery = cache(async ({ address, customer }: Prop const countries = response.data.geography.countries; const defaultCountry = response.data.site.settings?.contact?.country; - const reCaptchaSettings = bypassReCaptcha(response.data.site.settings?.reCaptcha); + const reCaptchaSettings = await bypassReCaptcha(response.data.site.settings?.reCaptcha); if (!addressFields || !customerFields || !countries) { return null; diff --git a/core/app/[locale]/(default)/(auth)/register/page.tsx b/core/app/[locale]/(default)/(auth)/register/page.tsx index 6e7852a4b..3e272f924 100644 --- a/core/app/[locale]/(default)/(auth)/register/page.tsx +++ b/core/app/[locale]/(default)/(auth)/register/page.tsx @@ -6,12 +6,6 @@ import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; import { RegisterCustomerForm } from './_components/register-customer-form'; import { getRegisterCustomerQuery } from './page-data'; -const FALLBACK_COUNTRY = { - entityId: 226, - name: 'United States', - code: 'US', -}; - export async function generateMetadata() { const t = await getTranslations('Register'); @@ -32,29 +26,16 @@ export default async function Register() { notFound(); } - const { - addressFields, - customerFields, - countries, - defaultCountry = FALLBACK_COUNTRY.name, - reCaptchaSettings, - } = registerCustomerData; - - const { - code = FALLBACK_COUNTRY.code, - entityId = FALLBACK_COUNTRY.entityId, - statesOrProvinces, - } = countries.find(({ name }) => name === defaultCountry) || {}; + const { addressFields, customerFields, reCaptchaSettings } = registerCustomerData; + const reCaptcha = await bypassReCaptcha(reCaptchaSettings); return (

{t('heading')}

); diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx index 89bfa6f68..8abd438b2 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -1,10 +1,9 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; import { ProductCard } from '~/components/product-card'; import { Pagination } from '~/components/ui/pagination'; -import { LocaleType } from '~/i18n/routing'; import { FacetedSearch } from '../../_components/faceted-search'; import { MobileSideNav } from '../../_components/mobile-side-nav'; @@ -14,14 +13,15 @@ import { fetchFacetedSearch } from '../../fetch-faceted-search'; import { getBrand } from './page-data'; interface Props { - params: { + params: Promise<{ slug: string; - locale: LocaleType; - }; - searchParams: Record; + locale: string; + }>; + searchParams: Promise>; } -export async function generateMetadata({ params }: Props): Promise { +export async function generateMetadata(props: Props): Promise { + const params = await props.params; const brandId = Number(params.slug); const brand = await getBrand({ entityId: brandId }); @@ -39,8 +39,13 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function Brand({ params: { slug, locale }, searchParams }: Props) { - unstable_setRequestLocale(locale); +export default async function Brand(props: Props) { + const searchParams = await props.searchParams; + const params = await props.params; + + const { slug, locale } = params; + + setRequestLocale(locale); const t = await getTranslations('Brand'); diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts index c8639e782..3aa721022 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts @@ -1,6 +1,6 @@ import { cache } from 'react'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; @@ -31,13 +31,13 @@ const CategoryPageQuery = graphql( type Variables = VariablesOf; export const getCategoryPageData = cache(async (variables: Variables) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const response = await client.fetch({ document: CategoryPageQuery, variables, - customerId, - fetchOptions: customerId ? { cache: 'no-store' } : { next: { revalidate } }, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, }); return response.data.site; diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index 17ade79d7..28905b1a9 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -1,11 +1,10 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Breadcrumbs } from '~/components/breadcrumbs'; import { ProductCard } from '~/components/product-card'; import { Pagination } from '~/components/ui/pagination'; -import { LocaleType } from '~/i18n/routing'; import { FacetedSearch } from '../../_components/faceted-search'; import { MobileSideNav } from '../../_components/mobile-side-nav'; @@ -18,15 +17,16 @@ import { SubCategories } from './_components/sub-categories'; import { getCategoryPageData } from './page-data'; interface Props { - params: { + params: Promise<{ slug: string; - locale: LocaleType; - }; - searchParams: Record; + locale: string; + }>; + searchParams: Promise>; } export async function generateMetadata({ params }: Props): Promise { - const categoryId = Number(params.slug); + const { slug } = await params; + const categoryId = Number(slug); const data = await getCategoryPageData({ categoryId, @@ -47,8 +47,13 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function Category({ params: { locale, slug }, searchParams }: Props) { - unstable_setRequestLocale(locale); +export default async function Category(props: Props) { + const searchParams = await props.searchParams; + const params = await props.params; + + const { locale, slug } = params; + + setRequestLocale(locale); const t = await getTranslations('Category'); diff --git a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts index 39670b1b4..798f43e5f 100644 --- a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts +++ b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts @@ -2,7 +2,7 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; import { z } from 'zod'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql, VariablesOf } from '~/client/graphql'; @@ -167,15 +167,15 @@ interface ProductSearch { const getProductSearchResults = cache( async ({ limit = 9, after, before, sort, filters }: ProductSearch) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const filterArgs = { filters, sort }; const paginationArgs = before ? { last: limit, before } : { first: limit, after }; const response = await client.fetch({ document: GetProductSearchResultsQuery, variables: { ...filterArgs, ...paginationArgs }, - customerId, - fetchOptions: customerId ? { cache: 'no-store' } : { next: { revalidate: 300 } }, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 300 } }, }); const { site } = response.data; @@ -236,7 +236,7 @@ const SearchParamToArray = SearchParamSchema.transform((value) => { return value; } - if (typeof value === 'string') { + if (typeof value === 'string' && value !== '') { return [value]; } diff --git a/core/app/[locale]/(default)/(faceted)/search/page.tsx b/core/app/[locale]/(default)/(faceted)/search/page.tsx index 1cb1d97f6..08a77334f 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/search/page.tsx @@ -18,10 +18,11 @@ export async function generateMetadata() { } interface Props { - searchParams: Record; + searchParams: Promise>; } -export default async function Search({ searchParams }: Props) { +export default async function Search(props: Props) { + const searchParams = await props.searchParams; const t = await getTranslations('Search'); const searchTerm = typeof searchParams.term === 'string' ? searchParams.term : undefined; diff --git a/core/app/[locale]/(default)/account/(tabs)/_components/account-notification.tsx b/core/app/[locale]/(default)/account/(tabs)/_components/account-notification.tsx deleted file mode 100644 index d556208b0..000000000 --- a/core/app/[locale]/(default)/account/(tabs)/_components/account-notification.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client'; - -import { Message } from '~/components/ui/message'; - -import { useAccountStatusContext } from './account-status-provider'; - -interface Props { - message: string; -} - -export const AccountNotification = ({ message }: Props) => { - const { accountState } = useAccountStatusContext(); - - return ( - accountState.status === 'success' && ( - -

{message}

-
- ) - ); -}; diff --git a/core/app/[locale]/(default)/account/(tabs)/_components/account-status-provider.tsx b/core/app/[locale]/(default)/account/(tabs)/_components/account-status-provider.tsx deleted file mode 100644 index 6683b9778..000000000 --- a/core/app/[locale]/(default)/account/(tabs)/_components/account-status-provider.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; - -import { usePathname } from '~/i18n/routing'; - -import { State as AccountState } from '../settings/change-password/_actions/change-password'; - -const defaultState: AccountState = { status: 'idle', message: '' }; - -export const AccountStatusContext = createContext<{ - accountState: AccountState; - setAccountState: (state: AccountState | ((prevState: AccountState) => AccountState)) => void; -} | null>(null); - -export const AccountStatusProvider = ({ children }: { children: ReactNode }) => { - const [accountState, setAccountState] = useState(defaultState); - const pathname = usePathname(); - - useEffect(() => { - if (pathname !== '/account/' && pathname !== '/login/' && pathname !== '/account/addresses/') { - setAccountState(defaultState); - } - }, [pathname]); - - return ( - - {children} - - ); -}; - -export function useAccountStatusContext() { - const context = useContext(AccountStatusContext); - - if (!context) { - throw new Error('useAccountStatusContext must be used within a AccountStatusProvider'); - } - - return context; -} diff --git a/core/app/[locale]/(default)/account/(tabs)/_components/tab-heading.tsx b/core/app/[locale]/(default)/account/(tabs)/_components/tab-heading.tsx deleted file mode 100644 index 0d4727108..000000000 --- a/core/app/[locale]/(default)/account/(tabs)/_components/tab-heading.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { useTranslations } from 'next-intl'; - -import { TabType } from '../layout'; - -export const TabHeading = ({ heading }: { heading: TabType }) => { - const t = useTranslations('Account.Home'); - - return

{t(heading)}

; -}; diff --git a/core/app/[locale]/(default)/account/(tabs)/addresses/_components/address-book.tsx b/core/app/[locale]/(default)/account/(tabs)/addresses/_components/address-book.tsx deleted file mode 100644 index 27e894a02..000000000 --- a/core/app/[locale]/(default)/account/(tabs)/addresses/_components/address-book.tsx +++ /dev/null @@ -1,131 +0,0 @@ -'use client'; - -import { useTranslations } from 'next-intl'; -import { PropsWithChildren, useState } from 'react'; - -import { ExistingResultType } from '~/client/util'; -import { Link } from '~/components/link'; -import { Button } from '~/components/ui/button'; -import { Message } from '~/components/ui/message'; - -import { useAccountStatusContext } from '../../_components/account-status-provider'; -import { Modal } from '../../_components/modal'; -import { deleteAddress } from '../_actions/delete-address'; -import { getCustomerAddresses } from '../page-data'; - -export type Addresses = ExistingResultType['addresses']; - -interface AddressChangeProps { - addressId: number; - isAddressRemovable: boolean; - onDelete: (state: Addresses | ((prevState: Addresses) => Addresses)) => void; -} - -const AddressChangeButtons = ({ addressId, isAddressRemovable, onDelete }: AddressChangeProps) => { - const { setAccountState } = useAccountStatusContext(); - const t = useTranslations('Account.Addresses'); - - const handleDeleteAddress = async () => { - const submit = await deleteAddress(addressId); - - if (submit.status === 'success') { - onDelete((prevAddressBook) => - prevAddressBook.filter(({ entityId }) => entityId !== addressId), - ); - - setAccountState({ - status: 'success', - message: submit.message || '', - }); - } - }; - - return ( -
- - - - -
- ); -}; - -interface AddressBookProps { - customerAddresses: Addresses; - addressesCount: number; -} - -export const AddressBook = ({ - children, - addressesCount, - customerAddresses, -}: PropsWithChildren) => { - const t = useTranslations('Account.Addresses'); - const [addressBook, setAddressBook] = useState(customerAddresses); - const { accountState } = useAccountStatusContext(); - - return ( - <> - {(accountState.status === 'error' || accountState.status === 'success') && ( - -

{accountState.message}

-
- )} -
    - {addressBook.map( - ({ - entityId, - firstName, - lastName, - address1, - address2, - city, - stateOrProvince, - postalCode, - countryCode, - }) => ( -
  • -
    -

    - {firstName} {lastName} -

    -

    {address1}

    - {Boolean(address2) &&

    {address2}

    } -

    - {city}, {stateOrProvince} {postalCode} -

    -

    {countryCode}

    -
    - 1} - onDelete={setAddressBook} - /> -
  • - ), - )} -
  • - - {children} -
  • -
- - ); -}; diff --git a/core/app/[locale]/(default)/account/(tabs)/addresses/edit/[slug]/_components/edit-address-form.tsx b/core/app/[locale]/(default)/account/(tabs)/addresses/edit/[slug]/_components/edit-address-form.tsx deleted file mode 100644 index b6c2570b5..000000000 --- a/core/app/[locale]/(default)/account/(tabs)/addresses/edit/[slug]/_components/edit-address-form.tsx +++ /dev/null @@ -1,470 +0,0 @@ -'use client'; - -import { useTranslations } from 'next-intl'; -import { MouseEvent, useEffect, useRef, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import ReCaptcha from 'react-google-recaptcha'; - -import { - Checkboxes, - createFieldName, - DateField, - FieldNameToFieldId, - FieldWrapper, - getPreviouslySubmittedValue, - MultilineText, - NumbersOnly, - Password, - Picklist, - PicklistOrText, - RadioButtons, - Text, -} from '~/components/form-fields'; -import { - createDatesValidationHandler, - createMultilineTextValidationHandler, - createNumbersInputValidationHandler, - createPasswordValidationHandler, - createPreSubmitCheckboxesValidationHandler, - createPreSubmitPicklistValidationHandler, - createRadioButtonsValidationHandler, - createTextInputValidationHandler, - type FieldStateSetFn, -} from '~/components/form-fields/shared/field-handlers'; -import { Link } from '~/components/link'; -import { Button } from '~/components/ui/button'; -import { Field, Form, FormSubmit } from '~/components/ui/form'; -import { Message } from '~/components/ui/message'; -import { useRouter } from '~/i18n/routing'; - -import { useAccountStatusContext } from '../../../../_components/account-status-provider'; -import { Modal } from '../../../../_components/modal'; -import { deleteAddress } from '../../../_actions/delete-address'; -import { updateAddress } from '../_actions/update-address'; -import { CustomerEditAddressQueryResult } from '../page'; - -interface FormStatus { - status: 'success' | 'error'; - message: string; -} - -type Address = NonNullable< - NonNullable['addresses']['edges'] ->[number]['node']; - -type AddressFields = NonNullable< - CustomerEditAddressQueryResult['site']['settings'] ->['formFields']['shippingAddress']; - -type Countries = NonNullable; -type CountryStates = Countries[number]['statesOrProvinces']; - -type FieldUnionType = Exclude< - keyof typeof FieldNameToFieldId, - 'email' | 'password' | 'confirmPassword' | 'exclusiveOffers' | 'currentPassword' ->; - -const createCountryChangeHandler = - (provinceSetter: FieldStateSetFn, countries: Countries) => (value: string) => { - const states = countries.find(({ code }) => code === value)?.statesOrProvinces; - - provinceSetter(states ?? []); - }; - -const isExistedField = (name: unknown): name is FieldUnionType => { - if (typeof name === 'string' && name in FieldNameToFieldId) { - return true; - } - - return false; -}; - -interface SumbitMessages { - messages: { - submit: string; - submitting: string; - }; -} - -const SubmitButton = ({ messages }: SumbitMessages) => { - const { pending } = useFormStatus(); - - return ( - - ); -}; - -interface EditAddressProps { - address: Address; - addressFields: AddressFields; - countries: Countries; - isAddressRemovable: boolean; - reCaptchaSettings?: { - isEnabledOnStorefront: boolean; - siteKey: string; - }; -} - -export const EditAddressForm = ({ - address, - addressFields, - countries, - isAddressRemovable, - reCaptchaSettings, -}: EditAddressProps) => { - const form = useRef(null); - const [formStatus, setFormStatus] = useState(null); - const t = useTranslations('Account.Addresses.Edit.Form'); - - const reCaptchaRef = useRef(null); - const router = useRouter(); - const [reCaptchaToken, setReCaptchaToken] = useState(''); - const [isReCaptchaValid, setReCaptchaValid] = useState(true); - const { setAccountState } = useAccountStatusContext(); - - useEffect(() => { - setAccountState({ status: 'idle' }); - }, [setAccountState]); - - const [textInputValid, setTextInputValid] = useState>({}); - const [passwordValid, setPasswordValid] = useState>({}); - 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 defaultStates = countries - .filter((country) => country.code === address.countryCode) - .flatMap((country) => country.statesOrProvinces); - const [countryStates, setCountryStates] = useState(defaultStates); - - const handleTextInputValidation = createTextInputValidationHandler( - setTextInputValid, - textInputValid, - ); - const handlePasswordValidation = createPasswordValidationHandler(setPasswordValid, addressFields); - const handleNumbersInputValidation = createNumbersInputValidationHandler( - setNumbersInputValid, - numbersInputValid, - ); - const handleCountryChange = createCountryChangeHandler(setCountryStates, countries); - const handleDatesValidation = createDatesValidationHandler(setDatesValid, datesValid); - const handleRadioButtonsChange = createRadioButtonsValidationHandler( - setRadioButtonsValid, - radioButtonsValid, - ); - const handleMultiTextValidation = createMultilineTextValidationHandler( - setMultiTextValid, - multiTextValid, - ); - - const onReCaptchaChange = (token: string | null) => { - if (!token) { - setReCaptchaValid(false); - - return; - } - - setReCaptchaToken(token); - setReCaptchaValid(true); - }; - - const validatePicklistFields = createPreSubmitPicklistValidationHandler( - addressFields, - setPicklistValid, - ); - - const validateCheckboxFields = createPreSubmitCheckboxesValidationHandler( - addressFields, - setCheckboxesValid, - ); - - 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) => { - if (reCaptchaSettings?.isEnabledOnStorefront && !reCaptchaToken) { - setReCaptchaValid(false); - - return; - } - - setReCaptchaValid(true); - - const submit = await updateAddress({ addressId: address.entityId, formData }); - - if (submit.status === 'success') { - setAccountState({ - status: 'success', - message: submit.message || '', - }); - - router.push('/account/addresses'); - - return; - } - - if (submit.status === 'error') { - setFormStatus({ status: 'error', message: submit.message || '' }); - } - - window.scrollTo({ - top: 0, - behavior: 'smooth', - }); - }; - - const onDeleteAddress = async () => { - const submit = await deleteAddress(address.entityId); - - if (submit.status === 'success') { - setAccountState({ status: submit.status, message: submit.message || '' }); - } - - if (submit.status === 'error') { - setAccountState({ status: submit.status, message: submit.message || '' }); - } - - router.push('/account/addresses'); - }; - - return ( - <> - {formStatus && ( - -

{formStatus.message}

-
- )} -
-
- {addressFields.map((field) => { - const fieldId = field.entityId; - const fieldName = createFieldName(field, 'address'); - const key = FieldNameToFieldId[fieldId]; - const defaultCustomField = address.formFields.find( - ({ entityId }) => entityId === fieldId, - ); - const defaultValue = (isExistedField(key) && address[key]) || undefined; - - switch (field.__typename) { - case 'TextFormField': { - const previousTextValue = - getPreviouslySubmittedValue(defaultCustomField).TextFormField; - - return ( - - - - ); - } - - case 'MultilineTextFormField': { - const previousMultiTextValue = - getPreviouslySubmittedValue(defaultCustomField).MultilineTextFormField; - - return ( - - - - ); - } - - case 'NumberFormField': { - const previousNumberValue = - getPreviouslySubmittedValue(defaultCustomField).NumberFormField; - - return ( - - - - ); - } - - case 'CheckboxesFormField': { - const previousCheckboxesValue = - getPreviouslySubmittedValue(defaultCustomField).CheckboxesFormField; - - return ( - - - - ); - } - - case 'DateFormField': { - const previousDateValue = - getPreviouslySubmittedValue(defaultCustomField).DateFormField; - - return ( - - - - ); - } - - case 'RadioButtonsFormField': { - const previousMultipleChoiceValue = - getPreviouslySubmittedValue(defaultCustomField).MultipleChoiceFormField; - - return ( - - - - ); - } - - case 'PicklistFormField': { - const isCountrySelector = fieldId === FieldNameToFieldId.countryCode; - const previousMultipleChoiceValue = - getPreviouslySubmittedValue(defaultCustomField).MultipleChoiceFormField; - const picklistOptions = isCountrySelector - ? countries.map(({ name, code }) => ({ label: name, entityId: code })) - : field.options; - - return ( - - - - ); - } - - case 'PicklistOrTextFormField': { - return ( - - ({ entityId: name, label: name }))} - /> - - ); - } - - case 'PasswordFormField': { - const previousPasswordValue = - getPreviouslySubmittedValue(defaultCustomField).PasswordFormField; - - return ( - - - - ); - } - - default: - return null; - } - })} - - {reCaptchaSettings?.isEnabledOnStorefront && ( - - - {!isReCaptchaValid && ( - - {t('recaptchaText')} - - )} - - )} -
- -
- - - - - - - -
-
- - ); -}; diff --git a/core/app/[locale]/(default)/account/(tabs)/layout.tsx b/core/app/[locale]/(default)/account/(tabs)/layout.tsx deleted file mode 100644 index 1e03f7bc4..000000000 --- a/core/app/[locale]/(default)/account/(tabs)/layout.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useTranslations } from 'next-intl'; -import { unstable_setRequestLocale } from 'next-intl/server'; -import { PropsWithChildren } from 'react'; - -import { Link } from '~/components/link'; -import { LocaleType } from '~/i18n/routing'; -import { cn } from '~/lib/utils'; - -const tabList = ['addresses', 'settings'] as const; - -export type TabType = (typeof tabList)[number]; - -interface Props extends PropsWithChildren { - params: { locale: LocaleType; tab?: TabType }; -} - -export default function AccountTabLayout({ children, params: { locale } }: Props) { - unstable_setRequestLocale(locale); - - const t = useTranslations('Account.Home'); - - const tabsTitles = { - addresses: t('addresses'), - settings: t('settings'), - }; - - return ( - <> -

{t('heading')}

- - {children} - - ); -} diff --git a/core/app/[locale]/(default)/account/(tabs)/settings/_components/update-settings-form.tsx b/core/app/[locale]/(default)/account/(tabs)/settings/_components/update-settings-form.tsx deleted file mode 100644 index 33dd14c03..000000000 --- a/core/app/[locale]/(default)/account/(tabs)/settings/_components/update-settings-form.tsx +++ /dev/null @@ -1,447 +0,0 @@ -'use client'; - -import { useTranslations } from 'next-intl'; -import { ChangeEvent, MouseEvent, useRef, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import ReCaptcha from 'react-google-recaptcha'; - -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 { Field, Form, FormSubmit } from '~/components/ui/form'; -import { Message } from '~/components/ui/message'; - -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; - reCaptchaSettings?: { - isEnabledOnStorefront: boolean; - siteKey: string; - }; -} - -interface FormStatus { - status: 'success' | 'error'; - message: string; -} - -interface SumbitMessages { - messages: { - submit: string; - submitting: string; - }; -} - -export enum FieldNameToFieldId { - email = 1, - firstName = 4, - lastName, - company, - phone, -} - -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, - reCaptchaSettings, -}: FormProps) => { - const form = useRef(null); - const [formStatus, setFormStatus] = useState(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 reCaptchaRef = useRef(null); - const [reCaptchaToken, setReCaptchaToken] = useState(''); - const [isReCaptchaValid, setReCaptchaValid] = useState(true); - - 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 onReCaptchaChange = (token: string | null) => { - if (!token) { - setReCaptchaValid(false); - - return; - } - - setReCaptchaToken(token); - setReCaptchaValid(true); - }; - - const onSubmit = async (formData: FormData) => { - if (reCaptchaSettings?.isEnabledOnStorefront && !reCaptchaToken) { - setReCaptchaValid(false); - - return; - } - - setReCaptchaValid(true); - - const submit = await updateCustomer({ formData, reCaptchaToken }); - - if (submit.status === 'success') { - setFormStatus({ - status: 'success', - message: t('successMessage', { - firstName: submit.data?.firstName, - lastName: submit.data?.lastName, - }), - }); - } - - if (submit.status === 'error') { - setFormStatus({ status: 'error', message: submit.error ?? '' }); - } - - window.scrollTo({ - top: 0, - behavior: 'smooth', - }); - }; - - return ( - <> - {formStatus && ( - -

{formStatus.message}

-
- )} -
-
- {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; - } - })} - - {reCaptchaSettings?.isEnabledOnStorefront && ( - - - {!isReCaptchaValid && ( - - {t('recaptchaText')} - - )} - - )} -
- - - - - - {t('changePassword')} - -
-
-
- - ); -}; diff --git a/core/app/[locale]/(default)/account/(tabs)/settings/change-password/_components/change-password-form.tsx b/core/app/[locale]/(default)/account/(tabs)/settings/change-password/_components/change-password-form.tsx deleted file mode 100644 index add4bdeb4..000000000 --- a/core/app/[locale]/(default)/account/(tabs)/settings/change-password/_components/change-password-form.tsx +++ /dev/null @@ -1,254 +0,0 @@ -'use client'; - -import { useTranslations } from 'next-intl'; -import { ChangeEvent, useEffect, useRef, useState } from 'react'; -import { useFormState, useFormStatus } from 'react-dom'; -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 { Message } from '~/components/ui/message'; - -import { useAccountStatusContext } from '../../../_components/account-status-provider'; -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 [state, formAction] = useFormState(changePassword, { - status: 'idle', - message: '', - }); - - const [isCurrentPasswordValid, setIsCurrentPasswordValid] = useState(true); - const [isNewPasswordValid, setIsNewPasswordValid] = useState(true); - const [isConfirmPasswordValid, setIsConfirmPasswordValid] = useState(true); - - const { setAccountState } = useAccountStatusContext(); - - useEffect(() => { - if (state.status === 'success') { - void logout(); - - setAccountState({ - status: 'success', - message: t('confirmChangePassword'), - }); - } - }, [state, setAccountState, t]); - - let messageText = ''; - - if (state.status === 'error') { - messageText = state.message; - } - - if (state.status === 'success') { - messageText = state.message; - } - - 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); - } - }; - - return ( - <> - {state.status === 'error' && ( - -

{messageText}

-
- )} - -
- - - {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/(tabs)/settings/change-password/page.tsx b/core/app/[locale]/(default)/account/(tabs)/settings/change-password/page.tsx deleted file mode 100644 index 6be6a836b..000000000 --- a/core/app/[locale]/(default)/account/(tabs)/settings/change-password/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; - -import { locales, LocaleType } from '~/i18n/routing'; - -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: { locale: LocaleType }; -} - -export default function ChangePassword({ params: { locale } }: Props) { - unstable_setRequestLocale(locale); - - return ( - <> - -
- -
- - ); -} - -export function generateStaticParams() { - return locales.map((locale) => ({ locale })); -} - -export const dynamic = 'force-static'; diff --git a/core/app/[locale]/(default)/account/(tabs)/_components/modal.tsx b/core/app/[locale]/(default)/account/_components/modal.tsx similarity index 100% rename from core/app/[locale]/(default)/account/(tabs)/_components/modal.tsx rename to core/app/[locale]/(default)/account/_components/modal.tsx diff --git a/core/app/[locale]/(default)/account/_components/tab-heading.tsx b/core/app/[locale]/(default)/account/_components/tab-heading.tsx new file mode 100644 index 000000000..b0e34e5cc --- /dev/null +++ b/core/app/[locale]/(default)/account/_components/tab-heading.tsx @@ -0,0 +1,9 @@ +import { getTranslations } from 'next-intl/server'; + +import { TabType } from './tab-navigation'; + +export const TabHeading = async ({ heading }: { heading: TabType }) => { + const t = await getTranslations('Account.Layout'); + + return

{t(heading)}

; +}; diff --git a/core/app/[locale]/(default)/account/_components/tab-navigation.tsx b/core/app/[locale]/(default)/account/_components/tab-navigation.tsx new file mode 100644 index 000000000..08b586012 --- /dev/null +++ b/core/app/[locale]/(default)/account/_components/tab-navigation.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +import { Link } from '~/components/link'; +import { usePathname } from '~/i18n/routing'; +import { cn } from '~/lib/utils'; + +const tabList = ['orders', 'addresses', 'settings'] as const; + +export type TabType = (typeof tabList)[number]; + +export const TabNavigation = () => { + const t = useTranslations('Account.Layout'); + const pathname = usePathname(); + const activeTab = pathname.slice(0, -1).split('/').pop(); + + const tabsTitles = { + addresses: t('addresses'), + settings: t('settings'), + orders: t('orders'), + }; + + return ( + + ); +}; diff --git a/core/app/[locale]/(default)/account/(tabs)/addresses/_actions/delete-address.ts b/core/app/[locale]/(default)/account/addresses/_actions/delete-address.ts similarity index 58% rename from core/app/[locale]/(default)/account/(tabs)/addresses/_actions/delete-address.ts rename to core/app/[locale]/(default)/account/addresses/_actions/delete-address.ts index 3f84c60af..7c4eed519 100644 --- a/core/app/[locale]/(default)/account/(tabs)/addresses/_actions/delete-address.ts +++ b/core/app/[locale]/(default)/account/addresses/_actions/delete-address.ts @@ -1,29 +1,23 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; - -import { State } from '../../settings/change-password/_actions/change-password'; +import { TAGS } from '~/client/tags'; const DeleteCustomerAddressMutation = graphql(` - mutation DeleteCustomerAddressMutation( - $reCaptcha: ReCaptchaV2Input - $input: DeleteCustomerAddressInput! - ) { + mutation DeleteCustomerAddressMutation($input: DeleteCustomerAddressInput!) { customer { - deleteCustomerAddress(reCaptchaV2: $reCaptcha, input: $input) { + deleteCustomerAddress(input: $input) { errors { __typename ... on CustomerAddressDeletionError { - __typename message } ... on CustomerNotLoggedInError { - __typename message } } @@ -32,15 +26,19 @@ const DeleteCustomerAddressMutation = graphql(` } `); -export const deleteAddress = async (addressId: number): Promise => { - const t = await getTranslations('Account.Addresses.Delete'); +interface DeleteAddressResponse { + status: 'success' | 'error'; + message: string; +} - const customerId = await getSessionCustomerId(); +export const deleteAddress = async (addressId: number): Promise => { + const t = await getTranslations('Account.Addresses.Delete'); + const customerAccessToken = await getSessionCustomerAccessToken(); try { const response = await client.fetch({ document: DeleteCustomerAddressMutation, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, variables: { input: { @@ -51,16 +49,16 @@ export const deleteAddress = async (addressId: number): Promise => { const result = response.data.customer.deleteCustomerAddress; - revalidatePath('/account/addresses', 'page'); - - if (result.errors.length === 0) { - return { status: 'success', message: t('success') }; + 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: 'error', - message: result.errors.map((error) => error.message).join('\n'), - }; + revalidateTag(TAGS.customer); + + return { status: 'success', message: t('success') }; } catch (error: unknown) { if (error instanceof Error) { return { diff --git a/core/app/[locale]/(default)/account/addresses/_components/address-book.tsx b/core/app/[locale]/(default)/account/addresses/_components/address-book.tsx new file mode 100644 index 000000000..2af1a6083 --- /dev/null +++ b/core/app/[locale]/(default)/account/addresses/_components/address-book.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { AlertCircle, Check } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { PropsWithChildren } from 'react'; +import { toast } from 'react-hot-toast'; + +import { ExistingResultType } from '~/client/util'; +import { Link } from '~/components/link'; +import { Button } from '~/components/ui/button'; + +import { Modal } from '../../_components/modal'; +import { deleteAddress } from '../_actions/delete-address'; +import { getCustomerAddresses } from '../page-data'; + +export type Addresses = ExistingResultType['addresses']; + +interface AddressBookProps { + customerAddresses: Addresses; + totalAddresses: number; +} + +export const AddressBook = ({ + children, + totalAddresses, + customerAddresses, +}: PropsWithChildren) => { + const t = useTranslations('Account.Addresses'); + + const handleDeleteAddress = (addressId: number) => async () => { + const { status, message } = await deleteAddress(addressId); + + if (status === 'error') { + toast.error(message, { + icon: , + }); + + return; + } + + toast.success(message, { + icon: , + }); + }; + + return ( + <> + {totalAddresses === 0 &&

{t('emptyAddresses')}

} +
    + {customerAddresses.map( + ({ + entityId, + firstName, + lastName, + address1, + address2, + city, + stateOrProvince, + postalCode, + countryCode, + }) => ( +
  • +
    +

    + {firstName} {lastName} +

    +

    {address1}

    + {Boolean(address2) &&

    {address2}

    } +

    + {city}, {stateOrProvince} {postalCode} +

    +

    {countryCode}

    +
    +
    + + + + +
    +
  • + ), + )} +
  • + + {children} +
  • +
+ + ); +}; diff --git a/core/app/[locale]/(default)/account/(tabs)/addresses/add/_actions/add-address.ts b/core/app/[locale]/(default)/account/addresses/add/_actions/add-address.ts similarity index 66% rename from core/app/[locale]/(default)/account/(tabs)/addresses/add/_actions/add-address.ts rename to core/app/[locale]/(default)/account/addresses/add/_actions/add-address.ts index 30bd6a590..468daa79b 100644 --- a/core/app/[locale]/(default)/account/(tabs)/addresses/add/_actions/add-address.ts +++ b/core/app/[locale]/(default)/account/addresses/add/_actions/add-address.ts @@ -1,20 +1,18 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; import { parseAccountFormData } from '~/components/form-fields/shared/parse-fields'; const AddCustomerAddressMutation = graphql(` - mutation AddCustomerAddressMutation( - $input: AddCustomerAddressInput! - $reCaptchaV2: ReCaptchaV2Input - ) { + mutation AddCustomerAddressMutation($input: AddCustomerAddressInput!) { customer { - addCustomerAddress(input: $input, reCaptchaV2: $reCaptchaV2) { + addCustomerAddress(input: $input) { errors { ... on CustomerAddressCreationError { message @@ -48,16 +46,14 @@ const isAddCustomerAddressInput = (data: unknown): data is AddCustomerAddressInp return false; }; -export const addAddress = async ({ - formData, - reCaptchaToken, -}: { - formData: FormData; - reCaptchaToken?: string; -}) => { - const t = await getTranslations('Account.Addresses.Add.Form'); +interface AddAddressResponse { + status: 'success' | 'error'; + message: string; +} - const customerId = await getSessionCustomerId(); +export const addAddress = async (formData: FormData): Promise => { + const t = await getTranslations('Account.Addresses.Add.Form'); + const customerAccessToken = await getSessionCustomerAccessToken(); try { const parsed = parseAccountFormData(formData); @@ -65,32 +61,31 @@ export const addAddress = async ({ if (!isAddCustomerAddressInput(parsed)) { return { status: 'error', - error: t('Errors.inputError'), + message: t('Errors.inputError'), }; } const response = await client.fetch({ document: AddCustomerAddressMutation, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, variables: { input: parsed, - ...(reCaptchaToken && { reCaptchaV2: { token: reCaptchaToken } }), }, }); const result = response.data.customer.addCustomerAddress; - revalidatePath('/account/addresses', 'page'); - - if (result.errors.length === 0) { - return { status: 'success', message: t('success') }; + 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: 'error', - message: result.errors.map((error) => error.message).join('\n'), - }; + revalidateTag(TAGS.customer); + + return { status: 'success', message: t('success') }; } catch (error: unknown) { if (error instanceof Error) { return { diff --git a/core/app/[locale]/(default)/account/addresses/add/_components/add-address-form.tsx b/core/app/[locale]/(default)/account/addresses/add/_components/add-address-form.tsx new file mode 100644 index 000000000..9a4786ee3 --- /dev/null +++ b/core/app/[locale]/(default)/account/addresses/add/_components/add-address-form.tsx @@ -0,0 +1,320 @@ +'use client'; + +import { AlertCircle, Check } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { MouseEvent, useRef, useState } from 'react'; +import { useFormStatus } from 'react-dom'; +import { toast } from 'react-hot-toast'; + +import { + Checkboxes, + createFieldName, + DateField, + FieldNameToFieldId, + FieldWrapper, + MultilineText, + NumbersOnly, + Password, + Picklist, + PicklistOrText, + RadioButtons, + Text, +} from '~/components/form-fields'; +import { + createDatesValidationHandler, + createMultilineTextValidationHandler, + createNumbersInputValidationHandler, + createPasswordValidationHandler, + createPreSubmitCheckboxesValidationHandler, + createPreSubmitPicklistValidationHandler, + createRadioButtonsValidationHandler, + createTextInputValidationHandler, + type FieldStateSetFn, +} 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 { useRouter } from '~/i18n/routing'; + +import { addAddress } from '../_actions/add-address'; +import { NewAddressQueryResult } from '../page'; + +type AddressFields = NonNullable< + NewAddressQueryResult['site']['settings'] +>['formFields']['shippingAddress']; + +type Countries = NonNullable; +type CountryCode = Countries[number]['code']; +type CountryStates = Countries[number]['statesOrProvinces']; + +const createCountryChangeHandler = + (provinceSetter: FieldStateSetFn, countries: Countries) => (value: string) => { + const states = countries.find(({ code }) => code === value)?.statesOrProvinces; + + provinceSetter(states ?? []); + }; + +interface SumbitMessages { + messages: { + submit: string; + submitting: string; + }; +} + +const SubmitButton = ({ messages }: SumbitMessages) => { + const { pending } = useFormStatus(); + + return ( + + ); +}; + +interface AddAddressProps { + addressFields: AddressFields; + countries: Countries; + defaultCountry: { + id: number; + code: CountryCode; + states: CountryStates; + }; +} + +export const AddAddressForm = ({ addressFields, countries, defaultCountry }: AddAddressProps) => { + const form = useRef(null); + + const router = useRouter(); + const t = useTranslations('Account.Addresses.Add.Form'); + + const [textInputValid, setTextInputValid] = useState>({}); + const [numbersInputValid, setNumbersInputValid] = useState>({}); + const [datesValid, setDatesValid] = useState>({}); + const [passwordValid, setPasswordValid] = useState>({}); + const [radioButtonsValid, setRadioButtonsValid] = useState>({}); + const [picklistValid, setPicklistValid] = useState>({}); + const [checkboxesValid, setCheckboxesValid] = useState>({}); + const [multiTextValid, setMultiTextValid] = useState>({}); + const [countryStates, setCountryStates] = useState(defaultCountry.states); + + const handleTextInputValidation = createTextInputValidationHandler( + setTextInputValid, + textInputValid, + ); + const handlePasswordValidation = createPasswordValidationHandler(setPasswordValid, addressFields); + const handleCountryChange = createCountryChangeHandler(setCountryStates, countries); + const handleNumbersInputValidation = createNumbersInputValidationHandler( + setNumbersInputValid, + numbersInputValid, + ); + const handleDatesValidation = createDatesValidationHandler(setDatesValid, datesValid); + const handleRadioButtonsChange = createRadioButtonsValidationHandler( + setRadioButtonsValid, + radioButtonsValid, + ); + const handleMultiTextValidation = createMultilineTextValidationHandler( + setMultiTextValid, + multiTextValid, + ); + const validatePicklistFields = createPreSubmitPicklistValidationHandler( + addressFields, + setPicklistValid, + ); + const validateCheckboxFields = createPreSubmitCheckboxesValidationHandler( + addressFields, + setCheckboxesValid, + ); + 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 addAddress(formData); + + if (status === 'error') { + toast.error(message, { + icon: , + }); + + return; + } + + toast.success(message, { + icon: , + }); + + router.push('/account/addresses', { scroll: true }); + }; + + return ( +
+
+ {addressFields.map((field) => { + const fieldId = field.entityId; + const fieldName = createFieldName(field, 'address'); + + switch (field.__typename) { + case 'TextFormField': { + return ( + + + + ); + } + + case 'MultilineTextFormField': { + return ( + + + + ); + } + + case 'NumberFormField': { + return ( + + + + ); + } + + case 'CheckboxesFormField': { + return ( + + + + ); + } + + case 'DateFormField': { + return ( + + + + ); + } + + case 'RadioButtonsFormField': { + return ( + + + + ); + } + + case 'PicklistFormField': { + const isCountrySelector = fieldId === FieldNameToFieldId.countryCode; + const picklistOptions = isCountrySelector + ? countries.map(({ name, code }) => ({ label: name, entityId: code })) + : field.options; + const defaultMultipleChoiceValue = isCountrySelector + ? defaultCountry.code + : undefined; + + return ( + + + + ); + } + + case 'PicklistOrTextFormField': + return ( + + { + return { entityId: name, label: name }; + })} + /> + + ); + + case 'PasswordFormField': { + return ( + + + + ); + } + + default: + return null; + } + })} +
+ +
+ + + + +
+
+ ); +}; diff --git a/core/app/[locale]/(default)/account/(tabs)/addresses/add/page.tsx b/core/app/[locale]/(default)/account/addresses/add/page.tsx similarity index 86% rename from core/app/[locale]/(default)/account/(tabs)/addresses/add/page.tsx rename to core/app/[locale]/(default)/account/addresses/add/page.tsx index 26fce41db..dee87581d 100644 --- a/core/app/[locale]/(default)/account/(tabs)/addresses/add/page.tsx +++ b/core/app/[locale]/(default)/account/addresses/add/page.tsx @@ -1,10 +1,9 @@ import { getTranslations } from 'next-intl/server'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, ResultOf } from '~/client/graphql'; import { FormFieldsFragment } from '~/components/form-fields/fragment'; -import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; import { AddAddressForm } from './_components/add-address-form'; @@ -20,10 +19,6 @@ const CustomerNewAdressQuery = graphql( contact { country } - reCaptcha { - isEnabledOnStorefront - siteKey - } formFields { shippingAddress(filters: $shippingFilters, sortBy: $shippingSorting) { ...FormFieldsFragment @@ -69,12 +64,11 @@ export async function generateMetadata() { export default async function AddPage() { const t = await getTranslations('Account.Addresses.Add'); - - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const { data } = await client.fetch({ document: CustomerNewAdressQuery, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, variables: { shippingSorting: 'SORT_ORDER', @@ -82,7 +76,6 @@ export default async function AddPage() { }); const addressFields = [...(data.site.settings?.formFields.shippingAddress ?? [])]; - const reCaptchaSettings = data.site.settings?.reCaptcha; const countries = data.geography.countries; const defaultCountry = data.site.settings?.contact?.country || FALLBACK_COUNTRY.name; @@ -99,7 +92,6 @@ export default async function AddPage() { addressFields={addressFields} countries={countries || []} defaultCountry={{ id: entityId, code, states: defaultCountryStates }} - reCaptchaSettings={bypassReCaptcha(reCaptchaSettings)} />
); diff --git a/core/app/[locale]/(default)/account/(tabs)/addresses/edit/[slug]/_actions/update-address.ts b/core/app/[locale]/(default)/account/addresses/edit/[slug]/_actions/update-address.ts similarity index 63% rename from core/app/[locale]/(default)/account/(tabs)/addresses/edit/[slug]/_actions/update-address.ts rename to core/app/[locale]/(default)/account/addresses/edit/[slug]/_actions/update-address.ts index 02a40fc05..58709d28e 100644 --- a/core/app/[locale]/(default)/account/(tabs)/addresses/edit/[slug]/_actions/update-address.ts +++ b/core/app/[locale]/(default)/account/addresses/edit/[slug]/_actions/update-address.ts @@ -1,22 +1,23 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; import { parseAccountFormData } from '~/components/form-fields/shared/parse-fields'; const UpdateCustomerAddressMutation = graphql(` - mutation UpdateCustomerAddressMutation( - $input: UpdateCustomerAddressInput! - $reCaptchaV2: ReCaptchaV2Input - ) { + mutation UpdateCustomerAddressMutation($input: UpdateCustomerAddressInput!) { customer { - updateCustomerAddress(input: $input, reCaptchaV2: $reCaptchaV2) { + updateCustomerAddress(input: $input) { errors { __typename + ... on AddressDoesNotExistError { + message + } ... on CustomerAddressUpdateError { message } @@ -51,18 +52,17 @@ const isUpdateCustomerAddressInput = ( return false; }; -export const updateAddress = async ({ - addressId, - formData, - reCaptchaToken, -}: { - addressId: number; - formData: FormData; - reCaptchaToken?: string; -}) => { - const t = await getTranslations('Account.Addresses.Edit.Form'); +interface UpdateAddressResponse { + status: 'success' | 'error'; + message: string; +} - const customerId = await getSessionCustomerId(); +export const updateAddress = async ( + formData: FormData, + addressId: number, +): Promise => { + const t = await getTranslations('Account.Addresses.Edit.Form'); + const customerAccessToken = await getSessionCustomerAccessToken(); try { const parsed = parseAccountFormData(formData); @@ -70,43 +70,34 @@ export const updateAddress = async ({ if (!isUpdateCustomerAddressInput(parsed)) { return { status: 'error', - error: t('Errors.inputError'), + message: t('Errors.inputError'), }; } const response = await client.fetch({ document: UpdateCustomerAddressMutation, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, variables: { input: { addressEntityId: addressId, data: parsed, }, - ...(reCaptchaToken && { reCaptchaV2: { token: reCaptchaToken } }), }, }); const result = response.data.customer.updateCustomerAddress; - revalidatePath('/account/addresses', 'page'); - - if (result.errors.length === 0) { - return { status: 'success', message: t('success') }; + 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: 'error', - message: result.errors - .map((error) => { - if (error.__typename === 'AddressDoesNotExistError') { - return t('Errors.notFound'); - } + revalidateTag(TAGS.customer); - return error.message; - }) - .join('\n'), - }; + return { status: 'success', message: t('success') }; } catch (error: unknown) { if (error instanceof Error) { return { diff --git a/core/app/[locale]/(default)/account/addresses/edit/[slug]/_components/edit-address-form.tsx b/core/app/[locale]/(default)/account/addresses/edit/[slug]/_components/edit-address-form.tsx new file mode 100644 index 000000000..7047c4e91 --- /dev/null +++ b/core/app/[locale]/(default)/account/addresses/edit/[slug]/_components/edit-address-form.tsx @@ -0,0 +1,407 @@ +'use client'; + +import { AlertCircle, Check } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { MouseEvent, useRef, useState } from 'react'; +import { useFormStatus } from 'react-dom'; +import { toast } from 'react-hot-toast'; + +import { + Checkboxes, + createFieldName, + DateField, + FieldNameToFieldId, + FieldWrapper, + getPreviouslySubmittedValue, + MultilineText, + NumbersOnly, + Password, + Picklist, + PicklistOrText, + RadioButtons, + Text, +} from '~/components/form-fields'; +import { + createDatesValidationHandler, + createMultilineTextValidationHandler, + createNumbersInputValidationHandler, + createPasswordValidationHandler, + createPreSubmitCheckboxesValidationHandler, + createPreSubmitPicklistValidationHandler, + createRadioButtonsValidationHandler, + createTextInputValidationHandler, + type FieldStateSetFn, +} 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 { useRouter } from '~/i18n/routing'; + +import { Modal } from '../../../../_components/modal'; +import { deleteAddress } from '../../../_actions/delete-address'; +import { updateAddress } from '../_actions/update-address'; +import { CustomerEditAddressQueryResult } from '../page'; + +type Address = NonNullable< + NonNullable['addresses']['edges'] +>[number]['node']; + +type AddressFields = NonNullable< + CustomerEditAddressQueryResult['site']['settings'] +>['formFields']['shippingAddress']; + +type Countries = NonNullable; +type CountryStates = Countries[number]['statesOrProvinces']; + +type FieldUnionType = Exclude< + keyof typeof FieldNameToFieldId, + 'email' | 'password' | 'confirmPassword' | 'exclusiveOffers' | 'currentPassword' +>; + +const createCountryChangeHandler = + (provinceSetter: FieldStateSetFn, countries: Countries) => (value: string) => { + const states = countries.find(({ code }) => code === value)?.statesOrProvinces; + + provinceSetter(states ?? []); + }; + +const isExistedField = (name: unknown): name is FieldUnionType => { + if (typeof name === 'string' && name in FieldNameToFieldId) { + return true; + } + + return false; +}; + +interface SumbitMessages { + messages: { + submit: string; + submitting: string; + }; +} + +const SubmitButton = ({ messages }: SumbitMessages) => { + const { pending } = useFormStatus(); + + return ( + + ); +}; + +interface EditAddressProps { + address: Address; + addressFields: AddressFields; + countries: Countries; + isAddressRemovable: boolean; +} + +export const EditAddressForm = ({ + address, + addressFields, + countries, + isAddressRemovable, +}: EditAddressProps) => { + const form = useRef(null); + const t = useTranslations('Account.Addresses.Edit.Form'); + + const router = useRouter(); + + const [textInputValid, setTextInputValid] = useState>({}); + const [passwordValid, setPasswordValid] = useState>({}); + 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 defaultStates = countries + .filter((country) => country.code === address.countryCode) + .flatMap((country) => country.statesOrProvinces); + const [countryStates, setCountryStates] = useState(defaultStates); + + const handleTextInputValidation = createTextInputValidationHandler( + setTextInputValid, + textInputValid, + ); + const handlePasswordValidation = createPasswordValidationHandler(setPasswordValid, addressFields); + const handleNumbersInputValidation = createNumbersInputValidationHandler( + setNumbersInputValid, + numbersInputValid, + ); + const handleCountryChange = createCountryChangeHandler(setCountryStates, countries); + const handleDatesValidation = createDatesValidationHandler(setDatesValid, datesValid); + const handleRadioButtonsChange = createRadioButtonsValidationHandler( + setRadioButtonsValid, + radioButtonsValid, + ); + const handleMultiTextValidation = createMultilineTextValidationHandler( + setMultiTextValid, + multiTextValid, + ); + + const validatePicklistFields = createPreSubmitPicklistValidationHandler( + addressFields, + setPicklistValid, + ); + + const validateCheckboxFields = createPreSubmitCheckboxesValidationHandler( + addressFields, + setCheckboxesValid, + ); + + 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 updateAddress(formData, address.entityId); + + if (status === 'error') { + toast.error(message, { + icon: , + }); + + return; + } + + toast.success(message, { + icon: , + }); + + router.push('/account/addresses', { scroll: true }); + }; + + const onDeleteAddress = async () => { + const { status, message } = await deleteAddress(address.entityId); + + if (status === 'error') { + toast.error(message, { + icon: , + }); + + return; + } + + toast.success(message, { + icon: , + }); + + router.push('/account/addresses', { scroll: true }); + }; + + return ( +
+
+ {addressFields.map((field) => { + const fieldId = field.entityId; + const fieldName = createFieldName(field, 'address'); + const key = FieldNameToFieldId[fieldId]; + const defaultCustomField = address.formFields.find( + ({ entityId }) => entityId === fieldId, + ); + const defaultValue = (isExistedField(key) && address[key]) || undefined; + + switch (field.__typename) { + case 'TextFormField': { + const previousTextValue = + getPreviouslySubmittedValue(defaultCustomField).TextFormField; + + return ( + + + + ); + } + + case 'MultilineTextFormField': { + const previousMultiTextValue = + getPreviouslySubmittedValue(defaultCustomField).MultilineTextFormField; + + return ( + + + + ); + } + + case 'NumberFormField': { + const previousNumberValue = + getPreviouslySubmittedValue(defaultCustomField).NumberFormField; + + return ( + + + + ); + } + + case 'CheckboxesFormField': { + const previousCheckboxesValue = + getPreviouslySubmittedValue(defaultCustomField).CheckboxesFormField; + + return ( + + + + ); + } + + case 'DateFormField': { + const previousDateValue = + getPreviouslySubmittedValue(defaultCustomField).DateFormField; + + return ( + + + + ); + } + + case 'RadioButtonsFormField': { + const previousMultipleChoiceValue = + getPreviouslySubmittedValue(defaultCustomField).MultipleChoiceFormField; + + return ( + + + + ); + } + + case 'PicklistFormField': { + const isCountrySelector = fieldId === FieldNameToFieldId.countryCode; + const previousMultipleChoiceValue = + getPreviouslySubmittedValue(defaultCustomField).MultipleChoiceFormField; + const picklistOptions = isCountrySelector + ? countries.map(({ name, code }) => ({ label: name, entityId: code })) + : field.options; + + return ( + + + + ); + } + + case 'PicklistOrTextFormField': { + return ( + + ({ entityId: name, label: name }))} + /> + + ); + } + + case 'PasswordFormField': { + const previousPasswordValue = + getPreviouslySubmittedValue(defaultCustomField).PasswordFormField; + + return ( + + + + ); + } + + default: + return null; + } + })} +
+ +
+ + + + + + + +
+
+ ); +}; diff --git a/core/app/[locale]/(default)/account/(tabs)/addresses/edit/[slug]/page.tsx b/core/app/[locale]/(default)/account/addresses/edit/[slug]/page.tsx similarity index 86% rename from core/app/[locale]/(default)/account/(tabs)/addresses/edit/[slug]/page.tsx rename to core/app/[locale]/(default)/account/addresses/edit/[slug]/page.tsx index cc1695d4e..bd07c7985 100644 --- a/core/app/[locale]/(default)/account/(tabs)/addresses/edit/[slug]/page.tsx +++ b/core/app/[locale]/(default)/account/addresses/edit/[slug]/page.tsx @@ -2,13 +2,12 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { notFound } from 'next/navigation'; import { getTranslations } from 'next-intl/server'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { FormFieldValuesFragment } from '~/client/fragments/form-fields-values'; import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql, ResultOf } from '~/client/graphql'; import { FormFieldsFragment } from '~/components/form-fields/fragment'; -import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; import { EditAddressForm } from './_components/edit-address-form'; @@ -57,10 +56,6 @@ const CustomerEditAddressQuery = graphql( contact { country } - reCaptcha { - isEnabledOnStorefront - siteKey - } formFields { shippingAddress(filters: $shippingFilters, sortBy: $shippingSorting) { ...FormFieldsFragment @@ -98,18 +93,18 @@ export async function generateMetadata() { } interface Props { - params: { slug: string }; - searchParams: Record; + params: Promise<{ slug: string }>; } -export default async function Edit({ params: { slug } }: Props) { - const t = await getTranslations('Account.Addresses.Edit'); +export default async function Edit({ params }: Props) { + const { slug } = await params; - const customerId = await getSessionCustomerId(); + const t = await getTranslations('Account.Addresses.Edit'); + const customerAccessToken = await getSessionCustomerAccessToken(); const { data } = await client.fetch({ document: CustomerEditAddressQuery, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, variables: { countryCode: null, @@ -117,7 +112,6 @@ export default async function Edit({ params: { slug } }: Props) { }, }); - const reCaptchaSettings = data.site.settings?.reCaptcha; const countries = data.geography.countries; const addressFields = [...(data.site.settings?.formFields.shippingAddress ?? [])]; const addresses = removeEdgesAndNodes({ edges: data.customer?.addresses.edges }); @@ -140,7 +134,6 @@ export default async function Edit({ params: { slug } }: Props) { addressFields={addressFields} countries={countries || []} isAddressRemovable={addresses.length > 1} - reCaptchaSettings={bypassReCaptcha(reCaptchaSettings)} />
); diff --git a/core/app/[locale]/(default)/account/(tabs)/addresses/page-data.ts b/core/app/[locale]/(default)/account/addresses/page-data.ts similarity index 81% rename from core/app/[locale]/(default)/account/(tabs)/addresses/page-data.ts rename to core/app/[locale]/(default)/account/addresses/page-data.ts index 25a4eec08..e61e09a5d 100644 --- a/core/app/[locale]/(default)/account/(tabs)/addresses/page-data.ts +++ b/core/app/[locale]/(default)/account/addresses/page-data.ts @@ -1,11 +1,12 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { FormFieldValuesFragment } from '~/client/fragments/form-fields-values'; import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; const GetCustomerAddressesQuery = graphql( ` @@ -51,15 +52,15 @@ interface Pagination { } export const getCustomerAddresses = cache( - async ({ before = '', after = '', limit = 9 }: Pagination) => { - const customerId = await getSessionCustomerId(); + async ({ before = '', after = '', limit = 10 }: Pagination) => { + const customerAccessToken = await getSessionCustomerAccessToken(); const paginationArgs = before ? { last: limit, before } : { first: limit, after }; const response = await client.fetch({ document: GetCustomerAddressesQuery, variables: { ...paginationArgs }, - customerId, - fetchOptions: { cache: 'no-store' }, + customerAccessToken, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, }); const addresses = response.data.customer?.addresses; @@ -70,7 +71,7 @@ export const getCustomerAddresses = cache( return { pageInfo: addresses.pageInfo, - addressesCount: addresses.collectionInfo?.totalItems ?? 0, + totalAddresses: addresses.collectionInfo?.totalItems ?? 0, addresses: removeEdgesAndNodes({ edges: addresses.edges }), }; }, diff --git a/core/app/[locale]/(default)/account/(tabs)/addresses/page.tsx b/core/app/[locale]/(default)/account/addresses/page.tsx similarity index 84% rename from core/app/[locale]/(default)/account/(tabs)/addresses/page.tsx rename to core/app/[locale]/(default)/account/addresses/page.tsx index 501b9ff21..27e9b76fa 100644 --- a/core/app/[locale]/(default)/account/(tabs)/addresses/page.tsx +++ b/core/app/[locale]/(default)/account/addresses/page.tsx @@ -9,11 +9,11 @@ import { AddressBook } from './_components/address-book'; import { getCustomerAddresses } from './page-data'; interface Props { - searchParams: { + searchParams: Promise<{ [key: string]: string | string[] | undefined; before?: string; after?: string; - }; + }>; } export async function generateMetadata() { @@ -25,25 +25,24 @@ export async function generateMetadata() { } export default async function Addresses({ searchParams }: Props) { - const { before, after } = searchParams; + const { before, after } = await searchParams; const data = await getCustomerAddresses({ ...(after && { after }), ...(before && { before }), - limit: 10, }); if (!data) { notFound(); } - const { addresses, pageInfo, addressesCount } = data; + const { addresses, pageInfo, totalAddresses } = data; const { hasNextPage, hasPreviousPage, startCursor, endCursor } = pageInfo; return ( <> - + ; +} + +export default async function AccountLayout({ children, params }: Props) { + const { locale } = await params; const session = await auth(); + setRequestLocale(locale); + if (!session) { - redirect('/login'); + redirect({ href: '/login', locale }); } - return children; + return ( + <> + + {children} + + ); } diff --git a/core/app/[locale]/(default)/account/order/[slug]/_components/order-details.tsx b/core/app/[locale]/(default)/account/order/[slug]/_components/order-details.tsx new file mode 100644 index 000000000..322ec3ff4 --- /dev/null +++ b/core/app/[locale]/(default)/account/order/[slug]/_components/order-details.tsx @@ -0,0 +1,293 @@ +import { getFormatter, getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; + +import { Link } from '~/components/link'; +import { Button } from '~/components/ui/button'; +import { cn } from '~/lib/utils'; + +import { + assembleProductData, + ProductSnippet, + ProductSnippetSkeleton, +} from '../../../orders/_components/product-snippet'; +import { OrderDataType } from '../page'; + +const OrderState = async ({ orderState }: { orderState: OrderDataType['orderState'] }) => { + const t = await getTranslations('Account.Orders'); + const format = await getFormatter(); + const { orderId, orderDate, status } = orderState; + + return ( +
+
+

+ {t('orderNumber')} + {orderId} +

+

+ {format.dateTime(new Date(orderDate.utc), { + year: 'numeric', + month: 'long', + day: 'numeric', + })} +

+
+

+ {status.label} +

+
+ ); +}; + +const OrderSummaryInfo = async ({ summaryInfo }: { summaryInfo: OrderDataType['summaryInfo'] }) => { + const t = await getTranslations('Account.Orders'); + const format = await getFormatter(); + const { subtotal, shipping, tax, discounts, grandTotal } = summaryInfo; + const { nonCouponDiscountTotal, couponDiscounts } = discounts; + + return ( +
+

{t('orderSummary')}

+
+

+ {t('orderSubtotal')} + + {format.number(subtotal.value, { + style: 'currency', + currency: subtotal.currencyCode, + })} + +

+ {nonCouponDiscountTotal.value > 0 && ( +

+ {t('orderDiscount')} + + - + {format.number(nonCouponDiscountTotal.value, { + style: 'currency', + currency: nonCouponDiscountTotal.currencyCode, + })} + +

+ )} + {couponDiscounts.map(({ couponCode, discountedAmount }, index) => ( +

+ {t('orderAppliedCoupon', { code: couponCode })} + + - + {format.number(discountedAmount.value, { + style: 'currency', + currency: discountedAmount.currencyCode, + })} + +

+ ))} +

+ {t('orderShipping')} + + {format.number(shipping.value, { + style: 'currency', + currency: shipping.currencyCode, + })} + +

+

+ {t('orderTax')} + + {format.number(tax.value, { + style: 'currency', + currency: tax.currencyCode, + })} + +

+
+
+

+ {t('orderGrandtotal')} + + {format.number(grandTotal.value, { + style: 'currency', + currency: grandTotal.currencyCode, + })} + +

+
+ {/* TODO: add manage-order buttons */} +
+ ); +}; +const combineAddressInfo = ( + address: NonNullable[number]['shippingAddress'], +) => { + const { firstName, lastName, address1, city, stateOrProvince, postalCode, country } = address; + const fullName = `${firstName ?? ''} ${lastName ?? ''}`; + const addressLine = address1 ?? ''; + const cityWithZipCode = `${city ?? ''}, ${stateOrProvince} ${postalCode}`; + const shippingCountry = country; + + return [fullName, addressLine, cityWithZipCode, shippingCountry]; +}; +const combineShippingMethodInfo = async ( + shipment?: NonNullable< + NonNullable[number]['shipments'] + >[number], +) => { + if (!shipment) { + return []; + } + + const t = await getTranslations('Account.Orders'); + const format = await getFormatter(); + const { shippingProviderName, shippingMethodName, shippedAt } = shipment; + const providerWithMethod = `${shippingProviderName} - ${shippingMethodName}`; + const shippedDate = `${t('shippedDate')} ${format.dateTime(new Date(shippedAt.utc), { + year: 'numeric', + month: 'long', + day: 'numeric', + })}`; + + return [providerWithMethod, shippedDate]; +}; + +const ShippingInfo = async ({ + consignments, + isMultiConsignments, + shippingNumber, +}: { + consignments: OrderDataType['consignments']; + isMultiConsignments: boolean; + shippingNumber?: number; +}) => { + const t = await getTranslations('Account.Orders'); + const shippingConsignments = consignments.shipping; + + if (!shippingConsignments) { + return; + } + + let customerShippingAddress: string[] = []; + let customerShippingMethod: string[] = []; + let trackingData; + + if (!isMultiConsignments && shippingConsignments[0]?.shippingAddress) { + trackingData = shippingConsignments[0].shipments[0]?.tracking; + customerShippingAddress = combineAddressInfo(shippingConsignments[0].shippingAddress); + customerShippingMethod = await combineShippingMethodInfo(shippingConsignments[0].shipments[0]); + } + + if (isMultiConsignments && shippingNumber !== undefined && shippingConsignments[shippingNumber]) { + trackingData = shippingConsignments[shippingNumber].shipments[0]?.tracking; + customerShippingAddress = combineAddressInfo( + shippingConsignments[shippingNumber].shippingAddress, + ); + customerShippingMethod = await combineShippingMethodInfo( + shippingConsignments[shippingNumber].shipments[0], + ); + } + + const trackingNumber = + trackingData && trackingData.__typename !== 'OrderShipmentUrlOnlyTracking' + ? trackingData.number + : null; + + return ( +
+ {!isMultiConsignments ? ( +

{t('shippingTitle')}

+ ) : null} +
+

{t('shippingAddress')}

+ {customerShippingAddress.map((line) => ( +

{line}

+ ))} +
+
+

{t('shippingMethod')}

+ {customerShippingMethod.map((line) => ( +

{line}

+ ))} + {Boolean(trackingNumber) && ( + + )} +
+
+ ); +}; + +export const OrderDetails = async ({ data }: { data: OrderDataType }) => { + const t = await getTranslations('Account.Orders'); + const { orderState, summaryInfo, consignments } = data; + const shippingConsignments = consignments.shipping; + const isMultiShippingConsignments = shippingConsignments && shippingConsignments.length > 1; + + return ( +
+ +
+
+ {shippingConsignments?.map((consignment, idx) => { + const { lineItems } = consignment; + + return ( +
+

+ {isMultiShippingConsignments + ? `${t('shipmentTitle')} ${idx + 1}/${shippingConsignments.length}` + : t('orderContents')} +

+ {isMultiShippingConsignments && ( + + )} +
    + {lineItems.map((shipment) => { + return ( +
  • + }> + + +
  • + ); + })} +
+
+ ); + })} +
+
+ + {!isMultiShippingConsignments && ( + + )} + {/* TODO: add PaymentInfo component later */} +
+
+
+ ); +}; diff --git a/core/app/[locale]/(default)/account/order/[slug]/page-data.tsx b/core/app/[locale]/(default)/account/order/[slug]/page-data.tsx new file mode 100644 index 000000000..b4be4ca7b --- /dev/null +++ b/core/app/[locale]/(default)/account/order/[slug]/page-data.tsx @@ -0,0 +1,144 @@ +import { cache } from 'react'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql, VariablesOf } from '~/client/graphql'; +import { ExistingResultType } from '~/client/util'; + +import { OrderItemFragment } from '../../orders/_components/product-snippet'; + +export const OrderShipmentFragment = graphql(` + fragment OrderShipmentFragment on OrderShipment { + shippingMethodName + shippingProviderName + tracking { + __typename + ... on OrderShipmentNumberAndUrlTracking { + number + url + } + ... on OrderShipmentUrlOnlyTracking { + url + } + ... on OrderShipmentNumberOnlyTracking { + number + } + } + } +`); + +const CustomerOrderDetails = graphql( + ` + query CustomerOrderDetails($filter: OrderFilterInput) { + site { + order(filter: $filter) { + entityId + orderedAt { + utc + } + status { + label + value + } + totalIncTax { + value + currencyCode + } + subTotal { + value + currencyCode + } + discounts { + nonCouponDiscountTotal { + value + currencyCode + } + couponDiscounts { + couponCode + discountedAmount { + value + currencyCode + } + } + } + shippingCostTotal { + value + currencyCode + } + taxTotal { + value + currencyCode + } + billingAddress { + firstName + lastName + address1 + city + stateOrProvince + postalCode + country + } + consignments { + shipping { + edges { + node { + entityId + shippingAddress { + firstName + lastName + address1 + city + stateOrProvince + postalCode + country + } + shipments { + edges { + node { + entityId + shippedAt { + utc + } + ...OrderShipmentFragment + } + } + } + lineItems { + edges { + node { + ...OrderItemFragment + } + } + } + } + } + } + } + } + } + } + `, + [OrderItemFragment, OrderShipmentFragment], +); + +export const getOrderDetails = cache( + async (variables: VariablesOf) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const response = await client.fetch({ + document: CustomerOrderDetails, + variables, + fetchOptions: { cache: 'no-store' }, + customerAccessToken, + }); + const order = response.data.site.order; + + if (!order) { + return undefined; + } + + return order; + }, +); + +export type OrderDetailsType = ExistingResultType; diff --git a/core/app/[locale]/(default)/account/order/[slug]/page.tsx b/core/app/[locale]/(default)/account/order/[slug]/page.tsx new file mode 100644 index 000000000..278ba5542 --- /dev/null +++ b/core/app/[locale]/(default)/account/order/[slug]/page.tsx @@ -0,0 +1,71 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { notFound } from 'next/navigation'; + +import { ExistingResultType } from '~/client/util'; + +import { OrderDetails } from './_components/order-details'; +import { getOrderDetails, OrderDetailsType } from './page-data'; + +interface Props { + params: Promise<{ + slug: string; + locale: string; + }>; +} + +const mapOrderData = (order: OrderDetailsType) => { + const shipping = order.consignments?.shipping + ? removeEdgesAndNodes(order.consignments.shipping).map( + ({ shipments, lineItems, ...otherItems }) => ({ + ...otherItems, + lineItems: removeEdgesAndNodes(lineItems), + shipments: removeEdgesAndNodes(shipments), + }), + ) + : undefined; + + return { + orderState: { + orderId: order.entityId, + status: order.status, + orderDate: order.orderedAt, + }, + summaryInfo: { + subtotal: order.subTotal, + discounts: order.discounts, + shipping: order.shippingCostTotal, + tax: order.taxTotal, + grandTotal: order.totalIncTax, + }, + paymentInfo: { + billingAddress: order.billingAddress, + // TODO: add payments data + }, + consignments: { + shipping, + }, + }; +}; + +export type OrderDataType = ExistingResultType; + +export default async function Order(props: Props) { + const { slug } = await props.params; + const entityId = Number(slug); + + const order = await getOrderDetails({ + filter: { + entityId, + }, + }); + + if (!order) { + notFound(); + } + + const data = mapOrderData(order); + + return ; +} + +export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/account/orders/_components/orders-list.tsx b/core/app/[locale]/(default)/account/orders/_components/orders-list.tsx new file mode 100644 index 000000000..fda7498b5 --- /dev/null +++ b/core/app/[locale]/(default)/account/orders/_components/orders-list.tsx @@ -0,0 +1,242 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { getFormatter, getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; + +import { ExistingResultType } from '~/client/util'; +import { Link } from '~/components/link'; +import { Button } from '~/components/ui/button'; + +import { getCustomerOrders } from '../page-data'; + +import { assembleProductData, ProductSnippet, ProductSnippetSkeleton } from './product-snippet'; + +export type Orders = ExistingResultType['orders']; + +interface OrdersListProps { + customerOrders: Orders; + ordersCount?: number; +} + +enum VisibleListItemsPerDevice { + xs = 1, + md = 3, + lg = 4, + xl = 5, +} + +const TruncatedCard = ({ itemsQuantity }: { itemsQuantity: number }) => { + const smItems = itemsQuantity - VisibleListItemsPerDevice.xs; + const mdItems = itemsQuantity - VisibleListItemsPerDevice.md; + const lgItems = itemsQuantity - VisibleListItemsPerDevice.lg; + const xlItems = itemsQuantity - VisibleListItemsPerDevice.xl; + + return ( + <> + {smItems > 0 && ( +
+
+ +{smItems} +
+
+ )} + {mdItems > 0 && ( +
+
+ +{mdItems} +
+
+ )} + {lgItems > 0 && ( +
+
+ +{lgItems} +
+
+ )} + {xlItems > 0 && ( +
+
+ +{xlItems} +
+
+ )} + + ); +}; + +interface ManageOrderButtonsProps { + className: string; + orderId: number; + orderTrackingUrl?: string; + orderStatus: string | null; +} + +const ManageOrderButtons = async ({ + className, + orderId, + orderStatus, + orderTrackingUrl, +}: ManageOrderButtonsProps) => { + const t = await getTranslations('Account.Orders'); + + return ( +
+ + {Boolean(orderTrackingUrl) && ( + + )} + {Boolean(orderStatus) && orderStatus === 'SHIPPED' && ( + + )} +
+ ); +}; +const OrderDetails = async ({ + orderId, + orderDate, + orderPrice, + orderStatus, +}: { + orderId: number; + orderDate: string; + orderPrice: { + value: number; + currencyCode: string; + }; + orderStatus: string; +}) => { + const t = await getTranslations('Account.Orders'); + const format = await getFormatter(); + + return ( +
+ +

+ {t('orderNumber')} + {orderId} +

+ +

+ {t('placedDate')} + + {format.dateTime(new Date(orderDate), { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +

+

+ {t('totalPrice')} + + {format.number(orderPrice.value, { + style: 'currency', + currency: orderPrice.currencyCode, + })} + +

+

+ {orderStatus} +

+
+ ); +}; + +export const OrdersList = ({ customerOrders }: OrdersListProps) => { + return ( +
    + {customerOrders.map(({ entityId, orderedAt, status, totalIncTax, consignments }) => { + const shippingConsignments = consignments.shipping + ? consignments.shipping.map(({ lineItems, shipments }) => ({ + lineItems: removeEdgesAndNodes(lineItems), + shipments: removeEdgesAndNodes(shipments), + })) + : undefined; + // NOTE: tracking url will be supported later + const trackingUrl = shippingConsignments + ? shippingConsignments + .flatMap(({ shipments }) => + shipments.map((shipment) => { + if ( + shipment.tracking?.__typename === 'OrderShipmentNumberAndUrlTracking' || + shipment.tracking?.__typename === 'OrderShipmentUrlOnlyTracking' + ) { + return shipment.tracking.url; + } + + return null; + }), + ) + .find((url) => url !== null) + : undefined; + + return ( +
  • + +
    +
      + {(shippingConsignments ?? []).map(({ lineItems }) => { + return lineItems.slice(0, VisibleListItemsPerDevice.xl).map((shippedProduct) => { + return ( +
    • + }> + + +
    • + ); + }); + })} +
    + orderItems + shipment.lineItems.length, + 0, + )} + /> + +
    + +
  • + ); + })} +
+ ); +}; diff --git a/core/app/[locale]/(default)/account/orders/_components/product-snippet.tsx b/core/app/[locale]/(default)/account/orders/_components/product-snippet.tsx new file mode 100644 index 000000000..f4b5b8856 --- /dev/null +++ b/core/app/[locale]/(default)/account/orders/_components/product-snippet.tsx @@ -0,0 +1,279 @@ +import { getFormatter, getTranslations } from 'next-intl/server'; + +import { client } from '~/client'; +import { graphql, ResultOf, VariablesOf } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; +import { Image } from '~/components/image'; +import { Link } from '~/components/link'; +import { ProductCardFragment } from '~/components/product-card/fragment'; +import { Price as PricesType } from '~/components/ui/product-card'; +import { pricesTransformer } from '~/data-transformers/prices-transformer'; +import { cn } from '~/lib/utils'; + +const ProductAttributes = graphql(` + query ProductAttributes($entityId: Int) { + site { + product(entityId: $entityId) { + path + } + } + } +`); + +export type ProductAttributesVariables = VariablesOf; + +export const OrderItemFragment = graphql(` + fragment OrderItemFragment on OrderPhysicalLineItem { + entityId + productEntityId + brand + name + quantity + image { + url: urlTemplate(lossy: true) + altText + } + subTotalListPrice { + value + currencyCode + } + productOptions { + __typename + name + value + } + } +`); + +export type ProductSnippetFragment = Omit< + ResultOf, + 'productOptions' | 'reviewSummary' | 'inventory' | 'availabilityV2' | 'brand' | 'path' +> & { + productId: number; + brand: string | null; + quantity: number; + productOptions?: Array<{ + __typename: string; + name: string; + value: string; + }>; +}; + +export const assembleProductData = (orderItem: ResultOf) => { + const { + entityId, + productEntityId: productId, + name, + brand, + image, + subTotalListPrice, + productOptions, + } = orderItem; + + return { + entityId, + productId, + name, + brand, + defaultImage: image + ? { + url: image.url, + altText: image.altText, + } + : null, + productOptions, + quantity: orderItem.quantity, + prices: { + price: subTotalListPrice, + basePrice: null, + retailPrice: null, + salePrice: null, + priceRange: { + min: subTotalListPrice, + max: subTotalListPrice, + }, + }, + }; +}; + +const Price = async ({ price }: { price?: PricesType }) => { + const t = await getTranslations('Product.Details.Prices'); + + if (!price) { + return; + } + + return ( + Boolean(price) && + (typeof price === 'object' ? ( +

+ {price.type === 'range' && ( + + {price.minValue} - {price.maxValue} + + )} + + {price.type === 'sale' && ( + <> + + {t('was')}: {price.previousValue} + + + {t('now')}: {price.currentValue} + + + )} +

+ ) : ( + {price} + )) + ); +}; + +interface Props { + product: ProductSnippetFragment; + imageSize?: 'tall' | 'wide' | 'square'; + brandSize?: string; + productSize?: string; + imagePriority?: boolean; + isExtended?: boolean; +} + +export const ProductSnippet = async ({ + product, + isExtended = false, + imageSize = 'square', + imagePriority = false, + brandSize, + productSize, +}: Props) => { + const { name, defaultImage, brand, productId, prices } = product; + const format = await getFormatter(); + const t = await getTranslations('Product.Details'); + const price = pricesTransformer(prices, format); + const isImageAvailable = defaultImage !== null; + + const { data } = await client.fetch({ + document: ProductAttributes, + variables: { entityId: productId }, + fetchOptions: { next: { revalidate } }, + }); + + const { path = '' } = data.site.product ?? {}; + + return ( +
+
+ {isImageAvailable && ( +
+ {defaultImage.altText +
+ )} + {!isImageAvailable && ( +
+
+ {t('comingSoon')} +
+
+ )} +
+
+ {brand ?

{brand}

: null} + {isExtended ? ( +
+
+

+ +

+
+ {product.productOptions?.map(({ name: optionName, value }, idx) => { + return ( +

+ {optionName}: + {value} +

+ ); + })} +

+ {t('qty')}: + {product.quantity} +

+
+
+
+ +
+
+ ) : ( +

+ +

+ )} + {!isExtended && ( +
+ +
+ )} +
+
+ ); +}; + +export const ProductSnippetSkeleton = ({ isExtended = false }: { isExtended?: boolean }) => { + return ( +
+
+
+
+
+
+
+ {isExtended ? ( +
+
+
+
+
+
+
+ ) : ( +
+
+
+
+
+ )} +
+
+ ); +}; diff --git a/core/app/[locale]/(default)/account/orders/page-data.ts b/core/app/[locale]/(default)/account/orders/page-data.ts new file mode 100644 index 000000000..ed411d4d2 --- /dev/null +++ b/core/app/[locale]/(default)/account/orders/page-data.ts @@ -0,0 +1,129 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { cache } from 'react'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { PaginationFragment } from '~/client/fragments/pagination'; +import { graphql, VariablesOf } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; + +import { OrderShipmentFragment } from '../order/[slug]/page-data'; + +import { OrderItemFragment } from './_components/product-snippet'; + +const CustomerAllOrders = graphql( + ` + query CustomerAllOrders( + $after: String + $before: String + $first: Int + $last: Int + $filters: OrdersFiltersInput + ) { + customer { + orders(after: $after, before: $before, first: $first, last: $last, filters: $filters) { + pageInfo { + ...PaginationFragment + } + edges { + node { + entityId + orderedAt { + utc + } + status { + label + value + } + totalIncTax { + value + currencyCode + } + consignments { + shipping { + edges { + node { + lineItems { + edges { + node { + ...OrderItemFragment + } + } + } + shipments { + edges { + node { + ...OrderShipmentFragment + } + } + } + } + } + } + } + } + } + } + } + } + `, + [OrderItemFragment, OrderShipmentFragment, PaginationFragment], +); + +type OrdersFiltersInput = VariablesOf['filters']; +type OrderStatus = NonNullable['status']; +type OrderDateRange = NonNullable['dateRange']; + +interface CustomerOrdersArgs { + after?: string; + before?: string; + limit?: number; + filterByStatus?: OrderStatus; + filterByDateRange?: OrderDateRange; +} + +export const getCustomerOrders = cache( + async ({ + before = '', + after = '', + filterByStatus, + filterByDateRange, + limit = 2, + }: CustomerOrdersArgs) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + const filtersArgs = { + filters: { + ...(filterByDateRange && { dateRange: filterByDateRange }), + ...(filterByStatus && { status: filterByStatus }), + }, + }; + const response = await client.fetch({ + document: CustomerAllOrders, + variables: { ...paginationArgs, ...filtersArgs }, + customerAccessToken, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, + }); + + const orders = response.data.customer?.orders; + + if (!orders) { + return undefined; + } + + const data = { + orders: removeEdgesAndNodes(orders).map((order) => { + return { + ...order, + consignments: { + shipping: + order.consignments?.shipping && removeEdgesAndNodes(order.consignments.shipping), + }, + }; + }), + pageInfo: orders.pageInfo, + }; + + return data; + }, +); diff --git a/core/app/[locale]/(default)/account/orders/page.tsx b/core/app/[locale]/(default)/account/orders/page.tsx new file mode 100644 index 000000000..7c2e08f43 --- /dev/null +++ b/core/app/[locale]/(default)/account/orders/page.tsx @@ -0,0 +1,56 @@ +import { notFound } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; + +import { Pagination } from '~/components/ui/pagination'; + +import { TabHeading } from '../_components/tab-heading'; + +import { OrdersList } from './_components/orders-list'; +import { getCustomerOrders } from './page-data'; + +interface Props { + searchParams: Promise<{ + [key: string]: string | string[] | undefined; + before?: string; + after?: string; + }>; +} + +export default async function Orders({ searchParams }: Props) { + const { before, after } = await searchParams; + const t = await getTranslations('Account.Orders'); + + const customerOrdersDetails = await getCustomerOrders({ + ...(after && { after }), + ...(before && { before }), + }); + + if (!customerOrdersDetails) { + notFound(); + } + + const { orders, pageInfo } = customerOrdersDetails; + const { hasNextPage, hasPreviousPage, startCursor, endCursor } = pageInfo; + + return ( + <> + + {orders.length === 0 ? ( +
{t('noOrders')}
+ ) : ( + + )} +
+ +
+ + ); +} + +export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/account/page.tsx b/core/app/[locale]/(default)/account/page.tsx deleted file mode 100644 index 8419b5212..000000000 --- a/core/app/[locale]/(default)/account/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { BookUser, Settings } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { getTranslations } from 'next-intl/server'; -import { ReactNode } from 'react'; - -import { Link } from '~/components/link'; - -import { AccountNotification } from './(tabs)/_components/account-notification'; - -interface AccountItem { - children: ReactNode; - description?: string; - href: string; - title: string; -} - -const AccountItem = ({ children, title, description, href }: AccountItem) => { - return ( - - {children} - -

{title}

- {description ?

{description}

: null} -
- - ); -}; - -export async function generateMetadata() { - const t = await getTranslations('Account.Home'); - - return { - title: t('title'), - }; -} - -export default function Account() { - const t = useTranslations('Account.Home'); - - return ( -
-

{t('heading')}

- - - -
- - - - - - -
-
- ); -} - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/account/(tabs)/settings/_actions/update-customer.ts b/core/app/[locale]/(default)/account/settings/_actions/update-customer.ts similarity index 60% rename from core/app/[locale]/(default)/account/(tabs)/settings/_actions/update-customer.ts rename to core/app/[locale]/(default)/account/settings/_actions/update-customer.ts index 80352e4f1..78c358631 100644 --- a/core/app/[locale]/(default)/account/(tabs)/settings/_actions/update-customer.ts +++ b/core/app/[locale]/(default)/account/settings/_actions/update-customer.ts @@ -1,17 +1,18 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; import { parseAccountFormData } from '~/components/form-fields/shared/parse-fields'; const UpdateCustomerMutation = graphql(` - mutation UpdateCustomerMutation($input: UpdateCustomerInput!, $reCaptchaV2: ReCaptchaV2Input) { + mutation UpdateCustomerMutation($input: UpdateCustomerInput!) { customer { - updateCustomer(input: $input, reCaptchaV2: $reCaptchaV2) { + updateCustomer(input: $input) { customer { firstName lastName @@ -58,58 +59,49 @@ const isUpdateCustomerInput = (data: unknown): data is AddCustomerAddressInput = return false; }; -interface UpdateCustomerForm { - formData: FormData; - reCaptchaToken?: string; +interface UpdateCustomerResponse { + status: 'success' | 'error'; + message: string; } -export const updateCustomer = async ({ formData, reCaptchaToken }: UpdateCustomerForm) => { +export const updateCustomer = async (formData: FormData): Promise => { const t = await getTranslations('Account.Settings.UpdateCustomer'); - - const customerId = await getSessionCustomerId(); - - formData.delete('g-recaptcha-response'); + const customerAccessToken = await getSessionCustomerAccessToken(); const parsed = parseAccountFormData(formData); if (!isUpdateCustomerInput(parsed)) { return { status: 'error', - error: t('Errors.inputError'), + message: t('Errors.inputError'), }; } const response = await client.fetch({ document: UpdateCustomerMutation, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, variables: { input: parsed, - ...(reCaptchaToken && { reCaptchaV2: { token: reCaptchaToken } }), }, }); - revalidatePath('/account/settings', 'page'); - const result = response.data.customer.updateCustomer; - if (result.errors.length === 0) { - const { customer } = result; - - if (!customer) { - return { - status: 'error', - error: t('Errors.notFound'), - }; - } - - const { firstName, lastName } = customer; + if (result.errors.length > 0) { + result.errors.forEach((error) => { + throw new Error(error.message); + }); + } - return { status: 'success', data: { firstName, lastName } }; + if (!result.customer) { + return { + status: 'error', + message: t('Errors.notFound'), + }; } - return { - status: 'error', - error: result.errors.map((error) => error.message).join('\n'), - }; + revalidateTag(TAGS.customer); + + return { status: 'success', message: t('successMessage') }; }; diff --git a/core/app/[locale]/(default)/account/(tabs)/settings/_components/text-field.tsx b/core/app/[locale]/(default)/account/settings/_components/text-field.tsx similarity index 75% rename from core/app/[locale]/(default)/account/(tabs)/settings/_components/text-field.tsx rename to core/app/[locale]/(default)/account/settings/_components/text-field.tsx index f9dae8eb5..07df27731 100644 --- a/core/app/[locale]/(default)/account/(tabs)/settings/_components/text-field.tsx +++ b/core/app/[locale]/(default)/account/settings/_components/text-field.tsx @@ -49,12 +49,20 @@ export const TextField = ({ /> {isRequired && ( - - {t(fieldNameById ?? 'empty')} - + <> + + {t(fieldNameById ?? 'empty')} + + + {t(fieldNameById)} + + )} ); diff --git a/core/app/[locale]/(default)/account/(tabs)/addresses/add/_components/add-address-form.tsx b/core/app/[locale]/(default)/account/settings/_components/update-settings-form.tsx similarity index 54% rename from core/app/[locale]/(default)/account/(tabs)/addresses/add/_components/add-address-form.tsx rename to core/app/[locale]/(default)/account/settings/_components/update-settings-form.tsx index 6a09a14de..68b778a8c 100644 --- a/core/app/[locale]/(default)/account/(tabs)/addresses/add/_components/add-address-form.tsx +++ b/core/app/[locale]/(default)/account/settings/_components/update-settings-form.tsx @@ -1,23 +1,23 @@ 'use client'; +import { AlertCircle, Check } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { MouseEvent, useEffect, useRef, useState } from 'react'; +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, DateField, - FieldNameToFieldId, FieldWrapper, + getPreviouslySubmittedValue, MultilineText, NumbersOnly, Password, Picklist, - PicklistOrText, RadioButtons, - Text, } from '~/components/form-fields'; import { createDatesValidationHandler, @@ -28,37 +28,25 @@ import { createPreSubmitPicklistValidationHandler, createRadioButtonsValidationHandler, createTextInputValidationHandler, - type FieldStateSetFn, } from '~/components/form-fields/shared/field-handlers'; import { Link } from '~/components/link'; import { Button } from '~/components/ui/button'; -import { Field, Form, FormSubmit } from '~/components/ui/form'; -import { Message } from '~/components/ui/message'; -import { useRouter } from '~/i18n/routing'; +import { Form, FormSubmit } from '~/components/ui/form'; -import { useAccountStatusContext } from '../../../_components/account-status-provider'; -import { addAddress } from '../_actions/add-address'; -import { NewAddressQueryResult } from '../page'; +import { updateCustomer } from '../_actions/update-customer'; +import { getCustomerSettingsQuery } from '../page-data'; -interface FormStatus { - status: 'success' | 'error'; - message: string; -} - -type AddressFields = NonNullable< - NewAddressQueryResult['site']['settings'] ->['formFields']['shippingAddress']; +import { TextField } from './text-field'; -type Countries = NonNullable; -type CountryCode = Countries[number]['code']; -type CountryStates = Countries[number]['statesOrProvinces']; +type CustomerInfo = ExistingResultType['customerInfo']; +type CustomerFields = ExistingResultType['customerFields']; +type AddressFields = ExistingResultType['addressFields']; -const createCountryChangeHandler = - (provinceSetter: FieldStateSetFn, countries: Countries) => (value: string) => { - const states = countries.find(({ code }) => code === value)?.statesOrProvinces; - - provinceSetter(states ?? []); - }; +interface FormProps { + addressFields: AddressFields; + customerInfo: CustomerInfo; + customerFields: CustomerFields; +} interface SumbitMessages { messages: { @@ -67,6 +55,22 @@ interface SumbitMessages { }; } +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(); @@ -82,57 +86,32 @@ const SubmitButton = ({ messages }: SumbitMessages) => { ); }; -interface AddAddressProps { - addressFields: AddressFields; - countries: Countries; - defaultCountry: { - id: number; - code: CountryCode; - states: CountryStates; - }; - reCaptchaSettings?: { - isEnabledOnStorefront: boolean; - siteKey: string; - }; -} - -export const AddAddressForm = ({ - addressFields, - countries, - defaultCountry, - reCaptchaSettings, -}: AddAddressProps) => { +export const UpdateSettingsForm = ({ addressFields, customerFields, customerInfo }: FormProps) => { const form = useRef(null); - const [formStatus, setFormStatus] = useState(null); - - const reCaptchaRef = useRef(null); - const router = useRouter(); - const t = useTranslations('Account.Addresses.Add.Form'); - const [reCaptchaToken, setReCaptchaToken] = useState(''); - const [isReCaptchaValid, setReCaptchaValid] = useState(true); const [textInputValid, setTextInputValid] = useState>({}); + const [multiTextValid, setMultiTextValid] = useState>({}); const [numbersInputValid, setNumbersInputValid] = useState>({}); - const [datesValid, setDatesValid] = useState>({}); - const [passwordValid, setPasswordValid] = useState>({}); const [radioButtonsValid, setRadioButtonsValid] = useState>({}); const [picklistValid, setPicklistValid] = useState>({}); const [checkboxesValid, setCheckboxesValid] = useState>({}); - const [multiTextValid, setMultiTextValid] = useState>({}); - const [countryStates, setCountryStates] = useState(defaultCountry.states); + const [datesValid, setDatesValid] = useState>({}); + const [passwordValid, setPasswordValid] = useState>({}); - const { setAccountState } = useAccountStatusContext(); + const t = useTranslations('Account.Settings'); - useEffect(() => { - setAccountState({ status: 'idle' }); - }, [setAccountState]); + const handleTextInputValidation = (e: ChangeEvent) => { + const fieldId = Number(e.target.id.split('-')[1]); - const handleTextInputValidation = createTextInputValidationHandler( - setTextInputValid, - textInputValid, + const validityState = e.target.validity; + const validationStatus = validityState.valueMissing || validityState.typeMismatch; + + setTextInputValid({ ...textInputValid, [fieldId]: !validationStatus }); + }; + const handleMultiTextValidation = createMultilineTextValidationHandler( + setMultiTextValid, + multiTextValid, ); - const handlePasswordValidation = createPasswordValidationHandler(setPasswordValid, addressFields); - const handleCountryChange = createCountryChangeHandler(setCountryStates, countries); const handleNumbersInputValidation = createNumbersInputValidationHandler( setNumbersInputValid, numbersInputValid, @@ -142,18 +121,22 @@ export const AddAddressForm = ({ setRadioButtonsValid, radioButtonsValid, ); - const handleMultiTextValidation = createMultilineTextValidationHandler( - setMultiTextValid, - multiTextValid, - ); const validatePicklistFields = createPreSubmitPicklistValidationHandler( - addressFields, + customerFields, setPicklistValid, ); const validateCheckboxFields = createPreSubmitCheckboxesValidationHandler( - addressFields, + customerFields, setCheckboxesValid, ); + const handlePasswordValidation = createPasswordValidationHandler( + setPasswordValid, + customerFields, + ); + const handleCustomTextValidation = createTextInputValidationHandler( + setTextInputValid, + textInputValid, + ); const preSubmitFieldsValidation = ( e: MouseEvent & { target: HTMLButtonElement }, ) => { @@ -163,41 +146,20 @@ export const AddAddressForm = ({ } }; - const onReCaptchaChange = (token: string | null) => { - if (!token) { - setReCaptchaValid(false); - - return; - } - - setReCaptchaToken(token); - setReCaptchaValid(true); - }; const onSubmit = async (formData: FormData) => { - if (reCaptchaSettings?.isEnabledOnStorefront && !reCaptchaToken) { - setReCaptchaValid(false); + const { status, message } = await updateCustomer(formData); - return; - } - - setReCaptchaValid(true); - - const submit = await addAddress({ formData, reCaptchaToken }); - - if (submit.status === 'success') { - setAccountState({ - status: 'success', - message: submit.message || '', + if (status === 'error') { + toast.error(message, { + icon: , }); - router.push('/account/addresses'); - return; } - if (submit.status === 'error') { - setFormStatus({ status: 'error', message: submit.message || '' }); - } + toast.success(message, { + icon: , + }); window.scrollTo({ top: 0, @@ -206,76 +168,113 @@ export const AddAddressForm = ({ }; return ( - <> - {formStatus && ( - -

{formStatus.message}

-
- )} -
-
- {addressFields.map((field) => { + +
+ {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, 'address'); + const fieldName = createFieldName(field, 'customer'); + const previouslySubmittedField = customerInfo.formFields.find( + ({ entityId: id }) => id === fieldId, + ); switch (field.__typename) { - case 'TextFormField': { - return ( - - - - ); - } + case 'NumberFormField': { + const submittedValue = + getPreviouslySubmittedValue(previouslySubmittedField).NumberFormField; - case 'MultilineTextFormField': { return ( - ); } - case 'NumberFormField': { + case 'CheckboxesFormField': { + const submittedValue = + getPreviouslySubmittedValue(previouslySubmittedField).CheckboxesFormField; + return ( - ); } - case 'CheckboxesFormField': { + case 'MultilineTextFormField': { + const submittedValue = + getPreviouslySubmittedValue(previouslySubmittedField).MultilineTextFormField; + return ( - ); } case 'DateFormField': { + const submittedValue = + getPreviouslySubmittedValue(previouslySubmittedField).DateFormField; + return ( ({ label: name, entityId: code })) - : field.options; - const defaultMultipleChoiceValue = isCountrySelector - ? defaultCountry.code - : undefined; + const submittedValue = + getPreviouslySubmittedValue(previouslySubmittedField).MultipleChoiceFormField; return ( ); } - case 'PicklistOrTextFormField': + case 'TextFormField': { + const submittedValue = + getPreviouslySubmittedValue(previouslySubmittedField).TextFormField; + return ( - id === fieldId)?.label ?? ''} name={fieldName} - options={countryStates.map(({ name }) => { - return { entityId: name, label: name }; - })} + onChange={handleCustomTextValidation} + type="text" /> ); + } case 'PasswordFormField': { + const submittedValue = + getPreviouslySubmittedValue(previouslySubmittedField).PasswordFormField; + return ( - - {!isReCaptchaValid && ( - - {t('recaptchaText')} - - )} - - )} -
- -
+
- + + {t('changePassword')} +
- - +
+ ); }; diff --git a/core/app/[locale]/(default)/account/(tabs)/settings/change-password/_actions/change-password.ts b/core/app/[locale]/(default)/account/settings/change-password/_actions/change-password.ts similarity index 71% rename from core/app/[locale]/(default)/account/(tabs)/settings/change-password/_actions/change-password.ts rename to core/app/[locale]/(default)/account/settings/change-password/_actions/change-password.ts index deaec89f4..5676e7137 100644 --- a/core/app/[locale]/(default)/account/(tabs)/settings/change-password/_actions/change-password.ts +++ b/core/app/[locale]/(default)/account/settings/change-password/_actions/change-password.ts @@ -3,7 +3,7 @@ import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; @@ -44,15 +44,14 @@ const CustomerChangePasswordMutation = graphql(` } `); -export interface State { - status: 'idle' | 'error' | 'success'; - message?: string; +interface ChangePasswordResponse { + status: 'success' | 'error'; + message: string; } -export const changePassword = async (_previousState: unknown, formData: FormData) => { +export const changePassword = async (formData: FormData): Promise => { const t = await getTranslations('Account.Settings.ChangePassword'); - - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); try { const parsedData = CustomerChangePasswordSchema.parse({ @@ -69,30 +68,21 @@ export const changePassword = async (_previousState: unknown, formData: FormData newPassword: parsedData.newPassword, }, }, - customerId, + customerAccessToken, }); const result = response.data.customer.changePassword; - if (result.errors.length === 0) { - return { status: 'success', message: t('success') }; + 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: 'error', - message: result.errors.map((error) => error.message).join('\n'), - }; + return { status: 'success', message: t('confirmChangePassword') }; } catch (error: unknown) { - if (error instanceof z.ZodError) { - return { - status: 'error', - message: error.issues - .map(({ path, message }) => `${path.toString()}: ${message}.`) - .join('\n'), - }; - } - - if (error instanceof Error) { + if (error instanceof Error || error instanceof z.ZodError) { return { status: 'error', message: error.message, 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 new file mode 100644 index 000000000..78c563b7d --- /dev/null +++ b/core/app/[locale]/(default)/account/settings/change-password/_components/change-password-form.tsx @@ -0,0 +1,241 @@ +'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 new file mode 100644 index 000000000..c89fd8041 --- /dev/null +++ b/core/app/[locale]/(default)/account/settings/change-password/page.tsx @@ -0,0 +1,32 @@ +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/(tabs)/settings/page-data.tsx b/core/app/[locale]/(default)/account/settings/page-data.tsx similarity index 90% rename from core/app/[locale]/(default)/account/(tabs)/settings/page-data.tsx rename to core/app/[locale]/(default)/account/settings/page-data.tsx index c7b42ff44..bb5c76ffc 100644 --- a/core/app/[locale]/(default)/account/(tabs)/settings/page-data.tsx +++ b/core/app/[locale]/(default)/account/settings/page-data.tsx @@ -1,13 +1,13 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; -import { getSessionCustomerId } from '~/auth'; +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 { bypassReCaptcha } from '~/lib/bypass-recaptcha'; const CustomerSettingsQuery = graphql( ` @@ -65,10 +65,6 @@ const CustomerSettingsQuery = graphql( ...FormFieldsFragment } } - reCaptcha { - isEnabledOnStorefront - siteKey - } } } } @@ -91,7 +87,7 @@ interface Props { } export const getCustomerSettingsQuery = cache(async ({ address, customer }: Props = {}) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const response = await client.fetch({ document: CustomerSettingsQuery, @@ -102,15 +98,13 @@ export const getCustomerSettingsQuery = cache(async ({ address, customer }: Prop customerSortBy: customer?.sortBy, }, fetchOptions: { cache: 'no-store' }, - customerId, + customerAccessToken, }); const addressFields = response.data.site.settings?.formFields.shippingAddress; const customerFields = response.data.site.settings?.formFields.customer; const customerInfo = response.data.customer; - const reCaptchaSettings = bypassReCaptcha(response.data.site.settings?.reCaptcha); - if (!addressFields || !customerFields || !customerInfo) { return null; } @@ -119,7 +113,6 @@ export const getCustomerSettingsQuery = cache(async ({ address, customer }: Prop addressFields, customerFields, customerInfo, - reCaptchaSettings, }; }); @@ -168,14 +161,14 @@ export interface CustomerAddressesArgs { export const getCustomerAddresses = cache( async ({ before = '', after = '', limit = 9 }: CustomerAddressesArgs) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const paginationArgs = before ? { last: limit, before } : { first: limit, after }; const response = await client.fetch({ document: GetCustomerAddressesQuery, variables: { ...paginationArgs }, - customerId, - fetchOptions: { cache: 'no-store' }, + customerAccessToken, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, }); const addresses = response.data.customer?.addresses; diff --git a/core/app/[locale]/(default)/account/(tabs)/settings/page.tsx b/core/app/[locale]/(default)/account/settings/page.tsx similarity index 85% rename from core/app/[locale]/(default)/account/(tabs)/settings/page.tsx rename to core/app/[locale]/(default)/account/settings/page.tsx index b8706a91e..c5eda14ae 100644 --- a/core/app/[locale]/(default)/account/(tabs)/settings/page.tsx +++ b/core/app/[locale]/(default)/account/settings/page.tsx @@ -24,12 +24,10 @@ export default async function Settings() { } return ( - <> +
-
- -
- + +
); } diff --git a/core/app/[locale]/(default)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index 21e373a87..251346951 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page.tsx +++ b/core/app/[locale]/(default)/blog/[blogId]/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getFormatter } from 'next-intl/server'; -import { BcImage } from '~/components/bc-image'; +import { Image } from '~/components/image'; import { Link } from '~/components/link'; import { Tag } from '~/components/ui/tag'; @@ -10,12 +10,14 @@ import { SharingLinks } from './_components/sharing-links'; import { getBlogPageData } from './page-data'; interface Props { - params: { + params: Promise<{ blogId: string; - }; + }>; } -export async function generateMetadata({ params: { blogId } }: Props): Promise { +export async function generateMetadata({ params }: Props): Promise { + const { blogId } = await params; + const data = await getBlogPageData({ entityId: Number(blogId) }); const blogPost = data?.content.blog?.post; @@ -32,7 +34,9 @@ export async function generateMetadata({ params: { blogId } }: Props): Promise - ; + params: Promise<{ locale: string }>; + searchParams: Promise>; } -export async function generateMetadata({ searchParams }: Props): Promise { +export async function generateMetadata(props: Props): Promise { + const searchParams = await props.searchParams; const t = await getTranslations('Blog'); const blogPosts = await getBlogPosts(searchParams); @@ -26,7 +26,8 @@ export async function generateMetadata({ searchParams }: Props): Promise; + }>; + searchParams: Promise>; } -export default async function Tag({ params: { tagId }, searchParams }: Props) { +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) { 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 e3b129c60..a1362c5c9 100644 --- a/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts +++ b/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts @@ -1,8 +1,9 @@ 'use server'; +import { getLocale } from 'next-intl/server'; import { z } from 'zod'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { redirect } from '~/i18n/routing'; @@ -20,14 +21,15 @@ const CheckoutRedirectMutation = graphql(` `); export const redirectToCheckout = async (formData: FormData) => { + const locale = await getLocale(); const cartId = z.string().parse(formData.get('cartId')); - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const { data } = await client.fetch({ document: CheckoutRedirectMutation, variables: { cartId }, fetchOptions: { cache: 'no-store' }, - customerId, + customerAccessToken, }); const url = data.cart.createCartRedirectUrls.redirectUrls?.redirectedCheckoutUrl; @@ -36,5 +38,5 @@ export const redirectToCheckout = async (formData: FormData) => { throw new Error('Invalid checkout url.'); } - redirect(url); + 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 326848b15..a28b9a57a 100644 --- a/core/app/[locale]/(default)/cart/_actions/remove-item.ts +++ b/core/app/[locale]/(default)/cart/_actions/remove-item.ts @@ -3,7 +3,7 @@ import { revalidateTag } from 'next/cache'; import { cookies } from 'next/headers'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { TAGS } from '~/client/tags'; @@ -26,10 +26,11 @@ type DeleteCartLineItemInput = Variables['input']; export async function removeItem({ lineItemEntityId, }: Omit) { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); try { - const cartId = cookies().get('cartId')?.value; + const cookieStore = await cookies(); + const cartId = cookieStore.get('cartId')?.value; if (!cartId) { return { status: 'error', error: 'No cartId cookie found' }; @@ -47,7 +48,7 @@ export async function removeItem({ lineItemEntityId, }, }, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, }); @@ -57,7 +58,7 @@ export async function removeItem({ // so we need to remove the cartId cookie // TODO: We need to figure out if it actually failed. if (!cart) { - cookies().delete('cartId'); + cookieStore.delete('cartId'); } revalidateTag(TAGS.cart); diff --git a/core/app/[locale]/(default)/cart/_components/cart-item.tsx b/core/app/[locale]/(default)/cart/_components/cart-item.tsx index e853a233e..bb35121f1 100644 --- a/core/app/[locale]/(default)/cart/_components/cart-item.tsx +++ b/core/app/[locale]/(default)/cart/_components/cart-item.tsx @@ -1,7 +1,7 @@ import { useFormatter } from 'next-intl'; import { FragmentOf, graphql } from '~/client/graphql'; -import { BcImage } from '~/components/bc-image'; +import { Image } from '~/components/image'; import { ItemQuantity } from './item-quantity'; import { RemoveItem } from './remove-item'; @@ -155,7 +155,7 @@ export const CartItem = ({ currencyCode, product }: Props) => {
{product.image?.url ? ( - + {product.name} ) : (
)} 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 index 9afcd20a0..0443593a0 100644 --- 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 @@ -3,7 +3,7 @@ import { revalidateTag } from 'next/cache'; import { z } from 'zod'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; @@ -26,7 +26,7 @@ const ApplyCheckoutCouponMutation = graphql(` `); export const applyCouponCode = async (formData: FormData) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); try { const parsedData = ApplyCouponCodeSchema.parse({ @@ -44,7 +44,7 @@ export const applyCouponCode = async (formData: FormData) => { }, }, }, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, }); 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 index a055f2b15..2a3621db5 100644 --- 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 @@ -3,7 +3,7 @@ import { revalidateTag } from 'next/cache'; import { z } from 'zod'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; @@ -26,7 +26,7 @@ const UnapplyCheckoutCouponMutation = graphql(` `); export const removeCouponCode = async (formData: FormData) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); try { const parsedData = RemoveCouponCodeSchema.parse({ @@ -44,7 +44,7 @@ export const removeCouponCode = async (formData: FormData) => { }, }, }, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, }); diff --git a/core/app/[locale]/(default)/cart/_components/item-quantity/update-item-quantity.ts b/core/app/[locale]/(default)/cart/_components/item-quantity/update-item-quantity.ts index 4ab616f96..9ff6f6411 100644 --- a/core/app/[locale]/(default)/cart/_components/item-quantity/update-item-quantity.ts +++ b/core/app/[locale]/(default)/cart/_components/item-quantity/update-item-quantity.ts @@ -3,7 +3,7 @@ import { revalidatePath } from 'next/cache'; import { cookies } from 'next/headers'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; @@ -36,10 +36,11 @@ export async function updateItemQuantity({ variantEntityId, selectedOptions, }: UpdateProductQuantityParams) { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); try { - const cartId = cookies().get('cartId')?.value; + const cookieStore = await cookies(); + const cartId = cookieStore.get('cartId')?.value; if (!cartId) { return { status: 'error', error: 'No cartId cookie found' }; @@ -72,7 +73,7 @@ export async function updateItemQuantity({ }, }, }, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, }); 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 index 1e863f24f..e34099b94 100644 --- 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 @@ -3,7 +3,7 @@ import { revalidateTag } from 'next/cache'; import { z } from 'zod'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; @@ -47,7 +47,7 @@ export const submitShippingInfo = async ( lineItems: Array<{ quantity: number; lineItemEntityId: string }>; }, ) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); try { const parsedData = ShippingInfoSchema.parse({ @@ -81,7 +81,7 @@ export const submitShippingInfo = async ( }, }, }, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, }); @@ -108,7 +108,7 @@ export const submitShippingInfo = async ( }, }, }, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, }); 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 index 479f79ba1..0dc7719b1 100644 --- 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 @@ -3,7 +3,7 @@ import { revalidateTag } from 'next/cache'; import { z } from 'zod'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; @@ -29,7 +29,7 @@ export const submitShippingCosts = async ( checkoutEntityId: string, consignmentEntityId: string, ) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); try { const parsedData = ShippingCostSchema.parse({ @@ -47,7 +47,7 @@ export const submitShippingCosts = async ( }, }, }, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, }); diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index 8a25d5a00..5f357f4aa 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -1,7 +1,7 @@ import { cookies } from 'next/headers'; import { getTranslations } from 'next-intl/server'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; @@ -45,7 +45,9 @@ export async function generateMetadata() { } export default async function Cart() { - const cartId = cookies().get('cartId')?.value; + const cookieStore = await cookies(); + + const cartId = cookieStore.get('cartId')?.value; if (!cartId) { return ; @@ -53,12 +55,12 @@ export default async function Cart() { const t = await getTranslations('Cart'); - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const { data } = await client.fetch({ document: CartPageQuery, variables: { cartId }, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store', next: { 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 cee93504a..223363596 100644 --- a/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts +++ b/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts @@ -3,15 +3,19 @@ import { revalidateTag } from 'next/cache'; import { cookies } from 'next/headers'; -import { addCartLineItem } from '~/client/mutations/add-cart-line-item'; -import { createCart } from '~/client/mutations/create-cart'; +import { + addCartLineItem, + assertAddCartLineItemErrors, +} from '~/client/mutations/add-cart-line-item'; +import { assertCreateCartErrors, createCart } from '~/client/mutations/create-cart'; import { getCart } from '~/client/queries/get-cart'; import { TAGS } from '~/client/tags'; export const addToCart = async (data: FormData) => { const productEntityId = Number(data.get('product_id')); - const cartId = cookies().get('cartId')?.value; + const cookieStore = await cookies(); + const cartId = cookieStore.get('cartId')?.value; let cart; @@ -19,7 +23,7 @@ export const addToCart = async (data: FormData) => { cart = await getCart(cartId); if (cart) { - cart = await addCartLineItem(cart.entityId, { + const addCartLineItemResponse = await addCartLineItem(cart.entityId, { lineItems: [ { productEntityId, @@ -28,6 +32,10 @@ export const addToCart = async (data: FormData) => { ], }); + assertAddCartLineItemErrors(addCartLineItemResponse); + + cart = addCartLineItemResponse.data.cart.addCartLineItems?.cart; + if (!cart?.entityId) { return { status: 'error', error: 'Failed to add product to cart.' }; } @@ -37,13 +45,17 @@ export const addToCart = async (data: FormData) => { return { status: 'success', data: cart }; } - cart = await createCart([{ productEntityId, quantity: 1 }]); + const createCartResponse = await createCart([{ productEntityId, quantity: 1 }]); + + assertCreateCartErrors(createCartResponse); + + cart = createCartResponse.data.cart.createCart?.cart; if (!cart?.entityId) { return { status: 'error', error: 'Failed to add product to cart.' }; } - cookies().set({ + cookieStore.set({ name: 'cartId', value: cart.entityId, httpOnly: true, diff --git a/core/app/[locale]/(default)/compare/_components/add-to-cart/index.tsx b/core/app/[locale]/(default)/compare/_components/add-to-cart/index.tsx index 27deb4a88..6cbbf4102 100644 --- a/core/app/[locale]/(default)/compare/_components/add-to-cart/index.tsx +++ b/core/app/[locale]/(default)/compare/_components/add-to-cart/index.tsx @@ -3,7 +3,7 @@ import { FragmentOf } from 'gql.tada'; import { AlertCircle, Check } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useTransition } from 'react'; +import { useId, useTransition } from 'react'; import { toast } from 'react-hot-toast'; import { AddToCartButton } from '~/components/add-to-cart-button'; @@ -17,6 +17,7 @@ import { AddToCartFragment } from './fragment'; export const AddToCart = ({ data: product }: { data: FragmentOf }) => { const t = useTranslations('Compare.AddToCart'); const cart = useCart(); + const toastId = useId(); const [isPending, startTransition] = useTransition(); const handleSubmit = (event: React.FormEvent) => { @@ -47,7 +48,7 @@ export const AddToCart = ({ data: product }: { data: FragmentOf
), - { icon: }, + { icon: , id: toastId }, ); startTransition(async () => { @@ -56,8 +57,9 @@ export const AddToCart = ({ data: product }: { data: FragmentOf, + id: toastId, }); } }); diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index 506e38025..46f2803da 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -2,12 +2,12 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { getFormatter, getTranslations } from 'next-intl/server'; import * as z from 'zod'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { PricingFragment } from '~/client/fragments/pricing'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; -import { BcImage } from '~/components/bc-image'; +import { Image } from '~/components/image'; import { Link } from '~/components/link'; import { SearchForm } from '~/components/search-form'; import { Button } from '~/components/ui/button'; @@ -90,14 +90,14 @@ export async function generateMetadata() { } interface Props { - searchParams: Record; + searchParams: Promise>; } -export default async function Compare({ searchParams }: Props) { +export default async function Compare(props: Props) { + const searchParams = await props.searchParams; const t = await getTranslations('Compare'); const format = await getFormatter(); - - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const parsed = CompareParamsSchema.parse(searchParams); const productIds = parsed.ids?.filter((id) => !Number.isNaN(id)); @@ -108,8 +108,8 @@ export default async function Compare({ searchParams }: Props) { entityIds: productIds ?? [], first: productIds?.length ? MAX_COMPARE_LIMIT : 0, }, - customerId, - fetchOptions: customerId ? { cache: 'no-store' } : { next: { revalidate } }, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, }); const products = removeEdgesAndNodes(data.site.products).map((product) => ({ @@ -157,7 +157,7 @@ export default async function Compare({ searchParams }: Props) { return ( - ; } -export default function DefaultLayout({ children, params: { locale } }: Props) { - unstable_setRequestLocale(locale); +export default async function DefaultLayout({ params, children }: Props) { + const { locale } = await params; + + setRequestLocale(locale); return ( <> diff --git a/core/app/[locale]/(default)/page.tsx b/core/app/[locale]/(default)/page.tsx index 70ace9438..3147c224d 100644 --- a/core/app/[locale]/(default)/page.tsx +++ b/core/app/[locale]/(default)/page.tsx @@ -1,14 +1,13 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; import { ProductCardCarousel } from '~/components/product-card-carousel'; import { ProductCardCarouselFragment } from '~/components/product-card-carousel/fragment'; import { Slideshow } from '~/components/slideshow'; -import { LocaleType } from '~/i18n/routing'; const HomePageQuery = graphql( ` @@ -35,22 +34,21 @@ const HomePageQuery = graphql( ); interface Props { - params: { - locale: LocaleType; - }; + params: Promise<{ locale: string }>; } -export default async function Home({ params: { locale } }: Props) { - unstable_setRequestLocale(locale); +export default async function Home({ params }: Props) { + const { locale } = await params; - const t = await getTranslations('Home'); + setRequestLocale(locale); - const customerId = await getSessionCustomerId(); + const t = await getTranslations('Home'); + const customerAccessToken = await getSessionCustomerAccessToken(); const { data } = await client.fetch({ document: HomePageQuery, - customerId, - fetchOptions: customerId ? { cache: 'no-store' } : { next: { revalidate } }, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, }); const featuredProducts = removeEdgesAndNodes(data.site.featuredProducts); diff --git a/core/app/[locale]/(default)/product/[slug]/_components/product-form/_actions/add-to-cart.ts b/core/app/[locale]/(default)/product/[slug]/_components/product-form/_actions/add-to-cart.ts index c9ec0a250..73adec219 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/product-form/_actions/add-to-cart.ts +++ b/core/app/[locale]/(default)/product/[slug]/_components/product-form/_actions/add-to-cart.ts @@ -5,8 +5,11 @@ import { revalidateTag } from 'next/cache'; import { cookies } from 'next/headers'; import { FragmentOf, graphql } from '~/client/graphql'; -import { addCartLineItem } from '~/client/mutations/add-cart-line-item'; -import { createCart } from '~/client/mutations/create-cart'; +import { + addCartLineItem, + assertAddCartLineItemErrors, +} from '~/client/mutations/add-cart-line-item'; +import { assertCreateCartErrors, createCart } from '~/client/mutations/create-cart'; import { getCart } from '~/client/queries/get-cart'; import { TAGS } from '~/client/tags'; @@ -22,7 +25,8 @@ export async function handleAddToCart( const productEntityId = Number(data.product_id); const quantity = Number(data.quantity); - const cartId = cookies().get('cartId')?.value; + const cookieStore = await cookies(); + const cartId = cookieStore.get('cartId')?.value; let cart; @@ -137,7 +141,7 @@ export async function handleAddToCart( cart = await getCart(cartId); if (cart) { - cart = await addCartLineItem(cart.entityId, { + const addCartLineItemResponse = await addCartLineItem(cart.entityId, { lineItems: [ { productEntityId, @@ -147,8 +151,12 @@ export async function handleAddToCart( ], }); + assertAddCartLineItemErrors(addCartLineItemResponse); + + cart = addCartLineItemResponse.data.cart.addCartLineItems?.cart; + if (!cart?.entityId) { - return { status: 'error', error: 'Failed to add product to cart.' }; + throw new Error('Failed to add product to cart.'); } revalidateTag(TAGS.cart); @@ -156,8 +164,7 @@ export async function handleAddToCart( return { status: 'success', data: cart }; } - // Create cart - cart = await createCart([ + const createCartResponse = await createCart([ { productEntityId, selectedOptions, @@ -165,11 +172,15 @@ export async function handleAddToCart( }, ]); + assertCreateCartErrors(createCartResponse); + + cart = createCartResponse.data.cart.createCart?.cart; + if (!cart?.entityId) { return { status: 'error', error: 'Failed to add product to cart.' }; } - cookies().set({ + cookieStore.set({ name: 'cartId', value: cart.entityId, httpOnly: true, diff --git a/core/app/[locale]/(default)/product/[slug]/_components/product-form/index.tsx b/core/app/[locale]/(default)/product/[slug]/_components/product-form/index.tsx index ed280a51c..277617070 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/product-form/index.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/product-form/index.tsx @@ -4,6 +4,7 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { FragmentOf } from 'gql.tada'; import { AlertCircle, Check, Heart, ShoppingCart } from 'lucide-react'; import { useTranslations } from 'next-intl'; +import { useId } from 'react'; import { FormProvider, useFormContext } from 'react-hook-form'; import { toast } from 'react-hot-toast'; @@ -63,6 +64,7 @@ export const ProductForm = ({ data: product }: Props) => { const productOptions = removeEdgesAndNodes(product.productOptions); const cart = useCart(); + const toastId = useId(); const { handleSubmit, register, ...methods } = useProductForm(); const productFormSubmit = async (data: ProductFormData) => { @@ -91,14 +93,15 @@ export const ProductForm = ({ data: product }: Props) => {
), - { icon: }, + { icon: , id: toastId }, ); const result = await handleAddToCart(data, product); if (result.error) { - toast.error(t('error'), { + toast.error(result.error, { icon: , + id: toastId, }); cart.decrement(quantity); diff --git a/core/app/[locale]/(default)/product/[slug]/_components/related-products.tsx b/core/app/[locale]/(default)/product/[slug]/_components/related-products.tsx index 0863367f9..7561f0e99 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/related-products.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/related-products.tsx @@ -1,7 +1,7 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { getTranslations } from 'next-intl/server'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; @@ -33,14 +33,13 @@ interface Props { export const RelatedProducts = async ({ productId }: Props) => { const t = await getTranslations('Product.Carousel'); - - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const { data } = await client.fetch({ document: RelatedProductsQuery, variables: { entityId: productId }, - customerId, - fetchOptions: customerId ? { cache: 'no-store' } : { next: { revalidate } }, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, }); const product = data.site.product; diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 2d16108c1..f61ac31c5 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -1,6 +1,6 @@ import { cache } from 'react'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { ProductItemFragment } from '~/client/fragments/product-item'; import { graphql, VariablesOf } from '~/client/graphql'; @@ -65,13 +65,13 @@ const ProductPageQuery = graphql( type Variables = VariablesOf; export const getProduct = cache(async (variables: Variables) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const { data } = await client.fetch({ document: ProductPageQuery, variables, - customerId, - fetchOptions: customerId ? { cache: 'no-store' } : { next: { revalidate } }, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, }); return data.site.product; diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 9e4480ff7..6fdd3353a 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -1,11 +1,10 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Suspense } from 'react'; import { Breadcrumbs } from '~/components/breadcrumbs'; -import { LocaleType } from '~/i18n/routing'; import { Description } from './_components/description'; import { Details } from './_components/details'; @@ -17,11 +16,11 @@ import { Warranty } from './_components/warranty'; import { getProduct } from './page-data'; interface Props { - params: { slug: string; locale: LocaleType }; - searchParams: Record; + params: Promise<{ slug: string; locale: string }>; + searchParams: Promise>; } -function getOptionValueIds({ searchParams }: { searchParams: Props['searchParams'] }) { +function getOptionValueIds({ searchParams }: { searchParams: Awaited }) { const { slug, ...options } = searchParams; return Object.keys(options) @@ -34,7 +33,9 @@ function getOptionValueIds({ searchParams }: { searchParams: Props['searchParams ); } -export async function generateMetadata({ params, searchParams }: Props): Promise { +export async function generateMetadata(props: Props): Promise { + const searchParams = await props.searchParams; + const params = await props.params; const productId = Number(params.slug); const optionValueIds = getOptionValueIds({ searchParams }); @@ -68,8 +69,13 @@ export async function generateMetadata({ params, searchParams }: Props): Promise }; } -export default async function Product({ params: { locale, slug }, searchParams }: Props) { - unstable_setRequestLocale(locale); +export default async function Product(props: Props) { + const searchParams = await props.searchParams; + const params = await props.params; + + const { locale, slug } = params; + + setRequestLocale(locale); const t = await getTranslations('Product'); diff --git a/core/app/[locale]/(default)/product/[slug]/static/page.tsx b/core/app/[locale]/(default)/product/[slug]/static/page.tsx index 94bea74ae..2cb5384e7 100644 --- a/core/app/[locale]/(default)/product/[slug]/static/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/static/page.tsx @@ -1,7 +1,8 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { Metadata } from 'next'; import { cache } from 'react'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { getChannelIdFromLocale } from '~/channels.config'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; @@ -9,8 +10,8 @@ import { revalidate as revalidateTarget } from '~/client/revalidate-target'; import { locales } from '~/i18n/routing'; import ProductPage from '../page'; +import { getProduct } from '../page-data'; -export { generateMetadata } from '../page'; export default ProductPage; const FeaturedProductsQuery = graphql(` @@ -32,13 +33,15 @@ interface Options { } const getFeaturedProducts = cache(async ({ first = 12 }: Options = {}) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const response = await client.fetch({ document: FeaturedProductsQuery, variables: { first }, - customerId, - fetchOptions: customerId ? { cache: 'no-store' } : { next: { revalidate: revalidateTarget } }, + customerAccessToken, + fetchOptions: customerAccessToken + ? { cache: 'no-store' } + : { next: { revalidate: revalidateTarget } }, channelId: getChannelIdFromLocale(), // Using default channel id }); @@ -56,5 +59,42 @@ export async function generateStaticParams() { }); } +interface Props { + params: Promise<{ slug: string; locale: string }>; +} + +export async function generateMetadata(props: Props): Promise { + const params = await props.params; + const productId = Number(params.slug); + + const product = await getProduct({ + entityId: productId, + useDefaultOptionSelections: true, + }); + + if (!product) { + return {}; + } + + const { pageTitle, metaDescription, metaKeywords } = product.seo; + const { url, altText: alt } = product.defaultImage || {}; + + return { + title: pageTitle || product.name, + description: metaDescription || `${product.plainTextDescription.slice(0, 150)}...`, + keywords: metaKeywords ? metaKeywords.split(',') : null, + openGraph: url + ? { + images: [ + { + url, + alt, + }, + ], + } + : null, + }; +} + export const dynamic = 'force-static'; export const revalidate = 600; diff --git a/core/app/[locale]/(default)/webpages/contact/[id]/page.tsx b/core/app/[locale]/(default)/webpages/contact/[id]/page.tsx index 8c90cb53f..ec3e9b4c2 100644 --- a/core/app/[locale]/(default)/webpages/contact/[id]/page.tsx +++ b/core/app/[locale]/(default)/webpages/contact/[id]/page.tsx @@ -7,11 +7,12 @@ import { ContactUs } from './contact-us'; import { getWebpageData } from './page-data'; interface Props { - params: { id: string }; + params: Promise<{ id: string }>; } export async function generateMetadata({ params }: Props): Promise { - const data = await getWebpageData({ id: decodeURIComponent(params.id) }); + const { id } = await params; + const data = await getWebpageData({ id: decodeURIComponent(id) }); const webpage = data.node?.__typename === 'ContactPage' ? data.node : null; if (!webpage) { @@ -27,16 +28,18 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function WebPage({ params: { id } }: Props) { +export default async function WebPage({ params }: Props) { + const { id } = await params; + const data = await getWebpageData({ id: decodeURIComponent(id) }); const webpage = data.node?.__typename === 'ContactPage' ? data.node : null; - const recaptchaSettings = data.site.settings?.reCaptcha; if (!webpage) { notFound(); } const { name, htmlBody } = webpage; + const recaptchaSettings = await bypassReCaptcha(data.site.settings?.reCaptcha); return ( <> @@ -45,7 +48,7 @@ export default async function WebPage({ params: { id } }: Props) {
- + ); } diff --git a/core/app/[locale]/(default)/webpages/normal/[id]/page.tsx b/core/app/[locale]/(default)/webpages/normal/[id]/page.tsx index fcc095e8d..34ac56f5e 100644 --- a/core/app/[locale]/(default)/webpages/normal/[id]/page.tsx +++ b/core/app/[locale]/(default)/webpages/normal/[id]/page.tsx @@ -4,10 +4,12 @@ import { notFound } from 'next/navigation'; import { getWebpageData } from './page-data'; interface Props { - params: { id: string }; + params: Promise<{ id: string }>; } -export async function generateMetadata({ params: { id } }: Props): Promise { +export async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + const data = await getWebpageData({ id: decodeURIComponent(id) }); const webpage = data.node?.__typename === 'NormalPage' ? data.node : null; @@ -24,7 +26,9 @@ export async function generateMetadata({ params: { id } }: Props): Promise { +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + + setRequestLocale(locale); + const { data } = await client.fetch({ document: RootLayoutMetadataQuery, fetchOptions: { next: { revalidate } }, @@ -77,15 +81,17 @@ const VercelComponents = () => { }; interface Props extends PropsWithChildren { - params: { locale: string }; + params: Promise<{ locale: string }>; } -export default function RootLayout({ children, params: { locale } }: Props) { +export default async function RootLayout({ params, children }: Props) { + const { locale } = await params; + // need to call this method everywhere where static rendering is enabled - // https://next-intl-docs.vercel.app/docs/getting-started/app-router#add-unstable_setrequestlocale-to-all-layouts-and-pages - unstable_setRequestLocale(locale); + // https://next-intl-docs.vercel.app/docs/getting-started/app-router#add-setRequestLocale-to-all-layouts-and-pages + setRequestLocale(locale); - const messages = useMessages(); + const messages = await getMessages(); return ( diff --git a/core/app/[locale]/maintenance/page.tsx b/core/app/[locale]/maintenance/page.tsx index a983c25f2..7bbd4b581 100644 --- a/core/app/[locale]/maintenance/page.tsx +++ b/core/app/[locale]/maintenance/page.tsx @@ -1,12 +1,11 @@ import { Phone } from 'lucide-react'; -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; import { ReactNode } from 'react'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { StoreLogo } from '~/components/store-logo'; import { StoreLogoFragment } from '~/components/store-logo/fragment'; -import { LocaleType } from '~/i18n/routing'; const MaintenancePageQuery = graphql( ` @@ -38,11 +37,13 @@ const Container = ({ children }: { children: ReactNode }) => ( ); interface Props { - params: { locale: LocaleType }; + params: Promise<{ locale: string }>; } -export default async function Maintenance({ params: { locale } }: Props) { - unstable_setRequestLocale(locale); +export default async function Maintenance({ params }: Props) { + const { locale } = await params; + + setRequestLocale(locale); const t = await getTranslations('Maintenance'); diff --git a/core/app/[locale]/not-found.tsx b/core/app/[locale]/not-found.tsx index 2c6db7de0..a26f08e73 100644 --- a/core/app/[locale]/not-found.tsx +++ b/core/app/[locale]/not-found.tsx @@ -32,6 +32,7 @@ const NotFoundQuery = graphql( export default async function NotFound() { const t = await getTranslations('NotFound'); + const ct = await getTranslations('Components.Header.MiniCart'); const { data } = await client.fetch({ document: NotFoundQuery, @@ -46,7 +47,7 @@ export default async function NotFound() {
- + } /> diff --git a/core/app/[locale]/store-selector/_components/locale-link.tsx b/core/app/[locale]/store-selector/_components/locale-link.tsx index af9450935..28cc9e810 100644 --- a/core/app/[locale]/store-selector/_components/locale-link.tsx +++ b/core/app/[locale]/store-selector/_components/locale-link.tsx @@ -1,5 +1,5 @@ import { Link } from '~/components/link'; -import { localeLanguageRegionMap, LocaleType } from '~/i18n/routing'; +import { localeLanguageRegionMap } from '~/i18n/routing'; import { cn } from '~/lib/utils'; export const LocaleLink = ({ locale, selected }: { locale: string; selected: boolean }) => { @@ -16,8 +16,7 @@ export const LocaleLink = ({ locale, selected }: { locale: string; selected: boo selected && 'border-black', )} href="/" - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - locale={locale as LocaleType} + locale={locale} >
{selectedLocale.flag}
diff --git a/core/app/[locale]/store-selector/page.tsx b/core/app/[locale]/store-selector/page.tsx index c7aed281c..89eb945f9 100644 --- a/core/app/[locale]/store-selector/page.tsx +++ b/core/app/[locale]/store-selector/page.tsx @@ -1,11 +1,11 @@ -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { Link } from '~/components/link'; import { StoreLogo } from '~/components/store-logo'; import { StoreLogoFragment } from '~/components/store-logo/fragment'; -import { locales, LocaleType } from '~/i18n/routing'; +import { locales } from '~/i18n/routing'; import { LocaleLink } from './_components/locale-link'; @@ -31,11 +31,13 @@ export async function generateMetadata() { } interface Props { - params: { locale: LocaleType }; + params: Promise<{ locale: string }>; } -export default async function StoreSelector({ params: { locale: selectedLocale } }: Props) { - unstable_setRequestLocale(selectedLocale); +export default async function StoreSelector({ params }: Props) { + const { locale: selectedLocale } = await params; + + setRequestLocale(selectedLocale); const t = await getTranslations('StoreSelector'); @@ -54,13 +56,11 @@ export default async function StoreSelector({ params: { locale: selectedLocale } )}
-

{t('heading')}

{locales.map((locale) => ( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition ))}
diff --git a/core/app/admin/route.ts b/core/app/admin/route.ts index 457ae77a4..ce1cd21f7 100644 --- a/core/app/admin/route.ts +++ b/core/app/admin/route.ts @@ -1,4 +1,4 @@ -import { redirect } from '~/i18n/routing'; +import { defaultLocale, redirect } from '~/i18n/routing'; const canonicalDomain: string = process.env.BIGCOMMERCE_GRAPHQL_API_DOMAIN ?? 'mybigcommerce.com'; const BIGCOMMERCE_STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH; @@ -7,14 +7,15 @@ const ENABLE_ADMIN_ROUTE = process.env.ENABLE_ADMIN_ROUTE; export const GET = () => { // This route should not work unless explicitly enabled if (ENABLE_ADMIN_ROUTE !== 'true') { - return redirect('/'); + return redirect({ href: '/', locale: defaultLocale }); } - return redirect( - BIGCOMMERCE_STORE_HASH + return redirect({ + href: BIGCOMMERCE_STORE_HASH ? `https://store-${BIGCOMMERCE_STORE_HASH}.${canonicalDomain}/admin` : 'https://login.bigcommerce.com', - ); + locale: defaultLocale, + }); }; export const runtime = 'edge'; diff --git a/core/app/api/cart-quantity/route.ts b/core/app/api/cart-quantity/route.ts index f0d7ab901..c741deae1 100644 --- a/core/app/api/cart-quantity/route.ts +++ b/core/app/api/cart-quantity/route.ts @@ -5,7 +5,8 @@ import { getChannelIdFromLocale } from '~/channels.config'; import { getCart } from '~/client/queries/get-cart'; export const GET = async (request: NextRequest) => { - const cartId = cookies().get('cartId')?.value; + const cookieStore = await cookies(); + const cartId = cookieStore.get('cartId')?.value; const searchParams = request.nextUrl.searchParams; const locale = searchParams.get('locale') ?? undefined; diff --git a/core/app/api/internal-auth.ts b/core/app/api/internal-auth.ts deleted file mode 100644 index 15277c8d1..000000000 --- a/core/app/api/internal-auth.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -const token = process.env.BIGCOMMERCE_CUSTOMER_IMPERSONATION_TOKEN; - -if (!token) { - throw new Error('BIGCOMMERCE_CUSTOMER_IMPERSONATION_TOKEN is not defined'); -} - -type Handler = (request: NextRequest) => NextResponse | Promise; - -export const withInternalAuth = (handler: Handler) => { - return (request: NextRequest) => { - const requestHeaders = new Headers(request.headers); - const requestToken = requestHeaders.get('x-internal-token'); - - if (requestToken !== token) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - - return handler(request); - }; -}; diff --git a/core/app/notifications.tsx b/core/app/notifications.tsx index 095ae743a..a529a4892 100644 --- a/core/app/notifications.tsx +++ b/core/app/notifications.tsx @@ -7,7 +7,7 @@ export const Notifications = () => { position="top-right" toastOptions={{ className: - '!text-black !rounded !border !border-gray-200 !bg-white !shadow-lg !py-4 !px-6 !text-base', + '!text-black !rounded !border !border-gray-200 !bg-white !shadow-lg !py-4 !px-6 !text-base [&>svg]:!shrink-0', }} /> ); diff --git a/core/app/providers.tsx b/core/app/providers.tsx index e8490a82b..7431c3077 100644 --- a/core/app/providers.tsx +++ b/core/app/providers.tsx @@ -5,14 +5,10 @@ import { PropsWithChildren } from 'react'; import { CartProvider } from '~/components/header/cart-provider'; import { CompareDrawerProvider } from '~/components/ui/compare-drawer'; -import { AccountStatusProvider } from './[locale]/(default)/account/(tabs)/_components/account-status-provider'; - export function Providers({ children }: PropsWithChildren) { return ( - - {children} - + {children} ); } diff --git a/core/app/sitemap.xml/route.ts b/core/app/sitemap.xml/route.ts index 585430ba3..f5cb52809 100644 --- a/core/app/sitemap.xml/route.ts +++ b/core/app/sitemap.xml/route.ts @@ -3,10 +3,12 @@ * Proxy to the existing BigCommerce sitemap index on the canonical URL */ +import { getChannelIdFromLocale } from '~/channels.config'; import { client } from '~/client'; +import { defaultLocale } from '~/i18n/routing'; export const GET = async () => { - const sitemapIndex = await client.fetchSitemapIndex(); + const sitemapIndex = await client.fetchSitemapIndex(getChannelIdFromLocale(defaultLocale)); return new Response(sitemapIndex, { headers: { diff --git a/core/app/xmlsitemap.php/route.ts b/core/app/xmlsitemap.php/route.ts index 42036d2b0..6d848b4af 100644 --- a/core/app/xmlsitemap.php/route.ts +++ b/core/app/xmlsitemap.php/route.ts @@ -1,5 +1,5 @@ /* eslint-disable check-file/folder-naming-convention */ -import { permanentRedirect } from '~/i18n/routing'; +import { defaultLocale, permanentRedirect } from '~/i18n/routing'; /* * This route is used to redirect the legacy Stencil sitemap that lives on /xmlsitemap.php @@ -8,6 +8,8 @@ import { permanentRedirect } from '~/i18n/routing'; * on /xmlsitemap.php */ -export const GET = () => permanentRedirect('/sitemap.xml'); +export const GET = () => { + permanentRedirect({ href: '/sitemap.xml', locale: defaultLocale }); +}; export const runtime = 'edge'; diff --git a/core/auth.ts b/core/auth.ts index 09b4d5aba..708933de8 100644 --- a/core/auth.ts +++ b/core/auth.ts @@ -10,6 +10,9 @@ import { graphql } from './client/graphql'; const LoginMutation = graphql(` mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { + customerAccessToken { + value + } customer { entityId firstName @@ -32,16 +35,10 @@ const AssignCartToCustomerMutation = graphql(` } `); -const UnassignCartFromCustomerMutation = graphql(` - mutation UnassignCartFromCustomer( - $unassignCartFromCustomerInput: UnassignCartFromCustomerInput! - ) { - cart { - unassignCartFromCustomer(input: $unassignCartFromCustomerInput) { - cart { - entityId - } - } +const LogoutMutation = graphql(` + mutation LogoutMutation { + logout { + result } } `); @@ -62,25 +59,26 @@ const config = { jwt: ({ token, user }) => { // user can actually be undefined // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (user?.id) { - token.id = user.id; + if (user?.customerAccessToken) { + token.customerAccessToken = user.customerAccessToken; } return token; }, session({ session, token }) { - if (token.id) { - session.user.id = token.id; + if (token.customerAccessToken) { + session.customerAccessToken = token.customerAccessToken; } return session; }, }, events: { - async signIn({ user }) { - const cookieCartId = cookies().get('cartId')?.value; + async signIn({ user: { customerAccessToken } }) { + const cookieStore = await cookies(); + const cookieCartId = cookieStore.get('cartId')?.value; - if (cookieCartId && user.id) { + if (cookieCartId) { try { await client.fetch({ document: AssignCartToCustomerMutation, @@ -89,7 +87,7 @@ const config = { cartEntityId: cookieCartId, }, }, - customerId: user.id, + customerAccessToken, fetchOptions: { cache: 'no-store', }, @@ -101,20 +99,14 @@ const config = { } }, async signOut(message) { - const cookieCartId = cookies().get('cartId')?.value; + const customerAccessToken = 'token' in message ? message.token?.customerAccessToken : null; - const customerId = 'token' in message ? message.token?.id : null; - - if (customerId && cookieCartId) { + if (customerAccessToken) { try { await client.fetch({ - document: UnassignCartFromCustomerMutation, - variables: { - unassignCartFromCustomerInput: { - cartEntityId: cookieCartId, - }, - }, - customerId, + document: LogoutMutation, + variables: {}, + customerAccessToken, fetchOptions: { cache: 'no-store', }, @@ -143,16 +135,20 @@ const config = { }, }); + if (response.errors && response.errors.length > 0) { + return null; + } + const result = response.data.login; - if (!result.customer) { + if (!result.customer || !result.customerAccessToken) { return null; } return { - id: result.customer.entityId.toString(), name: `${result.customer.firstName} ${result.customer.lastName}`, email: result.customer.email, + customerAccessToken: result.customerAccessToken.value, }; }, }), @@ -161,34 +157,34 @@ const config = { const { handlers, auth, signIn, signOut } = NextAuth(config); -const getSessionCustomerId = async () => { +const getSessionCustomerAccessToken = async () => { try { const session = await auth(); - return session?.user.id; + return session?.customerAccessToken; } catch { // No empty } }; -export { handlers, auth, signIn, signOut, getSessionCustomerId }; +export { handlers, auth, signIn, signOut, getSessionCustomerAccessToken }; declare module 'next-auth' { interface Session { - user: { - id: string; - } & DefaultSession['user']; + user?: DefaultSession['user']; + customerAccessToken?: string; } interface User { - id?: string; name?: string | null; email?: string | null; + customerAccessToken?: string; } } declare module 'next-auth/jwt' { interface JWT { id?: string; + customerAccessToken?: string; } } diff --git a/core/build-config/reader.ts b/core/build-config/reader.ts new file mode 100644 index 000000000..d52642fc5 --- /dev/null +++ b/core/build-config/reader.ts @@ -0,0 +1,16 @@ +import rawBuildConfig from './build-config.json'; +import { buildConfigSchema, BuildConfigSchema } from './schema'; + +class BuildConfig { + private config = buildConfigSchema.parse(rawBuildConfig); + + get(key: K): BuildConfigSchema[K] { + if (key in this.config) { + return this.config[key]; + } + + throw new Error(`Key "${key}" not found in BuildConfig`); + } +} + +export const buildConfig = new BuildConfig(); diff --git a/core/build-config/schema.ts b/core/build-config/schema.ts new file mode 100644 index 000000000..89aac2fab --- /dev/null +++ b/core/build-config/schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const buildConfigSchema = z.object({ + locales: z.array( + z.object({ + code: z.string(), + isDefault: z.boolean(), + }), + ), +}); + +export type BuildConfigSchema = z.infer; diff --git a/core/build-config/writer.ts b/core/build-config/writer.ts new file mode 100644 index 000000000..ca413e159 --- /dev/null +++ b/core/build-config/writer.ts @@ -0,0 +1,27 @@ +/* eslint-disable no-console */ +import { writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { z } from 'zod'; + +import { buildConfigSchema } from './schema'; + +const destinationPath = dirname(fileURLToPath(import.meta.url)); +const CONFIG_FILE = join(destinationPath, 'build-config.json'); + +// This fn is only intended to be used in the build process (next.config.ts) +export async function writeBuildConfig(data: unknown) { + try { + buildConfigSchema.parse(data); + + await writeFile(CONFIG_FILE, JSON.stringify(data), 'utf8'); + } catch (error) { + if (error instanceof z.ZodError) { + console.error('Data validation failed:', error.errors); + } else { + console.error('Error writing build-config.json:', error); + } + + throw error; + } +} diff --git a/core/channels.config.ts b/core/channels.config.ts index a990b0396..88b456343 100644 --- a/core/channels.config.ts +++ b/core/channels.config.ts @@ -1,17 +1,10 @@ -import { type LocaleType } from './i18n/routing'; - -export type RecordFromLocales = { - [K in LocaleType]: string; -}; - // Set overrides per locale -const localeToChannelsMappings: Partial = { +const localeToChannelsMappings: Record = { // es: '12345', }; -function getChannelIdFromLocale(locale?: string) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return localeToChannelsMappings[locale as LocaleType] ?? process.env.BIGCOMMERCE_CHANNEL_ID; +function getChannelIdFromLocale(locale = '') { + return localeToChannelsMappings[locale] ?? process.env.BIGCOMMERCE_CHANNEL_ID; } export { getChannelIdFromLocale }; diff --git a/core/client/index.ts b/core/client/index.ts index 7b8955e99..0c5b72db1 100644 --- a/core/client/index.ts +++ b/core/client/index.ts @@ -1,13 +1,30 @@ import { createClient } from '@bigcommerce/catalyst-client'; import { headers } from 'next/headers'; -import { getLocale } from 'next-intl/server'; - -import { getChannelIdFromLocale } from '~/channels.config'; +import { getLocale as getServerLocale } from 'next-intl/server'; +import { getChannelIdFromLocale } from '../channels.config'; import { backendUserAgent } from '../userAgent'; +const getLocale = async () => { + try { + const locale = await getServerLocale(); + + return locale; + } catch { + /** + * Next-intl `getLocale` only works on the server, and when middleware has run. + * + * Instances when `getLocale` will not work: + * - Requests in middlewares + * - Requests in `generateStaticParams` + * - Request in api routes + * - Requests in static sites without `setRequestLocale` + */ + } +}; + export const client = createClient({ - customerImpersonationToken: process.env.BIGCOMMERCE_CUSTOMER_IMPERSONATION_TOKEN ?? '', + storefrontToken: process.env.BIGCOMMERCE_STOREFRONT_TOKEN ?? '', xAuthToken: process.env.BIGCOMMERCE_ACCESS_TOKEN ?? '', storeHash: process.env.BIGCOMMERCE_STORE_HASH ?? '', channelId: process.env.BIGCOMMERCE_CHANNEL_ID, @@ -16,40 +33,31 @@ export const client = createClient({ (process.env.NODE_ENV !== 'production' && process.env.CLIENT_LOGGER !== 'false') || process.env.CLIENT_LOGGER === 'true', getChannelId: async (defaultChannelId: string) => { - /** - * Next-intl `getLocale` only works on the server, and when middleware has run. - * - * Instances when `getLocale` will not work: - * - Requests in middlewares - * - Requests in `generateStaticParams` - * - Request in api routes - * - Requests in static sites without `unstable_setRequestLocale` - * - * We use the default channelId as a fallback, but it is not ideal in some scenarios. - * */ - try { - const locale = await getLocale(); + const locale = await getLocale(); - return getChannelIdFromLocale(locale) ?? defaultChannelId; - } catch { - // eslint-disable-next-line no-console - console.error('Warning: issue using `getLocale`, using default channel id instead.'); - - return defaultChannelId; - } + // We use the default channelId as a fallback, but it is not ideal in some scenarios. + return getChannelIdFromLocale(locale) ?? defaultChannelId; }, - beforeRequest: (fetchOptions) => { + beforeRequest: async (fetchOptions) => { + // We can't serialize a `Headers` object within this method so we have to opt into using a plain object + const requestHeaders: Record = {}; + const locale = await getLocale(); + if (fetchOptions?.cache && ['no-store', 'no-cache'].includes(fetchOptions.cache)) { - const ipAddress = headers().get('X-Forwarded-For'); + const ipAddress = (await headers()).get('X-Forwarded-For'); if (ipAddress) { - return { - headers: { - 'X-Forwarded-For': ipAddress, - 'True-Client-IP': ipAddress, - }, - }; + requestHeaders['X-Forwarded-For'] = ipAddress; + requestHeaders['True-Client-IP'] = ipAddress; } } + + if (locale) { + requestHeaders['Accept-Language'] = locale; + } + + return { + headers: requestHeaders, + }; }, }); diff --git a/core/client/mutations/add-cart-line-item.ts b/core/client/mutations/add-cart-line-item.ts index b03926278..8280d6469 100644 --- a/core/client/mutations/add-cart-line-item.ts +++ b/core/client/mutations/add-cart-line-item.ts @@ -1,4 +1,4 @@ -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '..'; import { graphql, VariablesOf } from '../graphql'; @@ -22,14 +22,30 @@ export const addCartLineItem = async ( cartEntityId: AddCartLineItemsInput['cartEntityId'], data: AddCartLineItemsInput['data'], ) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); - const response = await client.fetch({ + return await client.fetch({ document: AddCartLineItemMutation, variables: { input: { cartEntityId, data } }, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, }); - - return response.data.cart.addCartLineItems?.cart; }; + +export function assertAddCartLineItemErrors( + response: Awaited>, +): asserts response is Awaited> { + if (typeof response === 'object' && 'errors' in response && Array.isArray(response.errors)) { + response.errors.forEach((error) => { + if (error.message.includes('Not enough stock:')) { + // This removes the item id from the message. It's very brittle, but it's the only + // solution to do it until our API returns a better error message. + throw new Error( + error.message.replace('Not enough stock: ', '').replace(/\(\w.+\)\s{1}/, ''), + ); + } + + throw new Error(error.message); + }); + } +} diff --git a/core/client/mutations/create-cart.ts b/core/client/mutations/create-cart.ts index d46a4282a..8ca9d2e79 100644 --- a/core/client/mutations/create-cart.ts +++ b/core/client/mutations/create-cart.ts @@ -1,4 +1,4 @@ -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '..'; import { graphql, VariablesOf } from '../graphql'; @@ -20,18 +20,34 @@ type CreateCartInput = Variables['createCartInput']; type LineItems = CreateCartInput['lineItems']; export const createCart = async (cartItems: LineItems) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); - const response = await client.fetch({ + return await client.fetch({ document: CreateCartMutation, variables: { createCartInput: { lineItems: cartItems, }, }, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store' }, }); - - return response.data.cart.createCart?.cart; }; + +export function assertCreateCartErrors( + response: Awaited>, +): asserts response is Awaited> { + if (typeof response === 'object' && 'errors' in response && Array.isArray(response.errors)) { + response.errors.forEach((error) => { + if (error.message.includes('Not enough stock:')) { + // This removes the item id from the message. It's very brittle, but it's the only + // solution to do it until our API returns a better error message. + throw new Error( + error.message.replace('Not enough stock: ', '').replace(/\(\w.+\)\s{1}/, ''), + ); + } + + throw new Error(error.message); + }); + } +} diff --git a/core/client/queries/get-cart.ts b/core/client/queries/get-cart.ts index 3f28930b6..1adbebb68 100644 --- a/core/client/queries/get-cart.ts +++ b/core/client/queries/get-cart.ts @@ -1,6 +1,6 @@ import { cache } from 'react'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '..'; import { graphql } from '../graphql'; @@ -122,12 +122,12 @@ const GetCartQuery = graphql( ); export const getCart = cache(async (cartId?: string, channelId?: string) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); const response = await client.fetch({ document: GetCartQuery, variables: { cartId }, - customerId, + customerAccessToken, fetchOptions: { cache: 'no-store', next: { diff --git a/core/client/tags.ts b/core/client/tags.ts index c00ebecbb..4100e8d89 100644 --- a/core/client/tags.ts +++ b/core/client/tags.ts @@ -1,4 +1,5 @@ export const TAGS = { cart: 'cart', checkout: 'checkout', + customer: 'customer', } as const; diff --git a/core/components/add-to-cart-button/index.tsx b/core/components/add-to-cart-button/index.tsx index c852ded2b..1f1885427 100644 --- a/core/components/add-to-cart-button/index.tsx +++ b/core/components/add-to-cart-button/index.tsx @@ -20,9 +20,6 @@ export const AddToCartButton = ({ }) => { const t = useTranslations('Components.AddToCartButton'); - const isProductDisabled = - product.availabilityV2.status === 'Unavailable' || !product.inventory.isInStock; - const buttonText = () => { if (product.availabilityV2.status === 'Unavailable') { return t('unavailable'); @@ -42,7 +39,7 @@ export const AddToCartButton = ({ return (
{validationError && ( - + {t('empty')} )} diff --git a/core/components/form-fields/date.tsx b/core/components/form-fields/date.tsx index 88424b0ed..45f533e25 100644 --- a/core/components/form-fields/date.tsx +++ b/core/components/form-fields/date.tsx @@ -96,7 +96,7 @@ export const DateField = ({ />
{validationError && ( - + {t('empty')} )} diff --git a/core/components/form-fields/multiline-text.tsx b/core/components/form-fields/multiline-text.tsx index cda447707..b2aaf86a4 100644 --- a/core/components/form-fields/multiline-text.tsx +++ b/core/components/form-fields/multiline-text.tsx @@ -52,7 +52,7 @@ export const MultilineText = ({
{field.isRequired && ( {t('empty')} diff --git a/core/components/form-fields/numbers-only.tsx b/core/components/form-fields/numbers-only.tsx index 701a5f343..a6006a1d0 100644 --- a/core/components/form-fields/numbers-only.tsx +++ b/core/components/form-fields/numbers-only.tsx @@ -48,21 +48,21 @@ export const NumbersOnly = ({ defaultValue, field, isValid, name, onChange }: Nu
{field.isRequired && ( {t('empty')} )} {t('numbersOnly')} {Boolean(field.minNumber) && ( {t('numbersUnderflow', { min: field.minNumber })} @@ -70,7 +70,7 @@ export const NumbersOnly = ({ defaultValue, field, isValid, name, onChange }: Nu )} {Boolean(field.maxNumber) && ( {t('numbersOverflow', { max: field.maxNumber })} diff --git a/core/components/form-fields/password.tsx b/core/components/form-fields/password.tsx index 01ab1ca48..06ee523e7 100644 --- a/core/components/form-fields/password.tsx +++ b/core/components/form-fields/password.tsx @@ -48,7 +48,7 @@ export const Password = ({ defaultValue, field, isValid, name, onChange }: Passw
{field.isRequired && ( {t('password')} @@ -56,7 +56,7 @@ export const Password = ({ defaultValue, field, isValid, name, onChange }: Passw )} {fieldName === 'confirmPassword' && ( { return !isValid; }} diff --git a/core/components/form-fields/picklist.tsx b/core/components/form-fields/picklist.tsx index 245cde0cd..be4824f0d 100644 --- a/core/components/form-fields/picklist.tsx +++ b/core/components/form-fields/picklist.tsx @@ -85,7 +85,7 @@ export const Picklist = ({
{validationError && ( - + {t('empty')} )} diff --git a/core/components/form-fields/radio-buttons.tsx b/core/components/form-fields/radio-buttons.tsx index 80d044e40..013f5dd8e 100644 --- a/core/components/form-fields/radio-buttons.tsx +++ b/core/components/form-fields/radio-buttons.tsx @@ -56,7 +56,7 @@ export const RadioButtons = ({ />
{validationError && ( - + {t('empty')} )} diff --git a/core/components/form-fields/shared/field-wrapper.tsx b/core/components/form-fields/shared/field-wrapper.tsx index cb85c4795..93e5b06ad 100644 --- a/core/components/form-fields/shared/field-wrapper.tsx +++ b/core/components/form-fields/shared/field-wrapper.tsx @@ -2,14 +2,11 @@ import { PropsWithChildren } from 'react'; import { FieldNameToFieldId } from '../utils'; -const LAYOUT_SINGLE_LINE_FIELDS = [ - FieldNameToFieldId.email, - FieldNameToFieldId.company, - FieldNameToFieldId.phone, -]; +const LAYOUT_HALF_OF_SINGLE_LINE_FIELDS = [FieldNameToFieldId.company, FieldNameToFieldId.phone]; +const LAYOUT_SINGLE_LINE_FIELDS = [FieldNameToFieldId.email]; export const FieldWrapper = ({ children, fieldId }: { fieldId: number } & PropsWithChildren) => { - if (LAYOUT_SINGLE_LINE_FIELDS.includes(fieldId)) { + if (LAYOUT_HALF_OF_SINGLE_LINE_FIELDS.includes(fieldId)) { return (
{children} @@ -17,5 +14,9 @@ export const FieldWrapper = ({ children, fieldId }: { fieldId: number } & PropsW ); } + if (LAYOUT_SINGLE_LINE_FIELDS.includes(fieldId)) { + return
{children}
; + } + return children; }; diff --git a/core/components/form-fields/shared/parse-fields.ts b/core/components/form-fields/shared/parse-fields.ts index 5e8c84a0a..bd3434ddb 100644 --- a/core/components/form-fields/shared/parse-fields.ts +++ b/core/components/form-fields/shared/parse-fields.ts @@ -19,7 +19,6 @@ type FormFieldsType = VariablesOf['input']['formFields']; interface ReturnedFormData { [k: string]: unknown; - address: Record; formFields: Record; } @@ -225,24 +224,7 @@ export const parseRegisterCustomerFormData = (registerFormData: FormData): unkno }); } - if (sections.includes('address')) { - parsedData.address[key] = value; - } - - if (sections.some((section) => section.startsWith('custom_address'))) { - const fields = updateFormFields({ - formFields: isFormFieldsType(parsedData.address.formFields) - ? parsedData.address.formFields - : null, - fieldType: sections[1] ?? '', - fieldEntityId: Number(key), - fieldValue: value, - }); - - parsedData.address = { ...parsedData.address, formFields: { ...fields } }; - } - return parsedData; }, - { formFields: {}, address: {} }, + { formFields: {} }, ); diff --git a/core/components/form-fields/text.tsx b/core/components/form-fields/text.tsx index 2566e6faa..4e9257025 100644 --- a/core/components/form-fields/text.tsx +++ b/core/components/form-fields/text.tsx @@ -47,7 +47,7 @@ export const Text = ({ defaultValue, field, isValid, name, onChange, type }: Tex
{field.isRequired && ( {t(fieldName ?? 'empty')} @@ -55,7 +55,7 @@ export const Text = ({ defaultValue, field, isValid, name, onChange, type }: Tex )} {fieldName === 'email' && ( {t('email')} diff --git a/core/components/form-fields/utils.ts b/core/components/form-fields/utils.ts index e86468e63..ecca3d400 100644 --- a/core/components/form-fields/utils.ts +++ b/core/components/form-fields/utils.ts @@ -47,6 +47,8 @@ export const BOTH_CUSTOMER_ADDRESS_FIELDS = [ FieldNameToFieldId.phone, ]; +export const FULL_NAME_FIELDS = [FieldNameToFieldId.firstName, FieldNameToFieldId.lastName]; + export const createFieldName = ( field: FragmentOf, fieldOrigin: 'customer' | 'address', diff --git a/core/components/header/_actions/get-search-results.ts b/core/components/header/_actions/get-search-results.ts index 920f48914..e5b4ce531 100644 --- a/core/components/header/_actions/get-search-results.ts +++ b/core/components/header/_actions/get-search-results.ts @@ -3,7 +3,7 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; -import { getSessionCustomerId } from '~/auth'; +import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; @@ -39,14 +39,14 @@ const GetQuickSearchResultsQuery = graphql( ); export const getSearchResults = cache(async (searchTerm: string) => { - const customerId = await getSessionCustomerId(); + const customerAccessToken = await getSessionCustomerAccessToken(); try { const response = await client.fetch({ document: GetQuickSearchResultsQuery, variables: { filters: { searchTerm } }, - customerId, - fetchOptions: customerId ? { cache: 'no-store' } : { next: { revalidate } }, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, }); const { products } = response.data.site.search.searchProducts; diff --git a/core/components/header/_actions/logout.ts b/core/components/header/_actions/logout.ts index 5e5bae528..4100ce5e0 100644 --- a/core/components/header/_actions/logout.ts +++ b/core/components/header/_actions/logout.ts @@ -1,10 +1,14 @@ 'use server'; +import { getLocale } from 'next-intl/server'; + import { signOut } from '~/auth'; import { redirect } from '~/i18n/routing'; export const logout = async () => { + const locale = await getLocale(); + await signOut({ redirect: false }); - redirect('/login'); + redirect({ href: '/login', locale }); }; diff --git a/core/components/header/cart-icon.tsx b/core/components/header/cart-icon.tsx index de1fca09d..787f5be7a 100644 --- a/core/components/header/cart-icon.tsx +++ b/core/components/header/cart-icon.tsx @@ -1,7 +1,7 @@ 'use client'; import { ShoppingCart } from 'lucide-react'; -import { useLocale } from 'next-intl'; +import { useLocale, useTranslations } from 'next-intl'; import { useEffect } from 'react'; import { z } from 'zod'; @@ -20,6 +20,7 @@ interface CartIconProps { export const CartIcon = ({ count: serverCount }: CartIconProps) => { const { count, setCount } = useCart(); const locale = useLocale(); + const t = useTranslations('Components.Header.MiniCart'); useEffect(() => { async function fetchCartQuantity() { @@ -40,12 +41,12 @@ export const CartIcon = ({ count: serverCount }: CartIconProps) => { }, [serverCount, locale, setCount]); if (!count) { - return ; + return ; } return ( <> - Cart Items + {t('items')}
), - { icon: }, + { icon: , id: toastId }, ); startTransition(async () => { @@ -60,8 +61,9 @@ export const Form = ({ data: product }: Props) => { if (result.error) { cart.decrement(quantity); - toast.error(t('error'), { + toast.error(result.error, { icon: , + id: toastId, }); } }); diff --git a/core/components/search-form/index.tsx b/core/components/search-form/index.tsx index 140c0c03a..8180cf966 100644 --- a/core/components/search-form/index.tsx +++ b/core/components/search-form/index.tsx @@ -16,7 +16,7 @@ export const SearchForm = ({ initialTerm = '' }: Props) => {
{initialTerm ? (

- {t('noSearchResults', { term: `"${initialTerm}"` })} + {t('noSearchResults', { term: initialTerm })}

) : (

{t('searchProducts')}

diff --git a/core/components/store-logo/index.tsx b/core/components/store-logo/index.tsx index a62eea5f4..f29e99efd 100644 --- a/core/components/store-logo/index.tsx +++ b/core/components/store-logo/index.tsx @@ -1,6 +1,5 @@ import { FragmentOf } from '~/client/graphql'; - -import { BcImage } from '../bc-image'; +import { Image } from '~/components/image'; import { StoreLogoFragment } from './fragment'; @@ -16,7 +15,7 @@ export const StoreLogo = ({ data }: Props) => { } return ( - - { + const t = useTranslations('Components.Breadcrumb'); + return ( -
( @@ -34,7 +38,7 @@ const Product = ({ product, onDismiss }: { product: Product; onDismiss: () => vo key={product.id} > {product.image ? ( - ( -
-
- -
- {Boolean(logo) && ( -

- {typeof logo === 'object' ? ( - - ) : ( - {logo} - )} -

- )} - {Boolean(contactInformation) && ( - <> -
- {contactInformation?.address?.split('\n').map((line) => ( - - {line} -
-
- ))} -
- {Boolean(contactInformation?.phone) && ( - -

{contactInformation?.phone}

-
- )} - - )} - {Boolean(socialMediaLinks) && ( - - )} -
-
-
- +}: Props) => { + const t = useTranslations('Components.Footer'); -
-
{paymentIcons}
-

{copyright}

-
-
- -
-

{copyright}

-
+ return ( +
+
+ +
+ {Boolean(logo) && ( +

+ {typeof logo === 'object' ? ( + {logo.altText} + ) : ( + {logo} + )} +

+ )} + {Boolean(contactInformation) && ( + <> +
+ {contactInformation?.address?.split('\n').map((line) => ( + + {line} +
+
+ ))} +
+ {Boolean(contactInformation?.phone) && ( + +

{contactInformation?.phone}

+
+ )} + + )} + {Boolean(socialMediaLinks) && ( + + )} +
+
+
-
{paymentIcons}
-
-
-
-); + +
+
{paymentIcons}
+

{copyright}

+
+ + +
+

{copyright}

+
+ +
{paymentIcons}
+
+
+ + ); +}; Footer.displayName = 'Footer'; diff --git a/core/components/ui/form/checkbox/checkbox.tsx b/core/components/ui/form/checkbox/checkbox.tsx index bf8295ba8..d298d8ad4 100644 --- a/core/components/ui/form/checkbox/checkbox.tsx +++ b/core/components/ui/form/checkbox/checkbox.tsx @@ -1,6 +1,6 @@ import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; import { Check } from 'lucide-react'; -import { ComponentPropsWithRef, ElementRef, forwardRef } from 'react'; +import { ComponentPropsWithRef, ComponentRef, forwardRef } from 'react'; import { cn } from '~/lib/utils'; @@ -8,7 +8,7 @@ interface Props extends ComponentPropsWithRef { error?: boolean; } -const Checkbox = forwardRef, Props>( +const Checkbox = forwardRef, Props>( ({ className, defaultChecked, error = false, onCheckedChange, ...props }, ref) => { return ( return defaultValue; }; -type CounterRef = ElementRef<'input'> | null; +type CounterRef = ComponentRef<'input'> | null; -const Counter = forwardRef, Props>( +const Counter = forwardRef, Props>( ( { children, @@ -56,6 +57,7 @@ const Counter = forwardRef, Props>( ) => { const [value, setValue] = useState(getDefaultValue(defaultValue, min, max)); const inputRef = useRef(null); + const t = useTranslations('Components.FormFields.Counter'); useImperativeHandle(ref, () => inputRef.current); @@ -101,7 +103,7 @@ const Counter = forwardRef, Props>(