diff --git a/.changeset/chatty-forks-sniff.md b/.changeset/chatty-forks-sniff.md new file mode 100644 index 0000000000..b4df57e774 --- /dev/null +++ b/.changeset/chatty-forks-sniff.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Implement Runtime Cache API as replacement for KV adapter diff --git a/.changeset/cold-buckets-watch.md b/.changeset/cold-buckets-watch.md new file mode 100644 index 0000000000..08664c3efa --- /dev/null +++ b/.changeset/cold-buckets-watch.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Remove unused exports from core diff --git a/.changeset/config.json b/.changeset/config.json index 6a36474769..b63b8a0935 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -11,7 +11,7 @@ "version": true, "tag": true }, - "baseBranch": "main", + "baseBranch": "integrations/makeswift", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": ["@bigcommerce/catalyst"] } diff --git a/.changeset/cruel-wings-shout.md b/.changeset/cruel-wings-shout.md new file mode 100644 index 0000000000..d2875aaf1c --- /dev/null +++ b/.changeset/cruel-wings-shout.md @@ -0,0 +1,8 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +fix: resolve maintenance page width issues + +- Add w-full classes to ensure proper width expansion +- Remove flex-1 in favor of w-full for column layout \ No newline at end of file diff --git a/.changeset/eleven-hornets-report.md b/.changeset/eleven-hornets-report.md new file mode 100644 index 0000000000..a90a158f06 --- /dev/null +++ b/.changeset/eleven-hornets-report.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Remove unused dependencies. diff --git a/.changeset/floppy-pans-return.md b/.changeset/floppy-pans-return.md new file mode 100644 index 0000000000..213f4fd429 --- /dev/null +++ b/.changeset/floppy-pans-return.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Adds the product count to the facet label if the facet provides the count. This also fixes an issue where the facets weren't respecting the collapse by default setting. diff --git a/.changeset/fruity-lands-smash.md b/.changeset/fruity-lands-smash.md new file mode 100644 index 0000000000..3f7dc6baa1 --- /dev/null +++ b/.changeset/fruity-lands-smash.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Remove unused UI files. diff --git a/.changeset/loose-parrots-exist.md b/.changeset/loose-parrots-exist.md new file mode 100644 index 0000000000..3ca76da543 --- /dev/null +++ b/.changeset/loose-parrots-exist.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Remove unused export types from core. diff --git a/.changeset/pretty-colts-obey.md b/.changeset/pretty-colts-obey.md new file mode 100644 index 0000000000..99ab2a68d5 --- /dev/null +++ b/.changeset/pretty-colts-obey.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Add graceful error handling for invalid anonymous JWT cookies diff --git a/.changeset/rude-pillows-itch.md b/.changeset/rude-pillows-itch.md new file mode 100644 index 0000000000..f3cbb90cb4 --- /dev/null +++ b/.changeset/rude-pillows-itch.md @@ -0,0 +1,9 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Remove recpatcha code until we're ready to add it at a later point (if needed). + +## Migration +- A lot of the code removed was just old commented out blocks. +- Remove any recaptcha mention from graphql mutation and queries diff --git a/.changeset/translations-patch-61802c24.md b/.changeset/translations-patch-61802c24.md new file mode 100644 index 0000000000..ad17b2636a --- /dev/null +++ b/.changeset/translations-patch-61802c24.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Update translations. diff --git a/.changeset/translations-patch-ab0af33f.md b/.changeset/translations-patch-ab0af33f.md new file mode 100644 index 0000000000..ad17b2636a --- /dev/null +++ b/.changeset/translations-patch-ab0af33f.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Update translations. diff --git a/.env.example b/.env.example index bacc5581f5..3647a7c116 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,24 @@ +# Makeswift Site API Key +# In the Makeswift builder, go to Settings > Host and copy the API key for the site. +MAKESWIFT_SITE_API_KEY= + # The hash visible in the subject store's URL when signed in to the store control panel. -# The control panel URL is of the form `https://store-{hash}.mybigcommerce.com`. +# The control panel URL is of the form `https://store-{hash}.mybigcommerce.com`. BIGCOMMERCE_STORE_HASH= # 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= +# A store-level API account token used for REST API actions. Optional by default, but required in +# order for some components to work properly. For example, the `CustomerGroupSlot` Makeswift component +# requires a `BIGCOMMERCE_ACCESS_TOKEN` with `read-only` scope on Customers. +# See https://support.bigcommerce.com/s/article/Store-API-Accounts?language=en_US +BIGCOMMERCE_ACCESS_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, -# but it is strongly recommended to create a new channel instead for production. +# but it is strongly recommended to create a new channel instead for production. # The CLI can do this for you. BIGCOMMERCE_CHANNEL_ID=1 diff --git a/.env.local b/.env.local new file mode 100644 index 0000000000..3eb798dd3a --- /dev/null +++ b/.env.local @@ -0,0 +1,21 @@ +# === MXPlantae Catalyst local config === +# Rellena estos valores con los de tu BigCommerce y MakeSwift + +# BigCommerce +BIGCOMMERCE_STORE_HASH=store_1d8l3jduoc +BIGCOMMERCE_STOREFRONT_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJjaWQiOltdLCJjb3JzIjpbXSwiZWF0IjoyMTQ3NDgzNjQ3LCJpYXQiOjE3NTY2NjY3MTUsImlzcyI6IkJDIiwic2lkIjoxMDAyMTA2NzIyLCJzdWIiOiJzMXE0aW83bWFoMmxtMWk2dXdwOXlsMWVpdDgwbjNiIiwic3ViX3R5cGUiOjIsInRva2VuX3R5cGUiOjF9.rK0JefAJcTE1Jr0nKnKn0_PIv_HkzWXrjsQzN3z4_Qrbd4K7WNCfexci7uJoY_3O_ECKPfpyDqO4tpflF-xRfg +BIGCOMMERCE_CHANNEL_ID=1780930 + +# Opcional: si usas /admin redirect +ENABLE_ADMIN_ROUTE=true + +# Auth (NextAuth/Auth.js) +AUTH_SECRET=c387ef5e77095374b2904fcb66417ab5b690f0df7b3099962f23ee9278284e3458418bfe82eb49a417f23849b9c357208f8918e09b1041605b5caba93a3572fc +# NEXTAUTH_URL=http://localhost:3000 + +# MakeSwift (si vas a conectar páginas desde MakeSwift) +MAKESWIFT_SITE_API_KEY=c5158450-d593-404d-b926-4bf0236f8184 +# MAKESWIFT_BASE_URL=https://app.makeswift.com + +# Cache recomendado +DEFAULT_REVALIDATE_TARGET=3600 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index eaea5b59cd..c3d79ef50e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @bigcommerce/team-catalyst +* @bigcommerce/team-catalyst @bigcommerce/makeswift diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 86c1f37310..9033d93b59 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: false contact_links: - name: Have questions? - url: https://github.com/bigcommerce/catalyst/blob/main/README.md + url: https://github.com/bigcommerce/catalyst/blob/canary/README.md about: Explore the Catalyst Docs. - name: Need help with Catalyst? url: https://github.com/bigcommerce/catalyst/discussions/new?category=q-a diff --git "a/.github/ISSUE_TEMPLATE/\360\237\220\236\360\237\223\235-bug-report-makeswift.md" "b/.github/ISSUE_TEMPLATE/\360\237\220\236\360\237\223\235-bug-report-makeswift.md" new file mode 100644 index 0000000000..98e7dcf03d --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\220\236\360\237\223\235-bug-report-makeswift.md" @@ -0,0 +1,37 @@ +--- +name: "\U0001F41E\U0001F4DD Makeswift Bug report" +about: You're running into a reproducible error while developing with Catalyst and Makeswift. +title: '[x] is not working when I [y]' +labels: '' +assignees: '' +--- + +We really appreciate the help making Catalyst and Makeswift better. Every issue helps! + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +Please link to a repo that can be used to reproduce this issue, if possible. It'll help fix the bug faster. + +**Previously working?** +Was this functionality previously working? If so, please link to a commit or PR that caused it to stop working. + +**Any Errors?** +Were there any errors that surfaced when merging the above PR? + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 870d5ddca4..4fa108d4f5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,8 +15,6 @@ updates: update-types: ['version-update:semver-major'] - dependency-name: 'eslint' update-types: ['version-update:semver-major'] - - dependency-name: 'react-day-picker' + # Disabling tailwind due to browser compatibility constraints. + - dependency-name: 'tailwindcss' 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/pull_request_template.md b/.github/pull_request_template.md index 5514ac106a..ff3953f6ad 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,4 +9,10 @@ \ No newline at end of file +---> + +## Migration + diff --git a/.github/scripts/prevent-invalid-changesets.js b/.github/scripts/prevent-invalid-changesets.js new file mode 100644 index 0000000000..c707b86651 --- /dev/null +++ b/.github/scripts/prevent-invalid-changesets.js @@ -0,0 +1,108 @@ +const fs = require("fs"); + +module.exports = async ({ core, exec }) => { + try { + await exec.exec("git", [ + "fetch", + "https://github.com/bigcommerce/catalyst.git", + "integrations/makeswift", + ]); + + const { stdout } = await exec.getExecOutput("git", [ + "diff", + "--name-only", + `origin/integrations/makeswift...HEAD`, + ]); + + const allFilenames = stdout.split("\n").filter((line) => line.trim()); + const changesetFilenames = allFilenames.filter( + (file) => file.startsWith(".changeset/") && file.endsWith(".md") + ); + + if (changesetFilenames.length === 0) { + core.info("No changeset files found to validate"); + return; + } + + core.info(`Found ${changesetFilenames.length} changeset files to validate`); + + for (const filename of changesetFilenames) { + core.info(`Checking ${filename}...`); + + // .changeset/*.md filenames should only contain alphanumeric characters, hyphens, and underscores + if (!/^\.changeset\/[a-zA-Z0-9_-]+\.md$/.test(filename)) { + core.setFailed(`Invalid filename pattern: ${filename}`); + return; + } + + // extra defense against path traversal attacks + if ( + filename.includes("..") || + (filename.includes("/") && !filename.startsWith(".changeset/")) + ) { + core.setFailed(`Suspicious file path: ${filename}`); + return; + } + + if (!fs.existsSync(filename)) { + core.setFailed(`File not found: ${filename}`); + return; + } + + // check file size (limit to 100KB) + const stats = fs.statSync(filename); + if (stats.size > 102400) { + core.error(`File too large`, { file: filename }); + core.setFailed(`File ${filename} is too large`); + return; + } + + if (stats.isSymbolicLink()) { + core.error(`Symlinks are not allowed`, { file: filename }); + core.setFailed(`File ${filename} is a symlink`); + return; + } + + const content = fs.readFileSync(filename, "utf8"); + + // starts with "---", captures everything until the next "---" + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + + if (!frontmatterMatch) { + core.error(`Failed to extract frontmatter or file has no frontmatter`, { + file: filename, + }); + core.setFailed(`File ${filename} has invalid or missing frontmatter`); + return; + } + + const frontmatter = frontmatterMatch[1]; + + // extract all packages starting with "@bigcommerce/ + const packageMatches = frontmatter.match(/"@bigcommerce\/[^"]+"/g); + + if (packageMatches) { + const invalidPackages = packageMatches.filter( + (pkg) => pkg !== '"@bigcommerce/catalyst-makeswift"' + ); + + if (invalidPackages.length > 0) { + core.error( + `Invalid package found in changeset file. Only @bigcommerce/catalyst-makeswift is allowed.`, + { file: filename } + ); + core.setFailed( + `File ${filename} contains invalid packages: ${invalidPackages.join( + ", " + )}` + ); + return; + } + } + } + + core.info("All changeset files validated successfully"); + } catch (error) { + core.setFailed(`Validation failed: ${error.message}`); + } +}; diff --git a/.github/workflows/.lighthouserc-desktop.json b/.github/workflows/.lighthouserc-desktop.json deleted file mode 100644 index fc47cda37f..0000000000 --- a/.github/workflows/.lighthouserc-desktop.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "ci": { - "collect": { - "settings": { - "preset": "desktop" - } - } - } -} diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index 965c030e6e..c56b656b50 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -2,7 +2,7 @@ name: Basic on: push: - branches: [main] + branches: [canary] pull_request: types: [opened, synchronize] merge_group: @@ -33,8 +33,8 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version-file: '.nvmrc' - cache: 'pnpm' + node-version-file: ".nvmrc" + cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile @@ -48,10 +48,13 @@ jobs: - name: Typecheck run: pnpm run typecheck - unit-tests: - name: CLI Unit Tests + cli-tests: + name: CLI Tests - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - name: Checkout code @@ -71,5 +74,4 @@ jobs: run: pnpm install --frozen-lockfile - name: Run Tests - run: pnpm test - working-directory: packages/create-catalyst + run: pnpm run test diff --git a/.github/workflows/changesets-release.yml b/.github/workflows/changesets-release.yml index 2f6f7f6938..251741f70a 100644 --- a/.github/workflows/changesets-release.yml +++ b/.github/workflows/changesets-release.yml @@ -3,7 +3,8 @@ name: Changesets Release on: push: branches: - - main + - canary + - integrations/makeswift concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -37,6 +38,8 @@ jobs: uses: changesets/action@v1 with: publish: pnpm exec changeset publish + title: "Version Packages (`${{ github.ref_name }}`)" + commit: "Version Packages (`${{ github.ref_name }}`)" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/lighthouseCommentMaker.js b/.github/workflows/lighthouseCommentMaker.js deleted file mode 100644 index d6fafd1e82..0000000000 --- a/.github/workflows/lighthouseCommentMaker.js +++ /dev/null @@ -1,61 +0,0 @@ -// @ts-check - -/** - * @typedef {Object} Summary - * @prop {number} performance - * @prop {number} accessibility - * @prop {number} best-practices - * @prop {number} seo - * @prop {number} pwa - */ - -/** - * @typedef {Object} Manifest - * @prop {string} url - * @prop {boolean} isRepresentativeRun - * @prop {string} htmlPath - * @prop {string} jsonPath - * @prop {Summary} summary - */ - -/** - * @typedef {Object} LighthouseOutputs - * @prop {Record} links - * @prop {Manifest[]} manifest - * @prop {string} preset - */ - -const formatScore = (/** @type { number } */ score) => Math.round(score * 100); -const emojiScore = (/** @type { number } */ score) => - score >= 0.9 ? '🟢' : score >= 0.5 ? '🟠' : '🔴'; - -const scoreRow = ( - /** @type { string } */ label, - /** @type { number } */ score -) => `| ${emojiScore(score)} ${label} | ${formatScore(score)} |`; - -/** - * @param {LighthouseOutputs} lighthouseOutputs - */ -function makeComment(lighthouseOutputs) { - const { summary } = lighthouseOutputs.manifest[2]; - const [[testedUrl, reportUrl]] = Object.entries(lighthouseOutputs.links); - const preset = lighthouseOutputs.preset; - - const comment = `We ran Lighthouse against the changes on a ${preset} and produced this [report](${reportUrl}). Here's the summary: - -| Category | Score | -| -------- | ----- | -${scoreRow('Performance', summary.performance)} -${scoreRow('Accessibility', summary.accessibility)} -${scoreRow('Best practices', summary['best-practices'])} -${scoreRow('SEO', summary.seo)} - -`; - - return comment; -} - -module.exports = ({ lighthouseOutputs }) => { - return makeComment(lighthouseOutputs); -}; diff --git a/.github/workflows/prevent-invalid-changesets.yml b/.github/workflows/prevent-invalid-changesets.yml new file mode 100644 index 0000000000..be99c8c55c --- /dev/null +++ b/.github/workflows/prevent-invalid-changesets.yml @@ -0,0 +1,27 @@ +name: Prevent invalid packages for Changesets + +on: + pull_request: + branches: + - integrations/makeswift + +permissions: + contents: read + pull-requests: read + +jobs: + validate-changesets: + runs-on: ubuntu-latest + name: Validate Changeset Packages + steps: + - name: Checkout PR code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate changesets only target @bigcommerce/catalyst-makeswift + uses: actions/github-script@v7 + with: + script: | + const script = require('./.github/scripts/prevent-invalid-changesets.js') + await script({ core, exec }) diff --git a/.github/workflows/pull-request-data.js b/.github/workflows/pull-request-data.js deleted file mode 100644 index 14d941f02c..0000000000 --- a/.github/workflows/pull-request-data.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = async ({ github, context, core }) => { - const result = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: context.sha, - }); - - core.setOutput('pr', result?.data[0]?.number || ''); - core.setOutput('title', result?.data[0]?.title || ''); - core.setOutput('url', result?.data[0]?.html_url || ''); - core.setOutput('draft', `${result?.data[0]?.draft}` || 'true'); -} diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/regression-tests.yml index 56aaa8253f..afcbc46c2f 100644 --- a/.github/workflows/regression-tests.yml +++ b/.github/workflows/regression-tests.yml @@ -5,224 +5,40 @@ on: states: ['success'] env: - PLAYWRIGHT_TEST_BASE_URL: ${{ github.event.deployment_status.target_url }} - VERCEL_PROTECTION_BYPASS: ${{ secrets.VERCEL_PROTECTION_BYPASS_CATALYST_LATEST }} - BIGCOMMERCE_ACCESS_TOKEN: ${{ secrets.BIGCOMMERCE_ACCESS_TOKEN }} - BIGCOMMERCE_STORE_HASH: ${{ secrets.BIGCOMMERCE_STORE_HASH }} + VERCEL_PROTECTION_BYPASS: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} -jobs: - generate-lighthouse-audit: - name: Lighthouse Audit - timeout-minutes: 30 - runs-on: ubuntu-latest - if: ${{ contains(fromJson('["Production – catalyst-latest", "Preview – catalyst-latest"]'), github.event.deployment_status.environment) }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Pull Request Details - id: pr_details - uses: actions/github-script@v7 - with: - script: | - const prData = require('./.github/workflows/pull-request-data.js'); - await prData({ github, context, core }); - - - name: Lighthouse house audit on desktop - id: lighthouse_audit_desktop - uses: treosh/lighthouse-ci-action@v11 - with: - urls: | - ${{ github.event.deployment_status.target_url }} - configPath: '.github/workflows/.lighthouserc-desktop.json' - temporaryPublicStorage: true - runs: 3 - - - name: Lighthouse audit on mobile - id: lighthouse_audit_mobile - uses: treosh/lighthouse-ci-action@v11 - with: - urls: | - ${{ github.event.deployment_status.target_url }} - temporaryPublicStorage: true - runs: 3 +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.deployment_status.target_url }} + cancel-in-progress: true - - uses: pnpm/action-setup@v3 - - name: Format lighthouse score on desktop - id: format_lighthouse_score_desktop - uses: actions/github-script@v7 - with: - script: | - const lighthouseCommentMaker = require('./.github/workflows/lighthouseCommentMaker.js'); - - const lighthouseOutputs = { - manifest: ${{ steps.lighthouse_audit_desktop.outputs.manifest }}, - links: ${{ steps.lighthouse_audit_desktop.outputs.links }}, - preset: "desktop" - }; - - const comment = lighthouseCommentMaker({ lighthouseOutputs }); - core.setOutput("comment", comment); - - - name: Format lighthouse score on mobile - id: format_lighthouse_score_mobile - uses: actions/github-script@v7 - with: - script: | - const lighthouseCommentMaker = require('./.github/workflows/lighthouseCommentMaker.js'); - - const lighthouseOutputs = { - manifest: ${{ steps.lighthouse_audit_mobile.outputs.manifest }}, - links: ${{ steps.lighthouse_audit_mobile.outputs.links }}, - preset: "mobile" - }; - - const comment = lighthouseCommentMaker({ lighthouseOutputs }); - core.setOutput("comment", comment); - - - name: Add comment to PR - id: comment_to_pr - uses: marocchino/sticky-pull-request-comment@v2.9.0 - if: ${{ steps.pr_details.outputs.pr }} - with: - recreate: true - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - number: ${{ steps.pr_details.outputs.pr }} - header: lighthouse - message: | - # ⚡️🏠 Lighthouse report - - *Lighthouse ran against ${{ github.event.deployment_status.target_url }}* - - ## 🖥️ Desktop - - ${{ steps.format_lighthouse_score_desktop.outputs.comment }} - - ## 📱 Mobile - - ${{ steps.format_lighthouse_score_mobile.outputs.comment }} - - ui-tests: - name: Playwright UI Tests - timeout-minutes: 30 +jobs: + unlighthouse-audit: + if: ${{ contains(fromJson('["Production – catalyst-canary", "Preview – catalyst-canary"]'), github.event.deployment_status.environment) }} + name: Unlighthouse Audit - ${{ matrix.device }} runs-on: ubuntu-latest - if: ${{ contains(fromJson('["Production – catalyst-latest", "Preview – catalyst-latest"]'), github.event.deployment_status.environment) }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v3 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Pull Request Details - id: pr_details - uses: actions/github-script@v7 - with: - script: | - const prData = require('./.github/workflows/pull-request-data.js'); - await prData({ github, context, core }); - - - name: Install Playwright Browsers - run: | - cd core - npx playwright install --with-deps - - - name: Run Playwright tests - run: | - cd core - npx playwright test tests/ui/ --project=tests-chromium - - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: playwright-report-ui - path: core/playwright-report/ - retention-days: 30 - - - name: Send slack notification - uses: slackapi/slack-github-action@v1.26.0 - if: ${{ steps.pr_details.outputs.draft != 'true' && steps.pr_details.outputs.pr && failure() }} - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - with: - payload: | - { - "Job": "${{ github.job }}", - "Status": "Failed", - "Environment": "${{ github.event.deployment_status.environment }}", - "Pull_Request": "${{ steps.pr_details.outputs.url }}", - "Commit_Message" : "${{ steps.pr_details.outputs.title }}", - "Job_Run": "https://github.com/bigcommerce/catalyst/actions/runs/${{ github.run_id }}" - } - - visual-regression-tests: - name: Playwright Visual Regression Tests - timeout-minutes: 30 - runs-on: macos-14 - if: ${{ contains(fromJson('["Production – catalyst-latest", "Preview – catalyst-latest"]'), github.event.deployment_status.environment) }} + strategy: + matrix: + device: [desktop, mobile] + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.deployment_status.target_url }}-${{ matrix.device }} + cancel-in-progress: true steps: - name: Checkout code uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v3 - - - name: Use Node.js - uses: actions/setup-node@v4 with: - node-version-file: '.nvmrc' - cache: 'pnpm' + fetch-depth: 0 - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Install Dependencies + run: npm install @unlighthouse/cli puppeteer -g - - name: Pull Request Details - id: pr_details - uses: actions/github-script@v7 - with: - script: | - const prData = require('./.github/workflows/pull-request-data.js'); - await prData({ github, context, core }); - - - name: Install Playwright Browsers - run: | - cd core - npx playwright install chromium - - - name: Run Playwright tests - run: | - cd core - npx playwright test tests/visual-regression/components/ --project=tests-chromium - - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: playwright-report-visual-regression - path: core/playwright-report/ - retention-days: 30 + - name: Unlighthouse audit on ${{ matrix.device }} + run: unlighthouse-ci --site ${{ github.event.deployment_status.target_url }} --${{ matrix.device }} --disable-robots-txt --extra-headers x-vercel-protection-bypass=$VERCEL_PROTECTION_BYPASS,x-vercel-set-bypass-cookie=true - - name: Send slack notification - uses: slackapi/slack-github-action@v1.26.0 - if: ${{ steps.pr_details.outputs.draft != 'true' && steps.pr_details.outputs.pr && failure() }} - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + - name: Upload ${{ matrix.device }} audit + if: failure() || success() + uses: actions/upload-artifact@v4 with: - payload: | - { - "Job": "${{ github.job }}", - "Status": "Failed", - "Environment": "${{ github.event.deployment_status.environment }}", - "Pull_Request": "${{ steps.pr_details.outputs.url }}", - "Commit_Message" : "${{ steps.pr_details.outputs.title }}", - "Job_Run": "https://github.com/bigcommerce/catalyst/actions/runs/${{ github.run_id }}" - } + name: unlighthouse-${{ matrix.device }}-report + path: './.unlighthouse/' + include-hidden-files: 'true' diff --git a/.github/workflows/translations-changeset.yml b/.github/workflows/translations-changeset.yml index 84cacf3a50..ce622d8c5a 100644 --- a/.github/workflows/translations-changeset.yml +++ b/.github/workflows/translations-changeset.yml @@ -5,7 +5,7 @@ on: types: - opened branches: - - main + - canary jobs: create-translations-patch: @@ -48,4 +48,4 @@ jobs: env: TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} HEAD:${{ github.event.pull_request.head.ref }} + git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} HEAD:$GITHUB_HEAD_REF diff --git a/.gitignore b/.gitignore index ae0b6b61f6..5f473bb15a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,14 +3,22 @@ dist .turbo .vscode/**/* !.vscode/settings.example.json +!.vscode/launch.example.json .idea -.vercel +. .catalyst .env .env*.local +.env*.test test-results/ playwright-report/ playwright/.cache/ +.tests bigcommerce.graphql bigcommerce-graphql.d.ts .DS_Store +coverage/ +.history +.unlighthouse +.bigcommerce +.vercel diff --git a/.nvmrc b/.nvmrc index 209e3ef4b6..2bd5a0a98a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +22 diff --git a/.vercel/README.txt b/.vercel/README.txt new file mode 100644 index 0000000000..525d8ce8ef --- /dev/null +++ b/.vercel/README.txt @@ -0,0 +1,11 @@ +> Why do I have a folder named ".vercel" in my project? +The ".vercel" folder is created when you link a directory to a Vercel project. + +> What does the "project.json" file contain? +The "project.json" file contains: +- The ID of the Vercel project that you linked ("projectId") +- The ID of the user or team your Vercel project is owned by ("orgId") + +> Should I commit the ".vercel" folder? +No, you should not share the ".vercel" folder with anyone. +Upon creation, it will be automatically added to your ".gitignore" file. diff --git a/.vercel/output/builds.json b/.vercel/output/builds.json new file mode 100644 index 0000000000..d5f50826cd --- /dev/null +++ b/.vercel/output/builds.json @@ -0,0 +1,36 @@ +{ + "//": "This file was generated by the `vercel build` command. It is not part of the Build Output API.", + "target": "preview", + "argv": [ + "C:\\Users\\pespa\\AppData\\Local\\Volta\\tools\\image\\node\\24.7.0\\node.exe", + "C:\\Users\\pespa\\AppData\\Local\\Volta\\tools\\image\\packages\\vercel\\node_modules\\vercel\\dist\\vc.js", + "build" + ], + "builds": [ + { + "require": "@vercel/next", + "requirePath": "C:\\Users\\pespa\\AppData\\Local\\Volta\\tools\\image\\packages\\vercel\\node_modules\\vercel\\node_modules\\@vercel\\next\\dist\\index", + "apiVersion": 2, + "src": "package.json", + "use": "@vercel/next", + "config": { + "zeroConfig": true, + "framework": "nextjs" + }, + "error": { + "name": "Error", + "stack": "Error: Command \"pnpm run build\" exited with 1\n at ChildProcess. (C:\\Users\\pespa\\AppData\\Local\\Volta\\tools\\image\\packages\\vercel\\node_modules\\vercel\\node_modules\\@vercel\\build-utils\\dist\\index.js:23166:9)\n at ChildProcess.emit (node:events:508:28)\n at ChildProcess.emit (node:domain:489:12)\n at cp.emit (C:\\Users\\pespa\\AppData\\Local\\Volta\\tools\\image\\packages\\vercel\\node_modules\\vercel\\node_modules\\@vercel\\build-utils\\dist\\index.js:14249:29)\n at maybeClose (node:internal/child_process:1101:16)\n at ChildProcess._handle.onexit (node:internal/child_process:305:5)", + "message": "Command \"pnpm run build\" exited with 1", + "hideStackTrace": true, + "code": "BUILD_UTILS_SPAWN_1" + } + } + ], + "error": { + "name": "Error", + "stack": "Error: Command \"pnpm run build\" exited with 1\n at ChildProcess. (C:\\Users\\pespa\\AppData\\Local\\Volta\\tools\\image\\packages\\vercel\\node_modules\\vercel\\node_modules\\@vercel\\build-utils\\dist\\index.js:23166:9)\n at ChildProcess.emit (node:events:508:28)\n at ChildProcess.emit (node:domain:489:12)\n at cp.emit (C:\\Users\\pespa\\AppData\\Local\\Volta\\tools\\image\\packages\\vercel\\node_modules\\vercel\\node_modules\\@vercel\\build-utils\\dist\\index.js:14249:29)\n at maybeClose (node:internal/child_process:1101:16)\n at ChildProcess._handle.onexit (node:internal/child_process:305:5)", + "message": "Command \"pnpm run build\" exited with 1", + "hideStackTrace": true, + "code": "BUILD_UTILS_SPAWN_1" + } +} diff --git a/.vercel/output/config.json b/.vercel/output/config.json new file mode 100644 index 0000000000..cd2f236b29 --- /dev/null +++ b/.vercel/output/config.json @@ -0,0 +1,3 @@ +{ + "version": 3 +} diff --git a/.vercel/output/diagnostics/cli_traces.json b/.vercel/output/diagnostics/cli_traces.json new file mode 100644 index 0000000000..773738ffe4 --- /dev/null +++ b/.vercel/output/diagnostics/cli_traces.json @@ -0,0 +1 @@ +[{"name":"vc.builder.install","duration":2192588,"timestamp":884827077387,"id":"a8f3b34d-62fb-4a6b-9630-12399badbc7e","parentId":"94bd320b-f48b-4560-85b5-19572de072bc","tags":{"cliType":"pnpm","lockfileVersion":"9","packageJsonPackageManager":"pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184","detectedPackageManager":"pnpm@10.x"},"startTime":1756680543004},{"name":"vc.builder.build","duration":1865419,"timestamp":884829273373,"id":"cf976190-2ad1-4c3f-b0b6-facacc4e9541","parentId":"94bd320b-f48b-4560-85b5-19572de072bc","tags":{"buildScriptName":"build"},"startTime":1756680545200},{"name":"vc.builder","duration":4150177,"timestamp":884826988632,"id":"94bd320b-f48b-4560-85b5-19572de072bc","parentId":"6964e4a8-f9df-4237-8593-45c9dd297b77","tags":{"name":"@vercel/next","install":"true","build":"true"},"startTime":1756680542915},{"name":"vc.builder.diagnostics","duration":2585,"timestamp":884831138853,"id":"c396a9a4-ba14-409f-9924-9bddd53424a7","parentId":"94bd320b-f48b-4560-85b5-19572de072bc","tags":{},"startTime":1756680547065},{"name":"vc.doBuild","duration":4303257,"timestamp":884826839545,"id":"6964e4a8-f9df-4237-8593-45c9dd297b77","parentId":"1fd913bd-9580-469f-9e8c-200fa6b0acb6","tags":{},"startTime":1756680542766},{"name":"vc","duration":4377536,"timestamp":884826765286,"id":"1fd913bd-9580-469f-9e8c-200fa6b0acb6","tags":{},"startTime":1756680542692}] \ No newline at end of file diff --git a/.vercel/project.json b/.vercel/project.json new file mode 100644 index 0000000000..df8bd0d467 --- /dev/null +++ b/.vercel/project.json @@ -0,0 +1 @@ +{"projectId":"prj_INaaQWb5emmBctoGCmyFyLGj7BPH","orgId":"team_2w0fAz2sFN9SYuZyItcUDCtV","projectName":"catalyst"} \ No newline at end of file diff --git a/.vscode/launch.example.json b/.vscode/launch.example.json new file mode 100644 index 0000000000..485f59f7de --- /dev/null +++ b/.vscode/launch.example.json @@ -0,0 +1,38 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Catalyst: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "pnpm run dev", + "cwd": "${workspaceFolder}" + }, + { + "name": "Catalyst: debug client-side", + "type": "chrome", // Use "chrome" "firefox" or "msedge" as needed + "request": "launch", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}/core" + }, + { + "name": "Catalyst: debug full stack", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}/core", + "program": "${workspaceFolder}/core/node_modules/next/dist/bin/next", + "args": ["dev"], + "runtimeArgs": ["--inspect"], + "skipFiles": ["/**"], + "env": { + "NODE_ENV": "development" + }, + "serverReadyAction": { + "action": "openExternally", + "killOnServerStop": true, + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s" + } + } + ] +} diff --git a/ADD_TO_components.tsx b/ADD_TO_components.tsx new file mode 100644 index 0000000000..d8c7f74640 --- /dev/null +++ b/ADD_TO_components.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { runtime } from "~/makeswift/runtime"; +import { NumberInput } from "@makeswift/runtime/controls"; +import DynamicProductGrid from "~/components/mx/DynamicProductGrid"; + +function DynamicGridWrapper({ + categoryId, + limit, + columns, + gap, + cardRadius, +}: { + categoryId: number; + limit?: number; + columns?: number; + gap?: number; + cardRadius?: number; +}) { + return ( + + ); +} + +runtime.registerComponent(DynamicGridWrapper, { + type: "product-grid-dynamic-mx", + label: "Grid de productos (Dinámico) — MX", + props: { + categoryId: NumberInput({ label: "Category ID (BigCommerce)", defaultValue: 0 }), + limit: NumberInput({ label: "Límite", defaultValue: 12 }), + columns: NumberInput({ label: "Columnas (1–4)", defaultValue: 3 }), + gap: NumberInput({ label: "Separación (px)", defaultValue: 16 }), + cardRadius: NumberInput({ label: "Radio tarjeta (px)", defaultValue: 12 }), + }, +}); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86b3056677..d69b24dfdb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,42 +1,128 @@ # Contributing to Catalyst + Thanks for showing interest in contributing! The following is a set of guidelines for contributing to Catalyst. These are just guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. -#### Table of Contents +## Repository Structure + +Catalyst is a monorepo that contains the code for the Catalyst Next.js application inside of `core/`, and supporting packages such as the GraphQL API client and the `create-catalyst` CLI in `packages/`. + +The default branch for this repository is called `canary`. This is the primary development branch where active development takes place, including the introduction of new features, bug fixes, and other changes before they are released in stable versions. + +To contribute to the `canary` branch, you can create a new branch off of `canary` and submit a PR against that branch. + +## Makeswift Integration + +In addition to `canary`, we also maintain the `integrations/makeswift` branch, which contains additional code required to integrate with [Makeswift](https://www.makeswift.com). + +To contribute to the `integrations/makeswift` branch, you can create a new branch off of `integrations/makeswift` and submit a PR against that branch. + +### Keeping `integrations/makeswift` in sync with `canary` + +Except for the additional code required to integrate with Makeswift, the `integrations/makeswift` branch is a mirror of the `canary` branch. This means that the `integrations/makeswift` branch should be kept in sync with the `canary` branch as much as possible. + +#### Prerequisites + +In order to complete the following steps, you will need to have met the following prerequisites: + +- You have a remote named `origin` pointing to the [`bigcommerce/catalyst` repository on GitHub](https://github.com/bigcommerce/catalyst). +- You have rights to push to the `integrations/makeswift` branch on GitHub. + +#### Steps + +1. Fetch latest from `origin` + + ```bash + git fetch origin + ``` + +2. Create a branch to perform a merge from `canary` + + ```bash + git checkout -B sync-integrations-makeswift origin/integrations/makeswift + ``` + +> [!TIP] +> The `-B` flag means "create branch or reset existing branch": +> +> - If the local branch doesn't exist, it creates it from `origin/integrations/makeswift` +> - If the local branch exists, it resets it to match `origin/integrations/makeswift` + +3. Merge `canary` and resolve merge conflicts, if necessary: + + ```bash + git merge canary + ``` + +> [!WARNING] +> **Gotchas when merging canary into integrations/makeswift:** +> +> - The `name` field in `core/package.json` should remain `@bigcommerce/catalyst-makeswift` +> - The `version` field in `core/package.json` should remain whatever the latest published `@bigcommerce/catalyst-makeswift` version was + +4. After resolving any merge conflicts, open a new PR in GitHub to merge your `sync-integrations-makeswift` into `integrations/makeswift`. This PR should be code reviewed and approved before the next steps. + +5. Rebase `integrations/makeswift` to establish new merge base + + ```bash + git checkout -B integrations/makeswift origin/integrations/makeswift + git rebase sync-integrations-makeswift + ``` + +6. Push the changes up to GitHub: + + ```bash + git push origin integrations/makeswift + ``` + +This should close the PR in GitHub automatically. + +> [!IMPORTANT] +> Do not squash or rebase-and-merge PRs into `integrations/makeswift`. Always use a true merge commit or rebase locally (as shown below). This is to preserve the merge commit and establish a new merge base between `canary` and `integrations/makeswift`. + +## Cutting New Releases + +Catalyst uses [Changesets](https://github.com/changesets/changesets) to manage version bumps, changelogs, and publishing. Releases happen in **two stages**: + +1. Cut a release from `canary` +2. Sync that release into `integrations/makeswift` and cut again + +This ensures `integrations/makeswift` remains a faithful mirror of `canary` while including its additional integration code. + +#### Stage 1: Cut a release from `canary` -How Can I Contribute? - * [Pull Requests](#pull-requests) - * [Issues / Bugs](#issues--bugs) - * [Other Ways to Contribute](#other-ways-to-contribute) +1. Begin the release process by merging the **Version Packages (`canary`)** PR. When `.changeset/` files exist on `canary`, a GitHub Action opens a **Version Packages (`canary`)** PR. This PR consolidates pending changesets, bumps versions, and updates changelogs. Merging this PR should publish new tags to GitHub, and optionally publish new package versions to NPM. -Styleguides - * [Git Commit Messages](#git-commit-messages) +#### Stage 2: Sync and Release `integrations/makeswift` -## Pull Requests +2. Follow steps 1-6 under "[Keeping `integrations/makeswift` in sync with `canary`](#keeping-integrationsmakeswift-in-sync-with-canary)" -First ensure that your feature isn't already being developed or considered (see open PRs and issues). -If it is, please consider contributing to those initiatives. +3. **IMPORTANT**: After step 6, you'll need to open another PR into `integrations/makeswift` + - Ensure a local `integrations/makeswift` branch exists and is up to date (`git checkout -B integrations/makeswift origin/integrations/makeswift`) + - Run `git fetch origin` and create a new branch from `integrations/makeswift` (`git checkout -B bump-version origin/integrations/makeswift`) + - From this new `bump-version` branch, run `pnpm changeset` + - Select `@bigcommerce/catalyst-makeswift` + - For choosing between a `patch/minor/major` bump, you should copy the bump from Stage 1. (e.g., if `@bigcommerce/catalyst-core` went from `1.1.0` to `1.2.0`, choose `minor`) + - Commit the generated changeset file and open a PR to merge this branch into `integrations/makeswift` + - Once merged, you can proceed to the next step -## Issues / Bugs - -* Please include a clear, specific title and replicable description. +4. Merge the **Version Packages (`integrations/makeswift`)** PR: Changesets will open another PR (similar to Stage 1) bumping `@bigcommerce/catalyst-makeswift`. Merge it following the same process. This cuts a new release of the Makeswift variant. -* Please include your environment, OS, and any exceptions/backtraces that occur. The more -information that is given, the more likely we can debug and fix the issue. +### Additional Notes -**If you find a security bug, please do not post as an issue. Send directly to security@bigcommerce.com -instead.** +- **Tags and Releases:** Confirm tags exist for both `@bigcommerce/catalyst-core` and `@bigcommerce/catalyst-makeswift`. If needed, update `latest` tags in GitHub manually. +- **Release cadence:** Teams typically review on Wednesdays whether to cut a release, but you may cut releases more frequently as needed. ## Other Ways to Contribute -* Consider reporting bugs, contributing to test coverage, or helping spread the word about Catalyst. +- Consider reporting bugs, contributing to test coverage, or helping spread the word about Catalyst. ## Git Commit Messages -* Use the present tense ("Add feature" not "Added feature") -* Use the imperative mood ("Move cursor to..." not "Moves cursor to...") -* Limit the first line to 72 characters or less -* Reference pull requests and external links liberally +- Use the present tense ("Add feature" not "Added feature") +- Use the imperative mood ("Move cursor to..." not "Moves cursor to...") +- Limit the first line to 72 characters or less +- Reference pull requests and external links liberally -Thank you again for your interest in contributing to Catalyst! \ No newline at end of file +Thank you again for your interest in contributing to Catalyst! diff --git a/PATCH_layout.txt b/PATCH_layout.txt new file mode 100644 index 0000000000..78b15b8872 --- /dev/null +++ b/PATCH_layout.txt @@ -0,0 +1,8 @@ +*** EDITA core/app/[locale]/layout.tsx *** +1) Agrega imports: + import { getSiteVersion } from "@makeswift/runtime/next/server"; + import { MakeswiftProvider } from "~/makeswift/provider"; + import "~/makeswift/components"; + +2) Envuelve {children} con: + {children} diff --git a/PATCH_next-config.txt b/PATCH_next-config.txt new file mode 100644 index 0000000000..1144f0f404 --- /dev/null +++ b/PATCH_next-config.txt @@ -0,0 +1,8 @@ +*** EDITA core/next.config.ts *** +1) Agrega al inicio: + import createWithMakeswift from "@makeswift/runtime/next/plugin"; + const withMakeswift = createWithMakeswift(); + +2) Donde ya aplicas withNextIntl, agrega: + nextConfig = withNextIntl(nextConfig); + nextConfig = withMakeswift(nextConfig); diff --git a/README.md b/README.md index d15338456b..956817b671 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,12 @@ [![MIT License](https://img.shields.io/github/license/bigcommerce/catalyst)](LICENSE.md) [![Lighthouse Report](https://github.com/bigcommerce/catalyst/actions/workflows/lighthouse.yml/badge.svg)](https://github.com/bigcommerce/catalyst/actions/workflows/lighthouse.yml) [![Lint, Typecheck, gql.tada](https://github.com/bigcommerce/catalyst/actions/workflows/basic.yml/badge.svg)](https://github.com/bigcommerce/catalyst/actions/workflows/basic.yml) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bigcommerce/catalyst) +> **Note:** This is the `integrations/makeswift` branch of Catalyst, which includes an integration with Makeswift for visual editing. This is the version of Catalyst deployed by default by the One-Click Catalyst functionality in the BigCommerce control panel. If you wish to use a version of Catalyst without a pre-integrated visual editor, consider the code on the `canary` branch or refer to the [tags](https://github.com/bigcommerce/catalyst/tags) for the version that's best for you. + **Catalyst** is the composable, fully customizable headless commerce framework for [BigCommerce](https://www.bigcommerce.com/). Catalyst is built with [Next.js](https://nextjs.org/), uses our [React](https://react.dev/) storefront components, and is backed by the @@ -21,6 +24,10 @@ By choosing Catalyst, you'll have a fully-functional storefront within a few sec up APIs or building SEO, Accessibility, and Performance-optimized ecommerce components you've probably written many times before. You can instead go straight to work building your brand and making this your own. +## Demo + +- [Catalyst Demo](https://catalyst-demo.site) + ![-----------------------------------------------------](https://storage.googleapis.com/bigcommerce-developers/images/catalyst_readme_hr.png)

@@ -32,45 +39,35 @@ times before. You can instead go straight to work building your brand and making ![-----------------------------------------------------](https://storage.googleapis.com/bigcommerce-developers/images/catalyst_readme_hr.png) +## Deploy via One-Click Catalyst App -## Deploy on Vercel +The easiest way to deploy your Catalyst Storefront is to use the [One-Click Catalyst App](https://login.bigcommerce.com/deep-links/app/53284) available in the BigCommerce App Marketplace. -The easiest way to deploy your Catalyst Storefront is to use the [Vercel Platform](https://vercel.com/new) from the creators of Next.js. - -Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. - -

- Deploy with Vercel -
+Check out the [Catalyst.dev One-Click Catalyst Documentation](https://www.catalyst.dev/docs/getting-started) for more details. -## Quickstart +## Getting Started -Create a new project interactively by running: +**Requirements:** -```bash -npm create @bigcommerce/catalyst@latest -``` +- A [BigCommerce account](https://www.bigcommerce.com/start-your-trial) +- Node.js version 20 or 22 +- Corepack-enabled `pnpm` -You'll then get the following prompts: + ```bash + corepack enable pnpm + ``` -```console -? What would you like to call your project? my-faster-storefront -? Which would you like? -❯ Link Catalyst to a BigCommerce Store - Use sample data +1. Install the latest version of Catalyst: -? Would you like to create a new channel? y + ```bash + pnpm create @bigcommerce/catalyst@latest + ``` -? What would you like to name the new channel? My Faster Storefront +2. Run the local development server: -Success! Created 'my-faster-storefront' at '/Users/first.last/Documents/GitHub/my-faster-storefront' -``` - -Next steps: - -```bash -cd my-faster-storefront && npm run dev -``` + ```bash + pnpm run dev + ``` Learn more about Catalyst at [catalyst.dev](https://catalyst.dev). @@ -80,9 +77,3 @@ Learn more about Catalyst at [catalyst.dev](https://catalyst.dev). - [GraphQL Storefront API Playground](https://developer.bigcommerce.com/graphql-storefront/playground) - [GraphQL Storefront API Explorer](https://developer.bigcommerce.com/graphql-storefront/explorer) - [BigCommerce DevDocs](https://developer.bigcommerce.com/docs/build) - -![-----------------------------------------------------](https://storage.googleapis.com/bigcommerce-developers/images/catalyst_readme_hr.png) - -> [!IMPORTANT] -> If you just want to build a storefront, start with the [CLI](#quickstart) which will install the Next.js application in [/core](/core/). -> If you wish to contribute back to Catalyst or create a fork of Catalyst, you can check the [docs for this monorepo](https://catalyst.dev/docs/monorepo) to get started. diff --git a/README_MAKESWIFT.md b/README_MAKESWIFT.md new file mode 100644 index 0000000000..d714d4bd11 --- /dev/null +++ b/README_MAKESWIFT.md @@ -0,0 +1,54 @@ +# MakeSwift Add-on para MXPlantae (Catalyst/Next.js) + +Este paquete contiene los archivos mínimos para integrar **MakeSwift** dentro de tu app `core` bajo la ruta **/cms/**. + +## Archivos incluidos +- `core/makeswift/runtime.ts` → instancia del runtime +- `core/makeswift/client.ts` → cliente con tu API key +- `core/makeswift/components.tsx` → registro de un componente ejemplo +- `core/makeswift/provider.tsx` → provider para estilos/preview +- `core/app/cms/[[...path]]/page.tsx` → ruta para páginas MakeSwift bajo /cms/* +- `core/app/api/makeswift/[...makeswift]/route.ts` → handler para preview/revalidate + +## Cambios que debes hacer tú +1) **Instalar dependencia** en el paquete `core`: + ```bash + pnpm -F ./core add @makeswift/runtime + ``` +2) **Agregar el Provider al layout** `core/app/[locale]/layout.tsx`: + - Imports arriba del archivo: + ```ts + import { getSiteVersion } from "@makeswift/runtime/next/server"; + import { MakeswiftProvider } from "~/makeswift/provider"; + import "~/makeswift/components"; + ``` + - En el JSX, ENVUELVE el `` con: + ```tsx + + {children} + + ``` +3) **Editar `core/next.config.ts`** para aplicar el plugin: + - Agrega: + ```ts + import createWithMakeswift from "@makeswift/runtime/next/plugin"; + const withMakeswift = createWithMakeswift(); + ``` + - Aplica el plugin junto al de next-intl: + ```ts + nextConfig = withNextIntl(nextConfig); + nextConfig = withMakeswift(nextConfig); + ``` +4) **Variables de entorno**: en `.env.local` y en Vercel agrega: + ``` + MAKESWIFT_SITE_API_KEY=msk_live_xxxxxxxxxxxxxxxxx + ``` +5) **CSP (si la usas)**: añade dominios de MakeSwift a `connect-src`, `img-src`, etc. +6) **Crea tu página en MakeSwift** con URL que **empiece con `/cms/`** (ej. `/cms/landing-suscripciones`). + +## Probar +```bash +pnpm dev +# luego visita http://localhost:3000/cms/landing-suscripciones +``` + diff --git a/README_dynamic_grid.md b/README_dynamic_grid.md new file mode 100644 index 0000000000..ddb7541cf0 --- /dev/null +++ b/README_dynamic_grid.md @@ -0,0 +1,14 @@ +# Dynamic Product Grid — MXPlantae + +Añade: +- API: `core/app/api/mxplantae/products/route.ts` +- Componente de app: `core/components/mx/DynamicProductGrid.tsx` +- Registro para MakeSwift: añade el bloque de abajo a `core/makeswift/components.tsx` + +## Cómo usar +1) Copia estos archivos en tu repo. +2) Asegúrate de tener `@makeswift/runtime` instalado y la integración previa. +3) En MakeSwift, inserta **"Grid de productos (Dinámico) — MX"** y define el **Category ID** de BigCommerce. + +## Nota +Usa SWR en el cliente y un endpoint del propio sitio para obtener los productos del servidor (seguro y cacheable). diff --git a/[file] b/[file] new file mode 100644 index 0000000000..8eaf53d14f --- /dev/null +++ b/[file] @@ -0,0 +1,2 @@ +# Created by Vercel CLI +VERCEL_OIDC_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1yay00MzAyZWMxYjY3MGY0OGE5OGFkNjFkYWRlNGEyM2JlNyJ9.eyJpc3MiOiJodHRwczovL29pZGMudmVyY2VsLmNvbS9teHBsYW50YWUiLCJzdWIiOiJvd25lcjpteHBsYW50YWU6cHJvamVjdDpjYXRhbHlzdDplbnZpcm9ubWVudDpkZXZlbG9wbWVudCIsInNjb3BlIjoib3duZXI6bXhwbGFudGFlOnByb2plY3Q6Y2F0YWx5c3Q6ZW52aXJvbm1lbnQ6ZGV2ZWxvcG1lbnQiLCJhdWQiOiJodHRwczovL3ZlcmNlbC5jb20vbXhwbGFudGFlIiwib3duZXIiOiJteHBsYW50YWUiLCJvd25lcl9pZCI6InRlYW1fMncwZkF6MnNGTjlTWXVaeUl0Y1VEQ3RWIiwicHJvamVjdCI6ImNhdGFseXN0IiwicHJvamVjdF9pZCI6InByal9JTmFhUVdiNWVtbUJjdG9HQ215RnlMR2o3QlBIIiwiZW52aXJvbm1lbnQiOiJkZXZlbG9wbWVudCIsInVzZXJfaWQiOiJUZGhUYlZZeDg4WkMxemdQV1o1NE0xY3ciLCJuYmYiOjE3NTY2ODEwNzQsImlhdCI6MTc1NjY4MTA3NCwiZXhwIjoxNzU2NzI0Mjc0fQ.GI9Byfz_sdnhKjv95TAgNNYAlvNxgQwC6lM22ejBrsbCZY_qhg81gVY1NCAdANMOtATt5XmDF7--T_gk1-UvjlpEqjvkU0eFwccd8U_mUotqf8wWDLruv82QU-28i4QY7CDImU2r54iojUW2xS8CD1YWh5Lh2qNa0NFp0cVcYISbCDJ1L-3f9M7r7sHanzphyF2JgzWubwHTtGg9MKQ5hZQdSFQBNJblj1GLz1RIeal13eX1AlZcD-3_VLEt19ATNIZqhUenFUk5JnBybHtjsWhlojAR2esQp6h7QPsS5n9fSUaMe7lADZJ6B06FIFW4Px9LnKp28b7KK6jQ_C28QQ" diff --git a/core/.env.example b/core/.env.example index bacc5581f5..3647a7c116 100644 --- a/core/.env.example +++ b/core/.env.example @@ -1,14 +1,24 @@ +# Makeswift Site API Key +# In the Makeswift builder, go to Settings > Host and copy the API key for the site. +MAKESWIFT_SITE_API_KEY= + # The hash visible in the subject store's URL when signed in to the store control panel. -# The control panel URL is of the form `https://store-{hash}.mybigcommerce.com`. +# The control panel URL is of the form `https://store-{hash}.mybigcommerce.com`. BIGCOMMERCE_STORE_HASH= # 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= +# A store-level API account token used for REST API actions. Optional by default, but required in +# order for some components to work properly. For example, the `CustomerGroupSlot` Makeswift component +# requires a `BIGCOMMERCE_ACCESS_TOKEN` with `read-only` scope on Customers. +# See https://support.bigcommerce.com/s/article/Store-API-Accounts?language=en_US +BIGCOMMERCE_ACCESS_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, -# but it is strongly recommended to create a new channel instead for production. +# but it is strongly recommended to create a new channel instead for production. # The CLI can do this for you. BIGCOMMERCE_CHANNEL_ID=1 diff --git a/core/.env.test.example b/core/.env.test.example new file mode 100644 index 0000000000..c5a47a2f0e --- /dev/null +++ b/core/.env.test.example @@ -0,0 +1,58 @@ +# This file should hold any environment variables specific to test execution. +# Any overlapping variables in here will overwrite variables from the other .env files. +# For example, if BIGCOMMERCE_CHANNEL_ID=1 in .env.local, but BIGCOMMERCE_CHANNEL_ID=5 in this file, the test environment will use BIGCOMMERCE_CHANNEL_ID=5 + +# Access token client ID and client secret are required for JWT login tests. If not provided, tests that require these will be skipped. +# If provided, ensure the API account associated with this client ID/secret pair has the following scope: +# - Customers login: login +BIGCOMMERCE_CLIENT_ID= +BIGCOMMERCE_CLIENT_SECRET= + +# Base URL for all tests. This should always be set. It is recommended to run tests against a production build. +# If not set or undefined, it will default to 'http://localhost:3000' +PLAYWRIGHT_TEST_BASE_URL=http://localhost:3000 + +# If true, no tests will execute that require modifying/deleting any data on your store. +# This is particularly useful if you want to run the tests against a live store and avoid test data being visible to any customers. +# If this is false, you should only run tests against a staging store where test data is okay. +TESTS_READ_ONLY=false + +# Locale to use during test execution +TESTS_LOCALE=en + +# The fallback locale to use in the occurence of missing translation keys in the selected TESTS_LOCALE +# This should match the default locale you have set for your storefront +TESTS_FALLBACK_LOCALE=en + +# An existing customer account to use for all tests - use this if you don't want tests to create a new customer account. +# If TESTS_READ_ONLY is `true`, then any test requiring a customer will be skipped if these values are not set. +TEST_CUSTOMER_ID=1 +TEST_CUSTOMER_EMAIL=example@test.com +TEST_CUSTOMER_PASSWORD=Password1 + +# Used to create/update/delete store data for testing purposes +# Some tests will not pass without a BIGCOMMERCE_ACCESS_TOKEN, even with TESTS_READ_ONLY set to true +# This is due to the test API client sending GET (read-only) API requests +# Create a token with the following scopes: +# - Content: read-only +# - Products: read-only +# - Channel listings: read-only +# - Channel settings: read-only +# - Information & settings: read-only +# - Customers: read-only +# - Orders: read-only +# When TESTS_READ_ONLY is false, you'll need additional scopes: +# - Products: modify +# - Content: modify +# - Customers: modify +# - Marketing: modify +# - Orders: modify +BIGCOMMERCE_ACCESS_TOKEN= + +# The default product ID to use for tests. This needs to be a product without variants (simple) +# This is optional, but recommended. If left empty, tests that require a product will be skipped if TESTS_READ_ONLY is true +DEFAULT_PRODUCT_ID= + +# The default product ID to use for tests that require a complex product. This product must have at least 1 variant. +# This is optional, but recommended. If left empty, tests that require a complex product will be skipped if TESTS_READ_ONLY is true +DEFAULT_COMPLEX_PRODUCT_ID= diff --git a/core/.eslintrc.cjs b/core/.eslintrc.cjs index ce48062577..ee3336a73f 100644 --- a/core/.eslintrc.cjs +++ b/core/.eslintrc.cjs @@ -1,9 +1,8 @@ // @ts-check -// eslint-disable-next-line import/no-extraneous-dependencies require('@bigcommerce/eslint-config/patch'); -/** @type {import('eslint').Linter.Config} */ +/** @type {import('eslint').Linter.LegacyConfig} */ const config = { root: true, extends: [ @@ -48,6 +47,11 @@ const config = { importNames: ['redirect', 'permanentRedirect', 'useRouter', 'usePathname'], message: 'Please import from `~/i18n/routing` instead.', }, + { + name: '@playwright/test', + importNames: ['expect', 'test'], + message: 'Please import from `~/tests/fixtures` instead.', + }, ], }, ], @@ -58,10 +62,31 @@ const config = { }, ], }, + overrides: [ + { + files: ['**/*.spec.ts', '**/*.test.ts'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'next-intl/server', + importNames: ['getTranslations', 'getFormatter'], + message: + 'Please import `getTranslations` from `~/tests/lib/i18n` and `getFormatter` from `~/tests/lib/formatter` instead.', + }, + ], + }, + ], + }, + }, + ], ignorePatterns: [ 'client/generated/**/*.ts', 'playwright-report/**', 'test-results/**', + '.tests/**', '**/google_analytics4.js', ], }; diff --git a/core/.gitignore b/core/.gitignore index 26db3633a1..d8185a63a3 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -10,6 +10,7 @@ /test-results/ /playwright-report/ /playwright/.cache/ +/.tests # next.js /.next/ @@ -31,8 +32,8 @@ yarn-error.log* # local env files .env*.local -# vercel -.vercel +# +. # typescript *.tsbuildinfo @@ -41,8 +42,15 @@ next-env.d.ts # generated client/generated +# next-intl +messages/*.d.json.ts + # secrets .catalyst # Build config build-config.json + +# OpenNext +.open-next +.wrangler diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 5980cbc5fc..e724da139e 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,5 +1,921 @@ # Changelog +## 1.1.0 + +### Minor Changes + +- [#2477](https://github.com/bigcommerce/catalyst/pull/2477) [`02af32c`](https://github.com/bigcommerce/catalyst/commit/02af32c459719f97e8973a19b6889e5fa73d0c38) Thanks [@bookernath](https://github.com/bookernath)! - Add support for Scripts API/Script Manager scripts rendering via next/script + +### Patch Changes + +- [#2465](https://github.com/bigcommerce/catalyst/pull/2465) [`a438bb6`](https://github.com/bigcommerce/catalyst/commit/a438bb660bc3bd11adacd125769ba99ba2e1c38d) Thanks [@bookernath](https://github.com/bookernath)! - Bump next to 15.4.0-canary.114 to fix issue with PDPs 500ing on Docker builds + +- [#2474](https://github.com/bigcommerce/catalyst/pull/2474) [`989bf97`](https://github.com/bigcommerce/catalyst/commit/989bf974c534a7201782ace9a4bf3fe745e8af01) Thanks [@bookernath](https://github.com/bookernath)! - Respect min/max purchase quantity from API in quantity selector + +- [#2464](https://github.com/bigcommerce/catalyst/pull/2464) [`474f960`](https://github.com/bigcommerce/catalyst/commit/474f960c4c428e28874022b36ae2b03e0b301e20) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove edge runtime declarations to be able to run Catalyst with OpenNext. + +- [#2468](https://github.com/bigcommerce/catalyst/pull/2468) [`8b64931`](https://github.com/bigcommerce/catalyst/commit/8b6493156a70490c0c35c35d45ebd9ad8f23615c) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. + +## 1.0.1 + +### Patch Changes + +- [#2448](https://github.com/bigcommerce/catalyst/pull/2448) [`e4444a2`](https://github.com/bigcommerce/catalyst/commit/e4444a2ca83b5b73776c842feff56e47f57344dc) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Fixes an issue where the anonymous session wasn't getting cleared after an actual session was established. + +## 1.0.0 + +### Major Changes + +- [`6b17bdb`](https://github.com/bigcommerce/catalyst/commit/6b17bdb) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Introduce Soul VIBE UI library to the repository. + +- Added a collection of reusable primitives with modern styles +- Prebuilt sections and page templates that are easy to use +- Fast performance and modern patterns leveraging the latest features of Next.js +- Easy customization to best represent your brand +- Utilize @conform-to/react for progressively enhanced HTML forms + +Join the discussion [here](https://github.com/bigcommerce/catalyst/discussions/1861) for more details of this major milestone for Catalyst! + +### Minor Changes + +- [`589c91a`](https://github.com/bigcommerce/catalyst/commit/589c91a) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Enable cart restoration on non-persistent cart logouts. + +**Migration** + +Update the logout mutation to include the `cartEntityId` variable + the `cartUnassignResult` node and make sure the `client.fetch` method contains the new variable. + +```diff +-mutation LogoutMutation { ++mutation LogoutMutation($cartEntityId: String) { +- logout { ++ logout(cartEntityId: $cartEntityId) { + result ++ cartUnassignResult { ++ cart { ++ entityId ++ } ++ } + } +} +``` + +- [`32a28b9`](https://github.com/bigcommerce/catalyst/commit/32a28b9) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Use isomorphic-dompurify to santize any sort of shopper supplied input. + +- [`f039b2c`](https://github.com/bigcommerce/catalyst/commit/f039b2c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Properly handle `BigCommerceGQLError` in actions, by returning the error messages from the request. + +- [`dd66f96`](https://github.com/bigcommerce/catalyst/commit/dd66f96) Thanks [@matthewvolk](https://github.com/matthewvolk)! - In order to maintain parity with Stencil's 404 page, we wanted to allow the user to search from the 404 page. Since the search included with the header component is fully featured, we included a CTA to open the same search that you get when clicking the search icon in the header. + +**Migration** + +Most changes are additive, so they should hopefully be easy to resolve if flagged for merge conflicts. Change #3 below replaces the Search state with the new search context, be sure to pay attention to the new + +1. This change adds a new directory under `core/` called `context/` containing a `search-context.tsx` file. Since this is a new file, there shouldn't be any merge conflicts +2. `SearchProvider` is imported into `core/app/providers` and replaces the React fragment (`<>`) that currently wraps `` and `{children}` +3. In `core/vibes/soul/primitives/navigation`, replace `useState` with `useSearch` imported from the new context file, and update the dependency arrays for the `useEffect`'s in the `Navigation component` +4. Add search `Button` that calls `setIsSearchOpen(true)` to the `NotFound` component in `core/vibes/sections/not-found/index.tsx` + +- [`62b891c`](https://github.com/bigcommerce/catalyst/commit/62b891c) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Adds support for nested web page children / trees. Restructure web page routing to support a layout file. + +- [`44342ee`](https://github.com/bigcommerce/catalyst/commit/44342ee) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Sets a default session when any user first visits the page. + +- [`ff57b8a`](https://github.com/bigcommerce/catalyst/commit/ff57b8a) Thanks [@eugene(yevhenii)kuzmenko]()! - Pass analytics cookies to checkout mutation to preserve the analytics session whenever shopper redirects to the external checkout + +- [`067d5a4`](https://github.com/bigcommerce/catalyst/commit/067d5a4) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Move the anonymous session into it's own cookie, separate from Auth.js in order to have better non-persistent cart support. + +**Migration** + +If you were using `await signIn('anonymous', { redirect: false });`, you'll need to migrate over to using the `await anonymousSignIn()` function. Otherwise, we am only changing the underlying logic in existing API's so pulling in the changes should immediately pick this up. + +- [`9b3541d`](https://github.com/bigcommerce/catalyst/commit/9b3541d) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Adds a new analytics provider meant to replace the other provider. This provider is built being framework agnostic but exposes a react provider to use within context. The initial implementation comes with a Google Analytics provider with some basic events to get started. We need to add some other events around starting checkout, banners, consent loading, and search. This change is additive only so no migration is needed until consumption. + +- [`bd3bc8b`](https://github.com/bigcommerce/catalyst/commit/bd3bc8b) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Implement the new analytics provider, utilizing the GoogleAnalytics provider as the first analytics solution. + +Most changes are additive so merge conflicts should be easy to resolve. In order to use the new provider from the previous provider, if it's already not setup in the BigCommerce control panel for checkout analytics, you'll need to add the GA4 property ID. This will automatically be used by the new GoogleAnalytics provider. + +- [`70afa5a`](https://github.com/bigcommerce/catalyst/commit/70afa5a) Thanks [@eugene(yevhenii)kuzmenko]()! - Dispatch Visit started and Product Viewed analytics events + +- [`da2a462`](https://github.com/bigcommerce/catalyst/commit/da2a462) Thanks [@bookernath](https://github.com/bookernath)! - Add currency selector to header + +- [`f3b4d90`](https://github.com/bigcommerce/catalyst/commit/f3b4d90) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add Wishlist account pages and public wishlist page + +- [`59ff1ce`](https://github.com/bigcommerce/catalyst/commit/59ff1ce) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Fetches the stores URLs on build which can remove the need of setting NEXT_PUBLIC_BIGCOMMERCE_CDN_HOSTNAME. The environment variable is still provided in case customization is needed. + +- [`a0e6425`](https://github.com/bigcommerce/catalyst/commit/a0e6425) Thanks [@eugene(yevhenii)kuzmenko]()! - Adds analytics cookies needed for native analytics. + +This is a add-only change, so migration should be as simple as pulling in the new code. + +- [`a601f7e`](https://github.com/bigcommerce/catalyst/commit/a601f7e) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes compare for caching and the eventual use of dynamicIO. + +**Key modifications include:** + +- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. +- Use `Streamable.from` to generate our streaming props that are passed to our UI components. + +**Migration instructions:** + +- Updated `/app/[locale]/(default)/compare/page.tsx` to use `Streamable.from` pattern. +- Renamed `getCompareData` query to `getComparedProducts`. + - Updated query + - Returns empty `[]` if no product ids are passed + +- [`c6e38a6`](https://github.com/bigcommerce/catalyst/commit/c6e38a6) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Reorganize and cleanup files: +- Moved `core/context/search-context` to `core/lib/search`. +- Moved `core/client/mutations/add-cart-line-item.ts` and `core/client/mutations/create-cart.ts` into `core/lib/cart/*`. +- Removed `core/client/queries/get-cart.ts` in favor of a smaller, more focused query within `core/lib/cart/validate-cart.ts`. + +**Migration** + +- Replace imports from `~/context/search-context` to `~/lib/search`. +- Replace imports from `~/client/mutations/` to `~/lib/cart/`. +- Remove any direct imports from `~/client/queries/get-cart.ts` and use the new `validate-cart.ts` query instead. If you need the previous `getCart` function, you can copy it from the old file and adapt it to your needs. + +- [`7b3b81c`](https://github.com/bigcommerce/catalyst/commit/7b3b81c) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Replaces the REST-powered `client.fetchShippingZones` method with a GraphQL-powered query containing the `site.settings.shipping.supportedShippingDestinations` field. + +**Migration:** + +1. The return type of `getShippingCountries` has the same shape as the `Country` BigCommerce GraphQL type, so you should be able to copy the graphql query from `core/app/[locale]/(default)/cart/page-data.ts` into your project and replace the existing `getShippingCountries` method in there. +2. Remove the argument `data.geography` from the `getShippingCountries` invocation in `core/app/[locale]/(default)/cart/page.tsx` +3. Finally, you should be able to delete the file `core/client/management/get-shipping-zones.ts` assuming it is no longer referenced anywhere in `core/` + +- [`53e0b5e`](https://github.com/bigcommerce/catalyst/commit/53e0b5e) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes category PLP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. + +**Key modifications include:** + +- We don't stream in Category page data, instead it's a blocking call that will redirect to `notFound` when category is not found. Same for metadata. +- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. +- Use `Streamable.from` to generate our streaming props that are passed to our UI components. +- Remove use of nuqs' `createSearchParamsCache` in favor of nuqs' `createLoader`. + +**Migration instructions:** + +- Update `/(facted)/category/[slug]/page.tsx` + - For this page we are now doing a blocking request for category page data. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. We still stream in filter and product data. +- Update `/(facted)/category/[slug]/page-data.tsx` + - Request now accept `customerAccessToken` as a prop instead of calling internally. +- Update`/(facted)/category/[slug]/fetch-compare-products.ts` + - Request now accept `customerAccessToken` as a prop instead of calling internally. +- Update `/(faceted)/fetch-faceted-search.ts` + - Request now accept `customerAccessToken` and `currencyCode` as a prop instead of calling internally. + +- [`537db2c`](https://github.com/bigcommerce/catalyst/commit/537db2c) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Add the ability to redirect from the login page. Developers can now append a relative path to the `?redirectTo=` query param on the `/login` page. When a shopper successfully logs in, it'll redirect them to the given relative path. Defaults to `/account/orders` to prevent a breaking change. + +- [`b20dfb0`](https://github.com/bigcommerce/catalyst/commit/b20dfb0) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Adds an eslint rule to import expect and test from ~/tests/fixtures instead of the @playwright/test module. This is to create a more consistent testing experience across the codebase. + +**Migration** + +Any import statements that import `expect` and `test` from `@playwright/test` should be updated to import from `~/tests/fixtures` instead. All other imports from `@playwright/test` should remain unchanged. + +```diff +-import { expect, type Page, test } from '@playwright/test'; ++import { type Page } from '@playwright/test'; ++ ++import { expect, test } from '~/tests/fixtures'; +``` + +- [`f0464a8`](https://github.com/bigcommerce/catalyst/commit/f0464a8) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Drops CSS support for Safari < 15 due to those versions only having 0.09% global usage. + +- [`1d6cf64`](https://github.com/bigcommerce/catalyst/commit/1d6cf64) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Render address fields for customer registration form. + +- [`42ded4a`](https://github.com/bigcommerce/catalyst/commit/42ded4a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes home page, header, and footer for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. + +**Key modifications include:** + +- Header and Footer now have a blocking request for the shared data that is the same for all users. +- Data that can change for logged in users is now a separate request. +- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. +- Dynamic fetches (using customerAccessToken or preferred currency) are now all streaming queries. +- Use `Streamable.from` to generate our streaming props that are passed to our UI components. +- Update Header UI component to allow streaming in of currencies data. + +**Migration instructions:** + +- Renamed `/app/[locale]/(default)/query.ts` to `/app/[locale]/(default)/page-data.ts`, include page query on this page. +- Updated `/app/[locale]/(default)/page.ts` to use `Streamable.from` pattern. +- Split data that can vary by user from `core/components/footer/fragment.ts` and `core/components/header/fragment.ts` +- Updated `core/components/header/index.tsx` and `core/components/footer/index.tsx` to fetch shared data in a blocking request and pass data that varies by customer as streamable data. Updated to use the new `Streamable.from` pattern. +- [`061063f`](https://github.com/bigcommerce/catalyst/commit/061063f) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes search PLP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. + +**Key modifications include:** + +- We don't stream in Search page data, instead it's a blocking call to get page data. +- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. +- Use `Streamable.from` to generate our streaming props that are passed to our UI components. +- Remove use of nuqs' `createSearchParamsCache` in favor of nuqs' `createLoader`. + +**Migration instructions:** + +- Update `/(facted)/search/page.tsx` + - For this page we are now doing a blocking request for brand page data. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. We still stream in filter and product data. + +- [`da2a462`](https://github.com/bigcommerce/catalyst/commit/da2a462) Thanks [@bookernath](https://github.com/bookernath)! - Adds the ability to redirect after logout. + +- [`863d744`](https://github.com/bigcommerce/catalyst/commit/863d744) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Removes the old analytics provider in favor of the provider that fetches the configuration from the GraphQL API. + +- [`061063f`](https://github.com/bigcommerce/catalyst/commit/061063f) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes brand PLP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. + +**Key modifications include:** + +- We don't stream in Brand page data, instead it's a blocking call that will redirect to `notFound` when brand is not found. Same for metadata. +- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. +- Use `Streamable.from` to generate our streaming props that are passed to our UI components. +- Remove use of nuqs' `createSearchParamsCache` in favor of nuqs' `createLoader`. + +**Migration instructions:** + +- Update `/(facted)/brand/[slug]/page.tsx` + - For this page we are now doing a blocking request for brand page data. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. We still stream in filter and product data. +- Update `/(facted)/brand/[slug]/page-data.tsx` + - Request now accept `customerAccessToken` as a prop instead of calling internally. + +### Patch Changes + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`c73b57e`](https://github.com/bigcommerce/catalyst/commit/c73b57e) Thanks [@migueloller](https://github.com/migueloller)! - Use `setRequestLocale` in all pages and layouts and pass `locale` parameter to `getTranslations` in all `generateMetadata` to maximize static rendering. This is part of the ongoing work in preparation of enabling PPR and `dynamicIO` for all routes. + +- [`d70596e`](https://github.com/bigcommerce/catalyst/commit/d70596e) Thanks [@alanpledger](https://github.com/alanpledger)! - Fixes types for signIn credentials and improves error handling for registering a customer. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Applied streamable pattern to Cart. + +- [`54ee390`](https://github.com/bigcommerce/catalyst/commit/54ee390) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove unecessary `fetchOptions` in object that has nothing to do with a client request. + +- [`ab1f0a0`](https://github.com/bigcommerce/catalyst/commit/ab1f0a0) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add wishlist support to product display pages + +**Migration** + +- Ensure WishlistButton component is passed to additionalActions prop on ProductDetail +- Ensure WishlistButtonForm is used on product page + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add persistent cart support + +- [`27b2823`](https://github.com/bigcommerce/catalyst/commit/27b2823) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix issue where delete button is not displayed if you have only 1 address + +**Migration steps:** + +Update `/core/app/[locale]/(default)/account/addresses/page.tsx` and pass the `minimumAddressCount={0}` prop to the AddressListSection component. + +Example: + +```tsx +return ( + +); +``` + +- [`0779856`](https://github.com/bigcommerce/catalyst/commit/0779856) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Adds Tailwind classes used to style the checkbox input and label based on the disabled state of the checkbox. + +**Migration:** + +Since this is a one-file change, you should be able to simply grab the diff from [this PR](https://github.com/bigcommerce/catalyst/pull/2399). The main changes to note are that we are [adding a `peer` class](https://v3.tailwindcss.com/docs/hover-focus-and-other-states#styling-based-on-sibling-state) to the CheckboxPrimitive.Root, explicitly styling the `enabled` pseudoclass, and only applying hover styles when the checkbox is enabled. + +- [`604450d`](https://github.com/bigcommerce/catalyst/commit/604450d) Thanks [@bookernath](https://github.com/bookernath)! - Re-apply auth grouping approach with middleware exemption to preserve functionality of /login/token endpoint for Customer Login API + +- [`82290cd`](https://github.com/bigcommerce/catalyst/commit/82290cd) Thanks [@migueloller](https://github.com/migueloller)! - Upgrade `next-intl` to v4 and add strong types for translated messages via TypeScript type augmentation. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Clean up 'en' dictionary. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove unused dependencies. + +- [`6b0c85a`](https://github.com/bigcommerce/catalyst/commit/6b0c85a) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Remove unused search props, add missing search translations + +**Migration** + +`core/components/header/index.tsx` + +Ensure the following props are passed to the `HeaderSection` navigation prop: + +```tsx + searchInputPlaceholder: t('Search.inputPlaceholder'), + searchSubmitLabel: t('Search.submitLabel'), +``` + +`core/messages/en.json` + +Add the following keys to the `Components.Header.Search` translations: + +```json + "somethingWentWrong": "Something went wrong. Please try again.", + "inputPlaceholder": "Search products, categories, brands...", + "submitLabel": "Search" +``` + +`core/vibes/soul/primitives/navigation/index.tsx` + +Copy all changes from this file: + +1. Create `searchSubmitLabel?: string;` property, ensure it is passed into `SearchForm` +2. On the `SearchForm`, remove the `searchCtaLabel = 'View more',` property, as it is unused, and rename `submitLabel` to `searchSubmitLabel` +3. Ensure that `SearchForm` passes `searchSubmitLabel` to the `SearchButton`: `` +4. Remove the `searchCtaLabel` property from the `SearchResults` component + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Format totalCount value for i18n. + +- [`dd42b25`](https://github.com/bigcommerce/catalyst/commit/dd42b25) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Fix the faceted search pages to account for facets with spaces or other special characters in the name. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add date field to product details form. + +- [`d9685ee`](https://github.com/bigcommerce/catalyst/commit/d9685ee) Thanks [@bookernath](https://github.com/bookernath)! - Remove featured products panel from 404 page, allowing the page to be static in preparation for adding a search box + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`8baf8b3`](https://github.com/bigcommerce/catalyst/commit/8baf8b3) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Memoize `GetCartCountQuery` using React.js `cache()` so that it only hits the GraphQL API once per render, instead of twice. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add shipping selection to checkout. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`6401bb2`](https://github.com/bigcommerce/catalyst/commit/6401bb2) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update ProductListSection's and ReviewsSection's `totalCount` prop to string. + +- [`43351ab`](https://github.com/bigcommerce/catalyst/commit/43351ab) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Dedupe requests in various pages by properly caching/memoizing the function per page render. + +- [`b19ee74`](https://github.com/bigcommerce/catalyst/commit/b19ee74) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Updates `SelectField` to have a hidden input to pass the value of the select to the form. This is a workaround for a [Radix Select issue](https://github.com/radix-ui/primitives/issues/3198) that auto selects the first option in the select when submitting a form (even when no selection has been made). + +Additionally, fixes an issue of incorrectly adding an empty query param for product options when an option is empty. + +**Migration** + +Migration is straighforward and requires adding the hidden input to the component and renaming the `name` prop for the `Select` component to something temporary. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`d663741`](https://github.com/bigcommerce/catalyst/commit/d663741) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Revert UI changes for product form since streaming in fields causes an issue with the form. + +- [`7bc57c8`](https://github.com/bigcommerce/catalyst/commit/7bc57c8) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Set a min-height for the Navigation fallback skeleton to prevent layout shift. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`c70bff2`](https://github.com/bigcommerce/catalyst/commit/c70bff2) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Dedupe requests in "webpages" by properly caching/memoizing the fetch function per page render. + +- [`5a853c2`](https://github.com/bigcommerce/catalyst/commit/5a853c2) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Check for `error.type` instead of `error.name` auth error in Login, since `error.name` gets minified in production and the check never returns `true`. Additionally, add a check for the `cause.err` to be of type `BigcommerceGQLError`. + +**Migration:** + +- Change `error.name === 'CallbackRouteError'` to `error.type === 'CallbackRouteError'` check in the error handling of the login action and include `error.cause.err instanceof BigCommerceGQLError`. + +- [`fada842`](https://github.com/bigcommerce/catalyst/commit/fada842) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Adds the `__Secure-` prefix to the add additional broswer security policies around this cookie. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`976c74d`](https://github.com/bigcommerce/catalyst/commit/976c74d) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix blog post card date formatting on alternate locales + +**Migration** + +`core/vibes/soul/primitives/blog-post-card/index.tsx` + +Update the component to use `` for the date, instead of calling `new Date(date).toLocaleDateString(...)`. + +- [`9176f56`](https://github.com/bigcommerce/catalyst/commit/9176f56) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix possibility of duplicate `key` error in Breadcrumbs component for truncated breadcrumbs. + +**Migration** + +Update `core/vibes/soul/sections/breadcrumbs/index.tsx` to use `index` as the `key` property instead of `href` + +- [`9827e4c`](https://github.com/bigcommerce/catalyst/commit/9827e4c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Translate home breadcrumb in Contact Us page. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`48d5c99`](https://github.com/bigcommerce/catalyst/commit/48d5c99) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix public wishlist analytics/server error + +- Add translation key for a Publish Wishlist empty state + +**Migration** + +1. Add the following imports to `core/app/[locale]/(default)/wishlist/[token]/page.tsx`: + +```tsx +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { WishlistAnalyticsProvider } from '~/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider'; +``` + +2. Add the following function into the file: + +```tsx +const getAnalyticsData = async (token: string, searchParamsPromise: Promise) => { + const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); + const wishlist = await getPublicWishlist(token, searchParamsParsed); + + if (!wishlist) { + return []; + } + + return removeEdgesAndNodes(wishlist.items) + .map(({ product }) => product) + .filter((product) => product !== null) + .map((product) => { + return { + id: product.entityId, + name: product.name, + sku: product.sku, + brand: product.brand?.name ?? '', + price: product.prices?.price.value ?? 0, + currency: product.prices?.price.currencyCode ?? '', + }; + }); +}; +``` + +3. Wrap the component in the `WishlistAnalyticsProvider`: + +```tsx +export default async function PublicWishlist({ params, searchParams }: Props) { + // ... + return ( + getAnalyticsData(token, searchParams))}> + // ... + + ); +} +``` + +4. Update `/core/messages/en.json` "PublishWishlist" to have translations: + +```json + "PublicWishlist": { + "title": "Public Wish List", + "defaultName": "Public wish list", + "emptyWishlist": "This wish list doesn't have any products yet." + }, +``` + +5. Update `WishlistDetails` component to accept the `emptyStateText` and `placeholderCount` props: + +```tsx +// ... +export const WishlistDetails = ({ + className = '', + wishlist: streamableWishlist, + emptyStateText, + paginationInfo, + headerActions, + prevHref, + placeholderCount, + action, + removeAction, +}: Props) => { +``` + +6. Update `WishlistDetails` component to pass the `emptyStateText` and `placeholderCount` props to both the `WishlistDetailSkeleton` and `WishlistItems` components: + +```tsx + +``` + +```tsx + +``` + +- [`1147a9e`](https://github.com/bigcommerce/catalyst/commit/1147a9e) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Deduplicate default image in the image gallery in PDP. + +- [`47b3ad0`](https://github.com/bigcommerce/catalyst/commit/47b3ad0) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fix an issue with orders with deleted products throwing an error and stopping page render by settings the errorPolicy for requests to ignore errors and update Soul components to render the products without using links for these cases. + +- [`589c91a`](https://github.com/bigcommerce/catalyst/commit/589c91a) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Remove cache from a customer-specific wishlist query. + +- [`aecc145`](https://github.com/bigcommerce/catalyst/commit/aecc145) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: localized home page routes are rewritten to the "catch all" page + +- [`3015503`](https://github.com/bigcommerce/catalyst/commit/3015503) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Fix style override issues with the latest version of the Tailwind bump. Changes should be easily rebasable. + +- [`a7b369c`](https://github.com/bigcommerce/catalyst/commit/a7b369c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fixes the error warning by having a `ProductPickList` with no images, by making the `image` prop optional for when it is not needed. + +**Migration** + +- Update `schema.ts` to allow optional `image` prop for `CardRadioField` +- Update `productOptionsTransformer` switch to have two cases for `ProductPickList` + - `ProductPickList` with no image object + - `ProductPickListWithImages` with image object +- Update ui component to make the `image` prop optional and conditionally render the image. + +- [`f16a6be`](https://github.com/bigcommerce/catalyst/commit/f16a6be) Thanks [@migueloller](https://github.com/migueloller)! - Adds `Streamable.from` and uses it wherever we were unintentionally executing an async function in a React Server Component. + +- [`43351ab`](https://github.com/bigcommerce/catalyst/commit/43351ab) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Pass in currency code to quick search results. + +- [`17d72ca`](https://github.com/bigcommerce/catalyst/commit/17d72ca) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Add the `store_hash` `` element to better support merchants. This enabled BigCommerce to identify the store more easily. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`7071dfe`](https://github.com/bigcommerce/catalyst/commit/7071dfe) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add locale prefix to auth middleware protected route URLPattern + +**Migration** + +In `core/middlewares/with-auth.ts`, update the `protectedPathPattern` variable to include an optional path segment for the locale: + +```tsx +const protectedPathPattern = new URLPattern({ pathname: `{/:locale}?/(account)/*` }); +``` + +- [`67715bf`](https://github.com/bigcommerce/catalyst/commit/67715bf) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Update GQL client and auth middleware to handle invalid tokens and invalidate session. + +**Summary** + +This will ensure that if a user is logged out elsewhere, they will be redirected to the /login page when they try to access a protected route. + +Previously, the pages would 404 which is misleading. + +**Migration** + +1. Copy all changes from the `/core/client` directory and the `/packages/client` directory +2. Copy translation values +3. Copy all changes from the `/core/app/[locale]/(default)/account/` directory server actions +4. Copy all changes from the `/core/app/[locale]/(default)/checkout/route.ts` file + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`6c77e57`](https://github.com/bigcommerce/catalyst/commit/6c77e57) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes PDP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. + +**Key modifications include:** + +- Split queries into four: + - Page Metadata (metadata fields that only depend on locale) + - Product (for fields that only depend on locale) + - Streamable Product (for fields that depend on locale and variant selection) + - Product Pricing and Related Products (for fields that require locale, variant selection, and currency -- in this case, pricing and related products) +- We don't stream in Product data, instead it's a blocking call that will redirect to `notFound` when product is not found. +- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. +- Use `Streamable.from` to generate our streaming props that are passed to our UI components. +- Update UI components to allow streaming product options before streaming in buy button. + +**Migration instructions:** + +- Update `/product/[slug]/page.tsx` + - For this page we are now doing a blocking request that is simplified for metadata and as a base product. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. +- Update `/product/[slug]/page-data.tsx` + - Expect our requests to be simplified/merged, essentially replacing what we had before for new requests and functions. +- Update`/product/[slug]/_components`. + - Similar to `page.tsx` and `page.data`, expect changes in the fragments defined and how we pass streamable functions to UI components. +- Update `/vibes/soul/product-detail/index.tsx` & `/vibes/soul/product-detail/product-detail-form.tsx` + - Minor changes to allow streaming in data. + +- [`8a25424`](https://github.com/bigcommerce/catalyst/commit/8a25424) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Refactors the sign in functionality to use two separate providers instead of one. This is some work needed to be done in order to provide a better API for session syncing so it shouldn't effect any existing functionality. + +- [`e968366`](https://github.com/bigcommerce/catalyst/commit/e968366) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: `useCompareDrawer` does not throw on missing context + +- [`a19b3ba`](https://github.com/bigcommerce/catalyst/commit/a19b3ba) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix persistent cart behavior during login. + +**Migration** + +In `core/auth/index.ts`, create the `cartIdSchema` variable: + +```ts +const cartIdSchema = z + .string() + .uuid() + .or(z.literal('undefined')) // auth.js seems to pass the cart id as a string literal 'undefined' when not set. + .optional() + .transform((val) => (val === 'undefined' ? undefined : val)); +``` + +Then, update all `Credentials` schemas to use this new `cartIdSchema`: + +```ts +const PasswordCredentials = z.object({ + email: z.string().email(), + password: z.string().min(1), + cartId: cartIdSchema, +}); + +const AnonymousCredentials = z.object({ + cartId: cartIdSchema, +}); + +const JwtCredentials = z.object({ + jwt: z.string(), + cartId: cartIdSchema, +}); + +const SessionUpdate = z.object({ + user: z.object({ + cartId: cartIdSchema, + }), +}); +``` + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add discounts summary item to Cart. + +- [`2de3c51`](https://github.com/bigcommerce/catalyst/commit/2de3c51) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fixes an issue with the checkbox not properly triggering the required validation. +- Fixes an issue with the checkbox not setting the default value from the API. +- Fixes an issue with the field value being incorrectly set as `undefined` + +**Migration** + +Update the props to set a `checked` value and pasa an empty string when checked box is unselected. + +``` +case 'checkbox': + return ( + handleChange(value ? 'true' : '')} + onFocus={controls.focus} + required={formField.required} + value={controls.value ?? ''} + /> + ); +``` + +- [`c5ce9dc`](https://github.com/bigcommerce/catalyst/commit/c5ce9dc) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Properly handle the auth error when login is invalid. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`2a7b05f`](https://github.com/bigcommerce/catalyst/commit/2a7b05f) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add translations for 'Search' button on 404 page + +**Migration** + +1. Add `"search"` translation key in the `"NotFound"` translations +2. In `core/vibes/soul/sections/not-found/index.tsx`, add a `ctaLabel` property and ensure it is used in place of the "Search" text +3. In `core/app/[locale]/not-found.tsx`, pass the `ctaLabel` prop as the new translation key `ctaLabel={t('search')}` + +- [`c095663`](https://github.com/bigcommerce/catalyst/commit/c095663) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Moves some auth related route handlers under the (auth) route group. This is to cleanup some of the routing. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add result type to all `generateMetadata`. + +- [`a15d84c`](https://github.com/bigcommerce/catalyst/commit/a15d84c) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Renames `core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider/index.tsx` to `core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider.tsx` for consistency with the other analytics components. + +**Migration** + +To migrate, rename the file with git: + +```bash +git mv core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider/index.tsx core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider.tsx +``` + +- [`5e5314b`](https://github.com/bigcommerce/catalyst/commit/5e5314b) Thanks [@jorgemoya](https://github.com/jorgemoya)! - We want state to be persitent on the `ProductDetailForm`, even after submit. This change will allow the API error messages to properly show when the form is submitted. Additionally, other form fields will retain state (like item quantity). + +**Migration** + +- Update `ProductDetailForm` to prevent reset on submit, by removing `requestFormReset` in the `onSubmit`. +- Remove `router.refresh()` call and instead call new `revalidateCart` action. + - `revalidateCart` is an action that `revalidateTag(TAGS.cart)` + - This prevents the form from fully refreshing on success. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`8c4f374`](https://github.com/bigcommerce/catalyst/commit/8c4f374) Thanks [@jordanarldt](https://github.com/jordanarldt)! - - Redirect to `/account/wishlists/` when a wishlist ID is not found +- Pass `actionsTitle` to WishlistActionsMenu on WishlistDetails page + +**Migration** + +1. Copy changes from `/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-actions.tsx` - Ensure that `actionsTitle` is an allowed property and that it is passed into the `WishlistActionsMenu` component +2. Copy changes from `/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx` - Redirect to `/account/wishlists/` on 404 +3. Ensure that the `removeButtonTitle` prop is passed down all the way to the `RemoveWishlistItemButton` component in the `WishlistItemCard` component + +- [`45bbd92`](https://github.com/bigcommerce/catalyst/commit/45bbd92) Thanks [@jordanarldt](https://github.com/jordanarldt)! - - Update the account pages to match the style of VIBES and remain consistent with the rest of Catalyst. +- Updated OrderDetails line items styling to display cost of each item and the selected `productOptions` +- Created OrderDetails skeletons +- Updated /account/orders/[id] to use `Streamable` + +**Migration** + +1. Copy all changes in the `/core/vibes/soul` directory +2. Copy all changes in the `/core/app/[locale]/(default)/account` directory + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add coupon code form to Cart page. + +- [`e8c693a`](https://github.com/bigcommerce/catalyst/commit/e8c693a) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add toast message when changing password + +**Migration** + +`core/vibes/soul/sections/account-settings/change-password-form.tsx` + +1. Import `toast`: + +```ts +import { toast } from '@/vibes/soul/primitives/toaster'; +``` + +2. Update the `ChangePasswordAction` types: + +```ts +type Action = (state: Awaited, payload: P) => S | Promise; + +interface State { + lastResult: SubmissionResult | null; + successMessage?: string; +} + +export type ChangePasswordAction = Action; +``` + +3. Update the `useActionState` hook: + +```ts +const [state, formAction] = useActionState(action, { lastResult: null }); +``` + +4. Update the `useEffect` hook to display a toast message on success: + +```ts +useEffect(() => { + if (state.lastResult?.status === 'success' && state.successMessage != null) { + toast.success(state.successMessage); + } + + if (state.lastResult?.error) { + // eslint-disable-next-line no-console + console.log(state.lastResult.error); + } +}, [state]); +``` + +`core/app/[locale]/(default)/account/settings/_actions/change-password.ts` + +Update all of the `return` values to match the new `ChangePasswordAction` interface, and return the `passwordUpdated` message on success. + +```ts +export const changePassword: ChangePasswordAction = async (prevState, formData) => { + const t = await getTranslations('Account.Settings'); + const customerAccessToken = await getSessionCustomerAccessToken(); + + const submission = parseWithZod(formData, { schema: changePasswordSchema }); + + if (submission.status !== 'success') { + return { lastResult: submission.reply() }; + } + + const input = { + currentPassword: submission.value.currentPassword, + newPassword: submission.value.password, + }; + + try { + const response = await client.fetch({ + document: CustomerChangePasswordMutation, + variables: { + input, + }, + customerAccessToken, + }); + + const result = response.data.customer.changePassword; + + if (result.errors.length > 0) { + return { + lastResult: submission.reply({ formErrors: result.errors.map((error) => error.message) }), + }; + } + + return { + lastResult: submission.reply(), + successMessage: t('passwordUpdated'), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), + }; + } + + if (error instanceof Error) { + return { + lastResult: submission.reply({ formErrors: [error.message] }), + }; + } + + return { lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }) }; + } +}; +``` + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Disable prefetch for the `/logout` link. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add textarea field to product details form. + +- [`525afdb`](https://github.com/bigcommerce/catalyst/commit/525afdb) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update empty state for account pages, adjusting headers and empty designs. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Set currency on cart at creation time + +- [`e145673`](https://github.com/bigcommerce/catalyst/commit/e145673) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Allow a list of CDN hostnames for cases when there can be more than one CDN available for image loader. + +**Migration:** + +- Update `build-config` schema to make `cdnUrls` an array of strings. +- Update `next.config.ts` to set `cdnUrls` as an array, and set multiple preconnected Link headers (one per CDN). +- `shouldUseLoaderProp` function now reads from array. + +- [`6b99400`](https://github.com/bigcommerce/catalyst/commit/6b99400) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Split coupon discounts and regular discounts from summary items, use total `cart.discountedAmount` for discounts. + +- [`0900330`](https://github.com/bigcommerce/catalyst/commit/0900330) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Refactors redirecting to checkout as a route. This will enable session syncing to happen through a redirect using the sites and routes API. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`7668774`](https://github.com/bigcommerce/catalyst/commit/7668774) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Disable PPR in Compare page due to an issue of Next.js and PPR, which causes the products to be removed once one is added to cart. More info: https://github.com//next.js/issues/59407. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`e8a9ebf`](https://github.com/bigcommerce/catalyst/commit/e8a9ebf) Thanks [@bookernath](https://github.com/bookernath)! - Revert auth route reorganization to fix regression with /login/token endpoint + +- [`84d416a`](https://github.com/bigcommerce/catalyst/commit/84d416a) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Soft fail analytics events if the provider is not rendered + +- [`6aef70b`](https://github.com/bigcommerce/catalyst/commit/6aef70b) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Refactors the add to cart logic to handle some shared functionality like revalidating the tags and setting the cart state. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Use `setRequestLocale` only where needed + +- [`96f7c8e`](https://github.com/bigcommerce/catalyst/commit/96f7c8e) Thanks [@jordanarldt](https://github.com/jordanarldt)! - - Fix incorrect/missing translation messages +- Separate defaultLocale in to a separate file +- Remove caching in `/account` pages +- Update `WishlistListItem` for better accessibility + +**Migration** + +Use this PR as a reference: https://github.com/bigcommerce/catalyst/pull/2341 + +1. Update your `messages/en.json` file with the translation keys added in this PR +2. Ensure that all components are being passed the correct translation keys +3. Update all references to `defaultLocale` to point to the `~/i18n/locales` file created in this PR +4. Update all pages in `/core/app/[locale]/(default)/account/` and ensure that `cache: 'no-store'` is set on the `client.fetch` calls +5. Update the `WishlistListItem` component to use the new accessibility features/tags as shown in the PR + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`5b83a97`](https://github.com/bigcommerce/catalyst/commit/5b83a97) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Pass search params to router.redirect when swapping locales. + +**Migration** + +Modify `useSwitchLocale` hook to include `Object.fromEntries(searchParams.entries())`. + +- [`edda0e3`](https://github.com/bigcommerce/catalyst/commit/edda0e3) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add missing border style for `Input`, `NumberInput` and `DatePicker`. + +**Migration** + +Following convention, add these conditional classes to the fields using `clsx`: + +``` +{ +light: + errors && errors.length > 0 + ? 'border-[var(--input-light-border-error,hsl(var(--error)))]' + : 'border-[var(--input-light-border,hsl(var(--contrast-100)))]', +dark: + errors && errors.length > 0 + ? 'border-[var(--input-dark-border-error,hsl(var(--error)))]' + : 'border-[var(--input-dark-border,hsl(var(--contrast-500)))]', +}[colorScheme], +``` + +- [`aade48a`](https://github.com/bigcommerce/catalyst/commit/aade48a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove explicit locale override in Link component that was appending default locale to links even with the 'as-needed' mode. + +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. + +- [`157ea54`](https://github.com/bigcommerce/catalyst/commit/157ea54) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Rename some GQL query/mutations/fragments to standardized naming. + +- [`c4e56c6`](https://github.com/bigcommerce/catalyst/commit/c4e56c6) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: switching locales redirects user to the home page + +- [`d9edb44`](https://github.com/bigcommerce/catalyst/commit/d9edb44) Thanks [@bookernath](https://github.com/bookernath)! - Remove unused variants collection from query for PDP + +- [`816290a`](https://github.com/bigcommerce/catalyst/commit/816290a) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add aria-label to currency selector and PDP wishlist buttons + +**Migration** + +1. Copy all changes from the `/messages/en.json` file to get updated translation keys +2. Add the `label` prop to the `Heart` component in `/core/vibes/soul/primitives/favorite/heart.tsx` +3. Add the `label` prop to the `Favorite` component in `/core/vibes/soul/primitives/favorite/index.tsx` and pass it to the `Heart` component +4. Copy all changes in the `/core/vibes/soul/navigation/index.tsx` file to add the `switchCurrencyLabel` property +5. Update `/core/components/header/index.tsx` file to pass the `switchCurrencyLabel` to the `HeaderSection` component +6. Update `/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/index.tsx` to pass the `label` prop to the `Favorite` component + ## 0.24.1 ### Patch Changes @@ -161,8 +1077,7 @@ ### 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)>): - +- [#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..app/blog/next-intl-3-22)>): - Use new `createNavigation` api. - Pass `locale` to redirects. - `setRequestLocale` is no longer unstable. @@ -255,12 +1170,11 @@ ### Minor Changes -- [#1362](https://github.com/bigcommerce/catalyst/pull/1362) [`0814afe`](https://github.com/bigcommerce/catalyst/commit/0814afefca00b2497dddb0622df45f4d50865882) Thanks [@deini](https://github.com/deini)! - If app is not running on Vercel's infra, `` and `` are not rendered. - - Opt-out of vercel analytics and speed insights by setting the following env vars to `true` +- [#1362](https://github.com/bigcommerce/catalyst/pull/1362) [`0814afe`](https://github.com/bigcommerce/catalyst/commit/0814afefca00b2497dddb0622df45f4d50865882) Thanks [@deini](https://github.com/deini)! - If app is not running on 's infra, `` and `` are not rendered. - - `DISABLE_VERCEL_ANALYTICS` - - `DISABLE_VERCEL_SPEED_INSIGHTS` + Opt-out of analytics and speed insights by setting the following env vars to `true` + - `DISABLE__ANALYTICS` + - `DISABLE__SPEED_INSIGHTS` - [#1354](https://github.com/bigcommerce/catalyst/pull/1354) [`3d298c7`](https://github.com/bigcommerce/catalyst/commit/3d298c7190e01309ee706c0b9696f8851071e73c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Move address forms in account to their own /add and /edit pages. @@ -274,7 +1188,7 @@ - [#1360](https://github.com/bigcommerce/catalyst/pull/1360) [`00f72dd`](https://github.com/bigcommerce/catalyst/commit/00f72ddc7e3c2cff780430e074341ee72bc0c893) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Change LocalePrefix mode to `as-needed`, since there's an issue that is causing caching problems when using `never`. - More info about LocalePrefixes: https://next-intl-docs.vercel.app/docs/routing#shared-configuration + More info about LocalePrefixes: https://next-intl-docs..app/docs/routing#shared-configuration Open issue: https://github.com/amannn/next-intl/issues/786 - [#1338](https://github.com/bigcommerce/catalyst/pull/1338) [`d50613a`](https://github.com/bigcommerce/catalyst/commit/d50613a669696f34a695bc35b9d40099eeea0660) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - improve redirect behavior after change password on account page @@ -347,7 +1261,6 @@ - [#1278](https://github.com/bigcommerce/catalyst/pull/1278) [`f8553c6`](https://github.com/bigcommerce/catalyst/commit/f8553c6c9fb35ab7a143fabd60719c8156269448) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fix wrapping author text in BlogPostCard. - [#1322](https://github.com/bigcommerce/catalyst/pull/1322) [`77ecb4b`](https://github.com/bigcommerce/catalyst/commit/77ecb4bb4f527e079788b0f9dff2468e92d0bc1a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Split auth forms to four different pages: - - /login - /register - /reset @@ -397,7 +1310,6 @@ - [#1194](https://github.com/bigcommerce/catalyst/pull/1194) [`b455b05`](https://github.com/bigcommerce/catalyst/commit/b455b05a6121b005bd5147a25c964b9554b1b350) Thanks [@BC-krasnoshapka](https://github.com/BC-krasnoshapka)! - Add basic support for Google Analytics via [Big Open Data Layer](https://developer.bigcommerce.com/docs/integrations/hosted-analytics). BODL and GA4 integration is encapsulated in `bodl` library which hides current complexity and limitations that will be improved in future. It can be extended with more events and integrations with other analytics providers later. Data transformation from Catalyst data models to BODL and firing events is done in client components, as only frontend events are supported by BODL for now. List of currently supported events: - - View product category - View product page - Add product to cart @@ -671,7 +1583,7 @@ - [#779](https://github.com/bigcommerce/catalyst/pull/779) [`fe34b3e`](https://github.com/bigcommerce/catalyst/commit/fe34b3ed79992f73084214b369b7750141a17c39) Thanks [@deini](https://github.com/deini)! - use LRU cache for DevKvAdapter -- [#789](https://github.com/bigcommerce/catalyst/pull/789) [`86403a6`](https://github.com/bigcommerce/catalyst/commit/86403a6fc66f52f93ace611631614c2844af5a87) Thanks [@deini](https://github.com/deini)! - best-effort in memory cache for vercel kv adapter +- [#789](https://github.com/bigcommerce/catalyst/pull/789) [`86403a6`](https://github.com/bigcommerce/catalyst/commit/86403a6fc66f52f93ace611631614c2844af5a87) Thanks [@deini](https://github.com/deini)! - best-effort in memory cache for kv adapter - [#815](https://github.com/bigcommerce/catalyst/pull/815) [`984c30c`](https://github.com/bigcommerce/catalyst/commit/984c30ca51601fb8f1c0f6c83bce40c3650f9b23) Thanks [@deini](https://github.com/deini)! - pin nextjs version diff --git a/core/README.md b/core/README.md index 17a5a336b9..6ce5bf2343 100644 --- a/core/README.md +++ b/core/README.md @@ -3,8 +3,16 @@
+
+ +
+ +[![MIT License](https://img.shields.io/github/license/bigcommerce/catalyst)](LICENSE.md) +[![Lighthouse Report](https://github.com/bigcommerce/catalyst/actions/workflows/lighthouse.yml/badge.svg)](https://github.com/bigcommerce/catalyst/actions/workflows/lighthouse.yml) [![Lint, Typecheck, gql.tada](https://github.com/bigcommerce/catalyst/actions/workflows/basic.yml/badge.svg)](https://github.com/bigcommerce/catalyst/actions/workflows/basic.yml) -**Catalyst** is the composable, fully customizable headless ecommerce storefront framework for +
+ +**Catalyst** is the composable, fully customizable headless commerce framework for [BigCommerce](https://www.bigcommerce.com/). Catalyst is built with [Next.js](https://nextjs.org/), uses our [React](https://react.dev/) storefront components, and is backed by the [GraphQL Storefront API](https://developer.bigcommerce.com/docs/storefront/graphql). @@ -13,47 +21,56 @@ By choosing Catalyst, you'll have a fully-functional storefront within a few sec up APIs or building SEO, Accessibility, and Performance-optimized ecommerce components you've probably written many times before. You can instead go straight to work building your brand and making this your own. -
+## Demo -![-----------------------------------------------------](https://storage.googleapis.com/bigcommerce-developers/images/catalyst_readme_hr.png) +- [Catalyst Demo](https://catalyst-demo.site) -
+![-----------------------------------------------------](https://storage.googleapis.com/bigcommerce-developers/images/catalyst_readme_hr.png)

🚀 catalyst.dev🤗 BigCommerce Developer Community • - 💬 GitHub Discussions + 💬 GitHub Discussions • + 💡 Docs in this repo

-
- ![-----------------------------------------------------](https://storage.googleapis.com/bigcommerce-developers/images/catalyst_readme_hr.png) -
+## Deploy via One-Click Catalyst App + +The easiest way to deploy your Catalyst Storefront is to use the [One-Click Catalyst App](http://login.bigcommerce.com/deep-links/app/53284) available in the BigCommerce App Marketplace. + +Check out the [Catalyst.dev One-Click Catalyst Documentation](https://www.catalyst.dev/docs/getting-started) for more details. + +## Getting Started -## Requirements +**Requirements:** -- Node.js 20+ -- `npm` (or `pnpm`/`yarn`) +- A [BigCommerce account](https://www.bigcommerce.com/start-your-trial) +- Node.js version 20 or 22 +- Corepack-enabled `pnpm` -## Getting started + ```bash + corepack enable pnpm + ``` -If this installation of Catalyst was created using the `catalyst` CLI, you should already be connected to a store and can get started immediately by running: +1. Install the latest version of Catalyst: -```shell -npm run dev -``` + ```bash + pnpm create @bigcommerce/catalyst@latest + ``` -If you want to connect to another store or channel, you can run the setup process again by running: +2. Run the local development server: -```shell -npx @bigcommerce/create-catalyst@latest init -``` + ```bash + pnpm run dev + ``` Learn more about Catalyst at [catalyst.dev](https://catalyst.dev). ## Resources +- [Catalyst Documentation](https://catalyst.dev/docs/) - [GraphQL Storefront API Playground](https://developer.bigcommerce.com/graphql-storefront/playground) - [GraphQL Storefront API Explorer](https://developer.bigcommerce.com/graphql-storefront/explorer) - [BigCommerce DevDocs](https://developer.bigcommerce.com/docs/build) diff --git a/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts b/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts index a6b3847f7c..128b2e3b8e 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts +++ b/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts @@ -1,25 +1,16 @@ 'use server'; +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; import { getTranslations } from 'next-intl/server'; -import { z, ZodError } from 'zod'; +import { schema } from '@/vibes/soul/sections/reset-password-section/schema'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; -const ChangePasswordFieldsSchema = z.object({ - customerId: z.string(), - customerToken: z.string(), - currentPassword: z.string().min(1), - newPassword: z.string().min(1), - confirmPassword: z.string().min(1), -}); - -const ChangePasswordSchema = ChangePasswordFieldsSchema.omit({ - currentPassword: true, -}).required(); - const ChangePasswordMutation = graphql(` - mutation ChangePassword($input: ResetPasswordInput!) { + mutation ChangePasswordMutation($input: ResetPasswordInput!) { customer { resetPassword(input: $input) { __typename @@ -34,29 +25,26 @@ const ChangePasswordMutation = graphql(` } `); -interface ChangePasswordResponse { - status: 'success' | 'error'; - message: string; -} +export async function changePassword( + { token, customerEntityId }: { token: string; customerEntityId: string }, + _prevState: { lastResult: SubmissionResult | null; successMessage?: string }, + formData: FormData, +) { + const t = await getTranslations('Auth.ChangePassword'); + const submission = parseWithZod(formData, { schema }); -export const changePassword = async (formData: FormData): Promise => { - const t = await getTranslations('ChangePassword'); + if (submission.status !== 'success') { + return { lastResult: submission.reply() }; + } try { - const parsedData = ChangePasswordSchema.parse({ - customerId: formData.get('customer-id'), - customerToken: formData.get('customer-token'), - newPassword: formData.get('new-password'), - confirmPassword: formData.get('confirm-password'), - }); - const response = await client.fetch({ document: ChangePasswordMutation, variables: { input: { - token: parsedData.customerToken, - customerEntityId: Number(parsedData.customerId), - newPassword: parsedData.newPassword, + token, + customerEntityId: Number(customerEntityId), + newPassword: submission.value.password, }, }, fetchOptions: { @@ -67,23 +55,35 @@ export const changePassword = async (formData: FormData): Promise 0) { - result.errors.forEach((error) => { - throw new Error(error.message); - }); + return { + lastResult: submission.reply({ formErrors: result.errors.map((error) => error.message) }), + }; } return { - status: 'success', - message: t('confirmChangePassword'), + lastResult: submission.reply(), + successMessage: t('passwordUpdated'), }; - } catch (error: unknown) { - if (error instanceof Error || error instanceof ZodError) { + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), + }; + } + + if (error instanceof Error) { return { - status: 'error', - message: error.message, + lastResult: submission.reply({ formErrors: [error.message] }), }; } - return { status: 'error', message: t('Errors.error') }; + return { + lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }), + }; } -}; +} diff --git a/core/app/[locale]/(default)/(auth)/change-password/_components/change-password-form.tsx b/core/app/[locale]/(default)/(auth)/change-password/_components/change-password-form.tsx deleted file mode 100644 index e6b3a1484d..0000000000 --- a/core/app/[locale]/(default)/(auth)/change-password/_components/change-password-form.tsx +++ /dev/null @@ -1,136 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import { toast } from 'react-hot-toast'; - -import { Button } from '~/components/ui/button'; -import { - Field, - FieldControl, - FieldLabel, - FieldMessage, - Form, - FormSubmit, - Input, -} from '~/components/ui/form'; -import { useRouter } from '~/i18n/routing'; - -import { changePassword } from '../_actions/change-password'; - -interface Props { - customerId: string; - customerToken: string; -} - -const SubmitButton = () => { - const t = useTranslations('ChangePassword.Form'); - - const { pending } = useFormStatus(); - - return ( - - ); -}; - -export const ChangePasswordForm = ({ customerId, customerToken }: Props) => { - const t = useTranslations('ChangePassword.Form'); - - const router = useRouter(); - - const [newPassword, setNewPasssword] = useState(''); - const [isConfirmPasswordValid, setIsConfirmPasswordValid] = useState(true); - - const handleNewPasswordChange = (e: ChangeEvent) => - setNewPasssword(e.target.value); - - const handleConfirmPasswordValidation = (e: ChangeEvent) => { - const confirmPassword = e.target.value; - - setIsConfirmPasswordValid(confirmPassword === newPassword); - }; - - const handleChangePassword = async (formData: FormData) => { - const { status, message } = await changePassword(formData); - - if (status === 'error') { - toast.error(message, { - icon: , - }); - - return; - } - - toast.success(message, { - icon: , - }); - - router.push('/login'); - }; - - return ( -
- - - - - - - - - - - - - {t('newPasswordLabel')} - - - - - - - - - {t('confirmPasswordLabel')} - - - - - value !== newPassword} - > - {t('confirmPasswordValidationMessage')} - - - - - - -
- ); -}; diff --git a/core/app/[locale]/(default)/(auth)/change-password/page.tsx b/core/app/[locale]/(default)/(auth)/change-password/page.tsx index f881749983..944f091c6c 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/change-password/page.tsx @@ -1,43 +1,48 @@ -import { getLocale, getTranslations } from 'next-intl/server'; +/* eslint-disable react/jsx-no-bind */ +import { Metadata } from 'next'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { ResetPasswordSection } from '@/vibes/soul/sections/reset-password-section'; import { redirect } from '~/i18n/routing'; -import { ChangePasswordForm } from './_components/change-password-form'; - -export async function generateMetadata() { - const t = await getTranslations('ChangePassword'); - - return { - title: t('title'), - }; -} +import { changePassword } from './_actions/change-password'; interface Props { + params: Promise<{ locale: string }>; searchParams: Promise<{ c?: string; t?: string; }>; } -export default async function ChangePassword({ searchParams }: Props) { - const { c: customerId, t: customerToken } = await searchParams; - const t = await getTranslations('ChangePassword'); - const locale = await getLocale(); +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; - if (!customerId || !customerToken) { - redirect({ href: '/login', locale }); - } + const t = await getTranslations({ locale, namespace: 'Auth.ChangePassword' }); + + return { + title: t('title'), + }; +} - if (customerId && customerToken) { - return ( -
-

{t('heading')}

- -
- ); +export default async function ChangePassword({ params, searchParams }: Props) { + const { locale } = await params; + + setRequestLocale(locale); + + const { c: customerEntityId, t: token } = await searchParams; + const t = await getTranslations('Auth.ChangePassword'); + + if (!customerEntityId || !token) { + return redirect({ href: '/login', locale }); } - return null; + return ( + + ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/(auth)/layout.tsx b/core/app/[locale]/(default)/(auth)/layout.tsx index 7a1de0e3da..cdccae9970 100644 --- a/core/app/[locale]/(default)/(auth)/layout.tsx +++ b/core/app/[locale]/(default)/(auth)/layout.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren } from 'react'; -import { auth } from '~/auth'; +import { isLoggedIn } from '~/auth'; import { redirect } from '~/i18n/routing'; interface Props extends PropsWithChildren { @@ -8,10 +8,10 @@ interface Props extends PropsWithChildren { } export default async function Layout({ children, params }: Props) { - const session = await auth(); + const loggedIn = await isLoggedIn(); const { locale } = await params; - if (session) { + if (loggedIn) { redirect({ href: '/account/orders', locale }); } diff --git a/core/app/[locale]/(default)/(auth)/login/_actions/login.ts b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts index d71be265ea..608575dafe 100644 --- a/core/app/[locale]/(default)/(auth)/login/_actions/login.ts +++ b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts @@ -1,42 +1,60 @@ 'use server'; -import { unstable_rethrow as rethrow } from 'next/navigation'; -import { getLocale } from 'next-intl/server'; - -import { Credentials, signIn } from '~/auth'; +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { AuthError } from 'next-auth'; +import { getLocale, getTranslations } from 'next-intl/server'; + +import { schema } from '@/vibes/soul/sections/sign-in-section/schema'; +import { signIn } from '~/auth'; import { redirect } from '~/i18n/routing'; +import { getCartId } from '~/lib/cart'; -interface LoginResponse { - status: 'success' | 'error'; -} +export const login = async ( + { redirectTo }: { redirectTo: string }, + _lastResult: SubmissionResult | null, + formData: FormData, +) => { + const locale = await getLocale(); + const t = await getTranslations('Auth.Login'); + const cartId = await getCartId(); -export const login = async (formData: FormData): Promise => { - try { - const locale = await getLocale(); + const submission = parseWithZod(formData, { schema }); - const credentials = Credentials.parse({ - type: 'password', - email: formData.get('email'), - password: formData.get('password'), - }); + if (submission.status !== 'success') { + return submission.reply(); + } - await signIn('credentials', { - ...credentials, - // We want to use next/navigation for the redirect as it - // follows basePath and trailing slash configurations. + try { + await signIn('password', { + email: submission.value.email, + password: submission.value.password, + cartId, redirect: false, }); - - redirect({ href: '/account/orders', locale }); - - return { - status: 'success', - }; - } catch (error: unknown) { - rethrow(error); - - return { - status: 'error', - }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }); + } + + if ( + error instanceof AuthError && + error.type === 'CallbackRouteError' && + error.cause && + error.cause.err instanceof BigCommerceGQLError && + error.cause.err.message.includes('Invalid credentials') + ) { + return submission.reply({ formErrors: [t('invalidCredentials')] }); + } + + return submission.reply({ formErrors: [t('somethingWentWrong')] }); } + + return redirect({ href: redirectTo, locale }); }; diff --git a/core/app/[locale]/(default)/(auth)/login/_components/login-form.tsx b/core/app/[locale]/(default)/(auth)/login/_components/login-form.tsx deleted file mode 100644 index 85df228be3..0000000000 --- a/core/app/[locale]/(default)/(auth)/login/_components/login-form.tsx +++ /dev/null @@ -1,131 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import { toast } from 'react-hot-toast'; - -import { Link } from '~/components/link'; -import { Button } from '~/components/ui/button'; -import { - Field, - FieldControl, - FieldLabel, - FieldMessage, - Form, - FormSubmit, - Input, -} from '~/components/ui/form'; - -import { login } from '../_actions/login'; - -const SubmitButton = () => { - const { pending } = useFormStatus(); - const t = useTranslations('Login'); - - return ( - - ); -}; - -export const LoginForm = () => { - const t = useTranslations('Login'); - - const [isEmailValid, setIsEmailValid] = useState(true); - const [isPasswordValid, setIsPasswordValid] = useState(true); - - const handleInputValidation = (e: ChangeEvent) => { - const validationStatus = e.target.validity.valueMissing; - - switch (e.target.name) { - case 'email': { - setIsEmailValid(!validationStatus); - - return; - } - - case 'password': { - setIsPasswordValid(!validationStatus); - } - } - }; - - const handleLogin = async (formData: FormData) => { - const { status } = await login(formData); - - if (status === 'error') { - toast.error(t('Form.error'), { - icon: , - }); - - return; - } - - toast.success(t('Form.successful'), { - icon: , - }); - }; - - return ( -
- - {t('Form.emailLabel')} - - - - - {t('Form.enterEmailMessage')} - - - - {t('Form.passwordLabel')} - - - - - {t('Form.enterPasswordMessage')} - - -
- - - - - {t('Form.forgotPassword')} - -
-
- ); -}; diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts b/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts index 0eb123c565..3001d9b27d 100644 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts +++ b/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts @@ -1,19 +1,18 @@ 'use server'; +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; import { getTranslations } from 'next-intl/server'; -import { z } from 'zod'; +import { schema } from '@/vibes/soul/sections/forgot-password-section/schema'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; -const ResetPasswordSchema = z.object({ - email: z.string().email(), -}); - const ResetPasswordMutation = graphql(` - mutation ResetPassword($input: RequestResetPasswordInput!, $reCaptcha: ReCaptchaV2Input) { + mutation ResetPasswordMutation($input: RequestResetPasswordInput!) { customer { - requestResetPassword(input: $input, reCaptchaV2: $reCaptcha) { + requestResetPassword(input: $input) { __typename errors { __typename @@ -26,31 +25,26 @@ const ResetPasswordMutation = graphql(` } `); -interface SubmitResetPasswordResponse { - status: 'success' | 'error'; - message: string; -} - export const resetPassword = async ( + _lastResult: { lastResult: SubmissionResult | null; successMessage?: string }, formData: FormData, - path: string, - reCaptchaToken?: string, -): Promise => { - const t = await getTranslations('Login.ForgotPassword'); +): Promise<{ lastResult: SubmissionResult | null; successMessage?: string }> => { + const t = await getTranslations('Auth.Login.ForgotPassword'); - try { - const parsedData = ResetPasswordSchema.parse({ - email: formData.get('email'), - }); + const submission = parseWithZod(formData, { schema }); + + if (submission.status !== 'success') { + return { lastResult: submission.reply() }; + } + try { const response = await client.fetch({ document: ResetPasswordMutation, variables: { input: { - email: parsedData.email, - path, + email: submission.value.email, + path: '/change-password', }, - ...(reCaptchaToken && { reCaptchaV2: { token: reCaptchaToken } }), }, fetchOptions: { cache: 'no-store', @@ -60,23 +54,31 @@ export const resetPassword = async ( const result = response.data.customer.requestResetPassword; if (result.errors.length > 0) { - result.errors.forEach((error) => { - throw new Error(error.message); - }); + return { + lastResult: submission.reply({ formErrors: result.errors.map((error) => error.message) }), + }; } return { - status: 'success', - message: t('Form.confirmResetPassword', { email: parsedData.email }), + lastResult: submission.reply(), + successMessage: t('confirmResetPassword', { email: submission.value.email }), }; - } catch (error: unknown) { - if (error instanceof Error || error instanceof z.ZodError) { + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { return { - status: 'error', - message: error.message, + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), }; } - return { status: 'error', message: t('Errors.error') }; + if (error instanceof Error) { + return { lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }) }; } }; diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/fragment.ts b/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/fragment.ts deleted file mode 100644 index c72ffe9bdd..0000000000 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/fragment.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { graphql } from '~/client/graphql'; - -export const ResetPasswordFormFragment = graphql(` - fragment ResetPasswordFormFragment on ReCaptchaSettings { - isEnabledOnStorefront - siteKey - } -`); diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/index.tsx b/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/index.tsx deleted file mode 100644 index c039c4a40b..0000000000 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/_components/reset-password-form/index.tsx +++ /dev/null @@ -1,155 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, useRef, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import ReCaptcha from 'react-google-recaptcha'; -import { toast } from 'react-hot-toast'; - -import { type FragmentOf } from '~/client/graphql'; -import { Button } from '~/components/ui/button'; -import { - Field, - FieldControl, - FieldLabel, - FieldMessage, - Form, - FormSubmit, - Input, -} from '~/components/ui/form'; -import { useRouter } from '~/i18n/routing'; - -import { resetPassword } from '../../_actions/reset-password'; - -import { ResetPasswordFormFragment } from './fragment'; - -interface Props { - reCaptchaSettings?: FragmentOf; -} - -const SubmitButton = () => { - const t = useTranslations('Login.ForgotPassword.Form'); - - const { pending } = useFormStatus(); - - return ( - - ); -}; - -export const ResetPasswordForm = ({ reCaptchaSettings }: Props) => { - const t = useTranslations('Login.ForgotPassword.Form'); - - const form = useRef(null); - const [isEmailValid, setIsEmailValid] = useState(true); - - const reCaptchaRef = useRef(null); - const [reCaptchaToken, setReCaptchaToken] = useState(); - const router = useRouter(); - - const isReCaptchaValid = Boolean(reCaptchaToken); - - const onReCatpchaChange = (token: string | null) => { - if (!token) { - setReCaptchaToken(undefined); - - return; - } - - setReCaptchaToken(token); - }; - - const handleEmailValidation = (e: ChangeEvent) => { - const validationStatus = e.target.validity.valueMissing || e.target.validity.typeMismatch; - - setIsEmailValid(!validationStatus); - }; - - const onSubmit = async (formData: FormData) => { - if (reCaptchaSettings?.isEnabledOnStorefront && !isReCaptchaValid) { - return; - } - - const { status, message } = await resetPassword(formData, '/change-password', reCaptchaToken); - - if (status === 'error') { - reCaptchaRef.current?.reset(); - - toast.error(message, { - icon: , - }); - - return; - } - - toast.success(message, { - icon: , - }); - - form.current?.reset(); - router.push('/login'); - }; - - return ( - <> -

{t('description')}

- -
- - {t('emailLabel')} - - - - - {t('emailValidationMessage')} - - - {t('emailValidationMessage')} - - - - {reCaptchaSettings?.isEnabledOnStorefront && ( - - - {!isReCaptchaValid && ( - - {t('recaptchaText')} - - )} - - )} - - - - -
- - ); -}; diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx b/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx index df5798f257..58dd54691f 100644 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx @@ -1,52 +1,32 @@ -import { getTranslations } from 'next-intl/server'; - -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; -import { revalidate } from '~/client/revalidate-target'; -import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; - -import { ResetPasswordForm } from './_components/reset-password-form'; -import { ResetPasswordFormFragment } from './_components/reset-password-form/fragment'; - -const ResetPageQuery = graphql( - ` - query ResetPageQuery { - site { - settings { - reCaptcha { - ...ResetPasswordFormFragment - } - } - } - } - `, - [ResetPasswordFormFragment], -); - -export async function generateMetadata() { - const t = await getTranslations('Login.ForgotPassword'); +import { Metadata } from 'next'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; + +import { ForgotPasswordSection } from '@/vibes/soul/sections/forgot-password-section'; + +import { resetPassword } from './_actions/reset-password'; + +interface Props { + params: Promise<{ locale: string }>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + + const t = await getTranslations({ locale, namespace: 'Auth.Login.ForgotPassword' }); return { title: t('title'), }; } -export default async function Reset() { - const t = await getTranslations('Login.ForgotPassword'); +export default async function Reset(props: Props) { + const { locale } = await props.params; - const { data } = await client.fetch({ - document: ResetPageQuery, - fetchOptions: { next: { revalidate } }, - }); + setRequestLocale(locale); - const recaptchaSettings = await bypassReCaptcha(data.site.settings?.reCaptcha); + const t = await getTranslations('Auth.Login.ForgotPassword'); return ( -
-

{t('heading')}

- -
+ ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/(auth)/login/page.tsx b/core/app/[locale]/(default)/(auth)/login/page.tsx index 59d2078ed8..d6bab2ddd6 100644 --- a/core/app/[locale]/(default)/(auth)/login/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/page.tsx @@ -1,53 +1,81 @@ +/* eslint-disable react/jsx-no-bind */ +import { Metadata } from 'next'; import { getTranslations, setRequestLocale } from 'next-intl/server'; -import { Link } from '~/components/link'; -import { Button } from '~/components/ui/button'; +import { ButtonLink } from '@/vibes/soul/primitives/button-link'; +import { SignInSection } from '@/vibes/soul/sections/sign-in-section'; +import { buildConfig } from '~/build-config/reader'; +import { ForceRefresh } from '~/components/force-refresh'; +import { Slot } from '~/lib/makeswift/slot'; -import { LoginForm } from './_components/login-form'; +import { login } from './_actions/login'; -export async function generateMetadata({ params }: Props) { - const { locale } = await params; +interface Props { + params: Promise<{ locale: string }>; + searchParams: Promise<{ + redirectTo?: string; + }>; +} - setRequestLocale(locale); +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; - const t = await getTranslations('Login'); + const t = await getTranslations({ locale, namespace: 'Auth.Login' }); return { title: t('title'), }; } -interface Props { - params: Promise<{ locale: string }>; -} - -export default async function Login({ params }: Props) { +export default async function Login({ params, searchParams }: Props) { const { locale } = await params; + const { redirectTo = '/account/orders' } = await searchParams; setRequestLocale(locale); - const t = await getTranslations('Login'); + const t = await getTranslations('Auth.Login'); + + const vanityUrl = buildConfig.get('urls').vanityUrl; + const redirectUrl = new URL(redirectTo, vanityUrl); + const redirectTarget = redirectUrl.pathname + redirectUrl.search; return ( -
-

{t('heading')}

-
- -
-

{t('CreateAccount.heading')}

-

{t('CreateAccount.accountBenefits')}

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

+ {t('CreateAccount.title')} +

+
+

{t('CreateAccount.accountBenefits')}

+
    +
  • {t('CreateAccount.fastCheckout')}
  • +
  • {t('CreateAccount.multipleAddresses')}
  • +
  • {t('CreateAccount.ordersHistory')}
  • +
  • {t('CreateAccount.ordersTracking')}
  • +
  • {t('CreateAccount.wishlists')}
  • +
+ + {t('CreateAccount.cta')} + +
+ + } + label="Login sidebar content" + snapshotId="login-sidebar-content" + /> +
+ ); } diff --git a/core/app/login/token/[token]/route.ts b/core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts similarity index 78% rename from core/app/login/token/[token]/route.ts rename to core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts index a7f1df4734..8c408056c5 100644 --- a/core/app/login/token/[token]/route.ts +++ b/core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts @@ -9,13 +9,11 @@ import { decodeJwt } from 'jose'; import { redirect, unstable_rethrow as rethrow } from 'next/navigation'; import { signIn } from '~/auth'; +import { getCartId } from '~/lib/cart'; -interface TokenParams { - params: Promise<{ token: string }>; -} - -export async function GET(request: Request, { params }: TokenParams) { - const token = (await params).token; +export async function GET(_: Request, { params }: { params: Promise<{ token: string }> }) { + const { token } = await params; + const cartId = await getCartId(); try { // decode token without checking signature to get redirect path @@ -27,11 +25,7 @@ export async function GET(request: Request, { params }: TokenParams) { // sign in with token which will check validity against BigCommerce API // and redirect to redirectTo - await signIn('credentials', { - type: 'jwt', - jwt: token, - redirectTo, - }); + await signIn('jwt', { jwt: token, cartId, redirectTo }); } catch (error) { rethrow(error); @@ -39,5 +33,4 @@ export async function GET(request: Request, { params }: TokenParams) { } } -export const runtime = 'edge'; export const dynamic = 'force-dynamic'; diff --git a/core/app/[locale]/(default)/(auth)/logout/route.ts b/core/app/[locale]/(default)/(auth)/logout/route.ts new file mode 100644 index 0000000000..713cc99acb --- /dev/null +++ b/core/app/[locale]/(default)/(auth)/logout/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from 'next/server'; + +import { signOut } from '~/auth'; +import { redirect } from '~/i18n/routing'; +import { setForceRefreshCookie } from '~/lib/force-refresh'; + +export const GET = async ( + request: NextRequest, + { params }: { params: Promise<{ locale: string }> }, +) => { + const { locale } = await params; + const redirectTo = request.nextUrl.searchParams.get('redirectTo') ?? '/login'; + const redirectToPathname = new URL(redirectTo, request.nextUrl.origin).pathname; + + await signOut({ redirect: false }); + await setForceRefreshCookie(); + + redirect({ href: redirectToPathname, locale }); +}; diff --git a/core/app/[locale]/(default)/(auth)/register/_actions/login.ts b/core/app/[locale]/(default)/(auth)/register/_actions/login.ts deleted file mode 100644 index 2b6ebd6528..0000000000 --- a/core/app/[locale]/(default)/(auth)/register/_actions/login.ts +++ /dev/null @@ -1,33 +0,0 @@ -'use server'; - -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'), - }); - - await signIn('credentials', { - ...credentials, - // We want to use next/navigation for the redirect as it - // follows basePath and trailing slash configurations. - redirect: false, - }); - - redirect({ href: '/account/orders', locale }); - } catch (error: unknown) { - rethrow(error); - - return { - status: 'error', - }; - } -}; diff --git a/core/app/[locale]/(default)/(auth)/register/_actions/prefixes.ts b/core/app/[locale]/(default)/(auth)/register/_actions/prefixes.ts new file mode 100644 index 0000000000..239c5a3924 --- /dev/null +++ b/core/app/[locale]/(default)/(auth)/register/_actions/prefixes.ts @@ -0,0 +1,2 @@ +export const ADDRESS_FIELDS_NAME_PREFIX = 'customAddress_'; +export const CUSTOMER_FIELDS_NAME_PREFIX = 'customCustomer_'; diff --git a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts index 23de2ae837..7b3baf2489 100644 --- a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts +++ b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts @@ -1,16 +1,25 @@ 'use server'; -import { BigCommerceAPIError } from '@bigcommerce/catalyst-client'; -import { getTranslations } from 'next-intl/server'; +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { getLocale, getTranslations } from 'next-intl/server'; +import { z } from 'zod'; +import { Field, FieldGroup, schema } from '@/vibes/soul/form/dynamic-form/schema'; +import { signIn } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; -import { parseRegisterCustomerFormData } from '~/components/form-fields/shared/parse-fields'; +import { FieldNameToFieldId } from '~/data-transformers/form-field-transformer/utils'; +import { redirect } from '~/i18n/routing'; +import { getCartId } from '~/lib/cart'; + +import { ADDRESS_FIELDS_NAME_PREFIX, CUSTOMER_FIELDS_NAME_PREFIX } from './prefixes'; const RegisterCustomerMutation = graphql(` - mutation RegisterCustomer($input: RegisterCustomerInput!, $reCaptchaV2: ReCaptchaV2Input) { + mutation RegisterCustomerMutation($input: RegisterCustomerInput!) { customer { - registerCustomer(input: $input, reCaptchaV2: $reCaptchaV2) { + registerCustomer(input: $input) { customer { firstName lastName @@ -34,74 +43,368 @@ const RegisterCustomerMutation = graphql(` } `); -type Variables = VariablesOf; -type RegisterCustomerInput = Variables['input']; +const stringToNumber = z.string().pipe(z.coerce.number()); -const isRegisterCustomerInput = (data: unknown): data is RegisterCustomerInput => { - if (typeof data === 'object' && data !== null && 'email' in data) { - return true; - } +const inputSchema = z.object({ + firstName: z.string(), + lastName: z.string(), + email: z.string(), + password: z.string(), + phone: z.string().optional(), + company: z.string().optional(), + address: z + .object({ + firstName: z.string(), + lastName: z.string(), + address1: z.string(), + address2: z.string().optional(), + city: z.string(), + company: z.string().optional(), + countryCode: z.string(), + stateOrProvince: z.string().optional(), + phone: z.string().optional(), + postalCode: z.string().optional(), + formFields: z.object({ + checkboxes: z.array( + z.object({ + fieldEntityId: stringToNumber, + fieldValueEntityIds: z.array(stringToNumber), + }), + ), + multipleChoices: z.array( + z.object({ + fieldEntityId: stringToNumber, + fieldValueEntityId: stringToNumber, + }), + ), + numbers: z.array( + z.object({ + fieldEntityId: stringToNumber, + number: stringToNumber, + }), + ), + dates: z.array( + z.object({ + fieldEntityId: stringToNumber, + date: z.string(), + }), + ), + passwords: z.array( + z.object({ + fieldEntityId: stringToNumber, + password: z.string(), + }), + ), + multilineTexts: z.array( + z.object({ + fieldEntityId: stringToNumber, + multilineText: z.string(), + }), + ), + texts: z.array( + z.object({ + fieldEntityId: stringToNumber, + text: z.string(), + }), + ), + }), + }) + .optional(), + formFields: z.object({ + checkboxes: z.array( + z.object({ + fieldEntityId: stringToNumber, + fieldValueEntityIds: z.array(stringToNumber), + }), + ), + multipleChoices: z.array( + z.object({ + fieldEntityId: stringToNumber, + fieldValueEntityId: stringToNumber, + }), + ), + numbers: z.array( + z.object({ + fieldEntityId: stringToNumber, + number: stringToNumber, + }), + ), + dates: z.array( + z.object({ + fieldEntityId: stringToNumber, + date: z.string(), + }), + ), + passwords: z.array( + z.object({ + fieldEntityId: stringToNumber, + password: z.string(), + }), + ), + multilineTexts: z.array( + z.object({ + fieldEntityId: stringToNumber, + multilineText: z.string(), + }), + ), + texts: z.array( + z.object({ + fieldEntityId: stringToNumber, + text: z.string(), + }), + ), + }), +}); + +function parseRegisterCustomerInput( + value: Record, + fields: Array>, +): VariablesOf['input'] { + const customFields = fields + .flatMap((f) => (Array.isArray(f) ? f : [f])) + .filter( + (field) => + ![ + String(FieldNameToFieldId.email), + String(FieldNameToFieldId.password), + String(FieldNameToFieldId.confirmPassword), + String(FieldNameToFieldId.firstName), + String(FieldNameToFieldId.lastName), + String(FieldNameToFieldId.address1), + String(FieldNameToFieldId.address2), + String(FieldNameToFieldId.city), + String(FieldNameToFieldId.company), + String(FieldNameToFieldId.countryCode), + String(FieldNameToFieldId.stateOrProvince), + String(FieldNameToFieldId.phone), + String(FieldNameToFieldId.postalCode), + ].includes(field.name), + ); + + const customAddressFields = customFields.filter((field) => + field.name.startsWith(ADDRESS_FIELDS_NAME_PREFIX), + ); + const customCustomerFields = customFields.filter((field) => + field.name.startsWith(CUSTOMER_FIELDS_NAME_PREFIX), + ); - return false; -}; + const mappedInput = { + firstName: value.firstName, + lastName: value.lastName, + email: value.email, + password: value.password, + phone: value.phone, + company: value.company, + address: { + firstName: value.firstName, + lastName: value.lastName, + address1: value.address1, + address2: value.address2, + city: value.city, + company: value.company, + countryCode: value.countryCode, + stateOrProvince: value.stateOrProvince, + phone: value.phone, + postalCode: value.postalCode, + formFields: { + checkboxes: customAddressFields + .filter((field) => ['checkbox-group'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.id, + fieldValueEntityIds: Array.isArray(value[field.name]) + ? value[field.name] + : [value[field.name]], + }; + }), + multipleChoices: customAddressFields + .filter((field) => ['radio-group', 'button-radio-group'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.id, + fieldValueEntityId: value[field.name], + }; + }), + numbers: customAddressFields + .filter((field) => ['number'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.id, + number: value[field.name], + }; + }), + dates: customAddressFields + .filter((field) => ['date'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.id, + date: new Date(String(value[field.name])).toISOString(), + }; + }), + passwords: customAddressFields + .filter((field) => ['password'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: field.id, + password: value[field.name], + })), + multilineTexts: customAddressFields + .filter((field) => ['textarea'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: field.id, + multilineText: value[field.name], + })), + texts: customAddressFields + .filter((field) => ['text'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: field.id, + text: value[field.name], + })), + }, + }, + formFields: { + checkboxes: customCustomerFields + .filter((field) => ['checkbox-group'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.id, + fieldValueEntityIds: Array.isArray(value[field.name]) + ? value[field.name] + : [value[field.name]], + }; + }), + multipleChoices: customCustomerFields + .filter((field) => ['radio-group', 'button-radio-group'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.id, + fieldValueEntityId: value[field.name], + }; + }), + numbers: customCustomerFields + .filter((field) => ['number'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.id, + number: value[field.name], + }; + }), + dates: customCustomerFields + .filter((field) => ['date'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => { + return { + fieldEntityId: field.id, + date: new Date(String(value[field.name])).toISOString(), + }; + }), + passwords: customCustomerFields + .filter((field) => ['password'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: field.id, + password: value[field.name], + })), + multilineTexts: customCustomerFields + .filter((field) => ['textarea'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: field.id, + multilineText: value[field.name], + })), + texts: customCustomerFields + .filter((field) => ['text'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: field.id, + text: value[field.name], + })), + }, + }; -interface RegisterCustomerResponse { - status: 'success' | 'error'; - message: string; + return inputSchema.parse(mappedInput); } -export const registerCustomer = async ( +export async function registerCustomer( + prevState: { lastResult: SubmissionResult | null; fields: Array> }, formData: FormData, - reCaptchaToken?: string, -): Promise => { - const t = await getTranslations('Register'); - - formData.delete('customer-confirmPassword'); +) { + const t = await getTranslations('Auth.Register'); + const locale = await getLocale(); + const cartId = await getCartId(); - const parsedData = parseRegisterCustomerFormData(formData); + const submission = parseWithZod(formData, { schema: schema(prevState.fields) }); - if (!isRegisterCustomerInput(parsedData)) { + if (submission.status !== 'success') { return { - status: 'error', - message: t('Errors.inputError'), + lastResult: submission.reply(), + fields: prevState.fields, }; } try { + const input = parseRegisterCustomerInput(submission.value, prevState.fields); const response = await client.fetch({ document: RegisterCustomerMutation, variables: { - input: parsedData, - ...(reCaptchaToken && { reCaptchaV2: { token: reCaptchaToken } }), - }, - fetchOptions: { - cache: 'no-store', + input, }, + fetchOptions: { cache: 'no-store' }, }); const result = response.data.customer.registerCustomer; if (result.errors.length > 0) { - result.errors.forEach((error) => { - throw new Error(error.message); - }); + return { + lastResult: submission.reply({ + formErrors: response.data.customer.registerCustomer.errors.map((error) => error.message), + }), + fields: prevState.fields, + }; } - return { status: 'success', message: t('Form.successMessage') }; + await signIn('password', { + email: input.email, + password: input.password, + cartId, + // We want to use next/navigation for the redirect as it + // follows basePath and trailing slash configurations. + redirect: false, + }); } catch (error) { // eslint-disable-next-line no-console console.error(error); - if (error instanceof BigCommerceAPIError) { + if (error instanceof BigCommerceGQLError) { + return { + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), + fields: prevState.fields, + }; + } + + if (error instanceof Error) { return { - status: 'error', - message: t('Errors.apiError'), + lastResult: submission.reply({ formErrors: [error.message] }), + fields: prevState.fields, }; } return { - status: 'error', - message: t('Errors.error'), + lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }), + fields: prevState.fields, }; } -}; + + return redirect({ href: '/account/orders', locale }); +} diff --git a/core/app/[locale]/(default)/(auth)/register/_components/register-customer-form.tsx b/core/app/[locale]/(default)/(auth)/register/_components/register-customer-form.tsx deleted file mode 100644 index 573ace62fc..0000000000 --- a/core/app/[locale]/(default)/(auth)/register/_components/register-customer-form.tsx +++ /dev/null @@ -1,394 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, MouseEvent, useRef, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import ReCaptcha from 'react-google-recaptcha'; -import { toast } from 'react-hot-toast'; - -import { ExistingResultType } from '~/client/util'; -import { - Checkboxes, - createFieldName, - CUSTOMER_FIELDS_TO_EXCLUDE, - DateField, - FieldNameToFieldId, - FieldWrapper, - FULL_NAME_FIELDS, - MultilineText, - NumbersOnly, - Password, - Picklist, - RadioButtons, - Text, -} from '~/components/form-fields'; -import { - createDatesValidationHandler, - createMultilineTextValidationHandler, - createNumbersInputValidationHandler, - createPreSubmitCheckboxesValidationHandler, - createPreSubmitPicklistValidationHandler, - createRadioButtonsValidationHandler, - isAddressOrAccountFormField, -} from '~/components/form-fields/shared/field-handlers'; -import { Button } from '~/components/ui/button'; -import { Field, Form, FormSubmit } from '~/components/ui/form'; - -import { login } from '../_actions/login'; -import { registerCustomer } from '../_actions/register-customer'; -import { getRegisterCustomerQuery } from '../page-data'; - -type CustomerFields = ExistingResultType['customerFields']; -type AddressFields = ExistingResultType['addressFields']; - -interface RegisterCustomerProps { - addressFields: AddressFields; - customerFields: CustomerFields; - reCaptchaSettings?: { - isEnabledOnStorefront: boolean; - siteKey: string; - }; -} - -interface SumbitMessages { - messages: { - submit: string; - submitting: string; - }; -} - -const SubmitButton = ({ messages }: SumbitMessages) => { - const { pending } = useFormStatus(); - - return ( - - ); -}; - -export const RegisterCustomerForm = ({ - addressFields, - customerFields, - reCaptchaSettings, -}: RegisterCustomerProps) => { - const form = useRef(null); - - const [textInputValid, setTextInputValid] = useState>({}); - const [passwordValid, setPassswordValid] = useState>({ - [FieldNameToFieldId.password]: true, - [FieldNameToFieldId.confirmPassword]: true, - }); - const [numbersInputValid, setNumbersInputValid] = useState>({}); - const [datesValid, setDatesValid] = useState>({}); - const [radioButtonsValid, setRadioButtonsValid] = useState>({}); - const [picklistValid, setPicklistValid] = useState>({}); - const [checkboxesValid, setCheckboxesValid] = useState>({}); - const [multiTextValid, setMultiTextValid] = useState>({}); - - const reCaptchaRef = useRef(null); - const [reCaptchaToken, setReCaptchaToken] = useState(''); - const [isReCaptchaValid, setReCaptchaValid] = useState(true); - - const t = useTranslations('Register.Form'); - - const handleTextInputValidation = (e: ChangeEvent) => { - const fieldId = Number(e.target.id.split('-')[1]); - - const validityState = e.target.validity; - const validationStatus = validityState.valueMissing || validityState.typeMismatch; - - setTextInputValid({ ...textInputValid, [fieldId]: !validationStatus }); - }; - const handleNumbersInputValidation = createNumbersInputValidationHandler( - setNumbersInputValid, - numbersInputValid, - ); - const handleMultiTextValidation = createMultilineTextValidationHandler( - setMultiTextValid, - multiTextValid, - ); - const handleDatesValidation = createDatesValidationHandler(setDatesValid, datesValid); - const handlePasswordValidation = (e: ChangeEvent) => { - const fieldId = e.target.id.split('-')[1] ?? ''; - - switch (FieldNameToFieldId[Number(fieldId)]) { - case 'password': { - setPassswordValid((prevState) => ({ - ...prevState, - [fieldId]: !e.target.validity.valueMissing, - })); - - return; - } - - case 'confirmPassword': { - const confirmPassword = e.target.value; - const field = customerFields.find( - ({ entityId }) => entityId === Number(FieldNameToFieldId.password), - ); - - if (!isAddressOrAccountFormField(field)) { - return; - } - - const passwordFieldName = createFieldName(field, 'customer'); - const password = new FormData(e.target.form ?? undefined).get(passwordFieldName); - - setPassswordValid((prevState) => ({ - ...prevState, - [fieldId]: password === confirmPassword && !e.target.validity.valueMissing, - })); - - return; - } - - default: { - setPassswordValid((prevState) => ({ - ...prevState, - [fieldId]: !e.target.validity.valueMissing, - })); - } - } - }; - - const handleRadioButtonsChange = createRadioButtonsValidationHandler( - setRadioButtonsValid, - radioButtonsValid, - ); - const validatePicklistFields = createPreSubmitPicklistValidationHandler( - [...customerFields, ...addressFields], - setPicklistValid, - ); - const validateCheckboxFields = createPreSubmitCheckboxesValidationHandler( - [...customerFields, ...addressFields], - setCheckboxesValid, - ); - const preSubmitFieldsValidation = ( - e: MouseEvent & { target: HTMLButtonElement }, - ) => { - if (e.target.nodeName === 'BUTTON' && e.target.type === 'submit') { - validatePicklistFields(form.current); - validateCheckboxFields(form.current); - } - }; - - const onReCaptchaChange = (token: string | null) => { - if (!token) { - setReCaptchaValid(false); - - return; - } - - setReCaptchaToken(token); - setReCaptchaValid(true); - }; - - const onSubmit = async (formData: FormData) => { - if (formData.get('customer-password') !== formData.get('customer-confirmPassword')) { - toast.error(t('confirmPassword'), { - icon: , - }); - - window.scrollTo({ - top: 0, - behavior: 'smooth', - }); - - return; - } - - if (reCaptchaSettings?.isEnabledOnStorefront && !reCaptchaToken) { - setReCaptchaValid(false); - - return; - } - - setReCaptchaValid(true); - - const { status, message } = await registerCustomer(formData, reCaptchaToken); - - if (status === 'error') { - toast.error(message, { - icon: , - }); - - return; - } - - toast.success(message, { - icon: , - }); - - await login(formData); - }; - - return ( -
-
- {addressFields.map((field) => { - const fieldId = field.entityId; - const fieldName = createFieldName(field, 'customer'); - - if (field.__typename === 'TextFormField' && FULL_NAME_FIELDS.includes(fieldId)) { - return ( - - - - ); - } - - return null; - })} -
-
- {customerFields - .filter((field) => !CUSTOMER_FIELDS_TO_EXCLUDE.includes(field.entityId)) - .map((field) => { - const fieldId = field.entityId; - const fieldName = createFieldName(field, 'customer'); - - switch (field.__typename) { - case 'TextFormField': - return ( - - - - ); - - case 'PasswordFormField': - return ( - - - - ); - - case 'MultilineTextFormField': { - return ( - - - - ); - } - - case 'NumberFormField': { - return ( - - - - ); - } - - case 'DateFormField': { - return ( - - - - ); - } - - case 'RadioButtonsFormField': { - return ( - - - - ); - } - - case 'PicklistFormField': { - return ( - - - - ); - } - - case 'CheckboxesFormField': { - return ( - - - - ); - } - - default: - return null; - } - })} - {reCaptchaSettings?.isEnabledOnStorefront && ( - - - {!isReCaptchaValid && ( - - {t('recaptchaText')} - - )} - - )} -
- - - - -
- ); -}; diff --git a/core/app/[locale]/(default)/(auth)/register/page-data.ts b/core/app/[locale]/(default)/(auth)/register/page-data.ts index 919742b8fa..2eb547453d 100644 --- a/core/app/[locale]/(default)/(auth)/register/page-data.ts +++ b/core/app/[locale]/(default)/(auth)/register/page-data.ts @@ -3,8 +3,7 @@ import { cache } from 'react'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; -import { FormFieldsFragment } from '~/components/form-fields/fragment'; -import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; +import { FormFieldsFragment } from '~/data-transformers/form-field-transformer/fragment'; const RegisterCustomerQuery = graphql( ` @@ -25,28 +24,11 @@ const RegisterCustomerQuery = graphql( } } } - settings { - contact { - country - } - reCaptcha { - isEnabledOnStorefront - siteKey - } - } } geography { countries { code - entityId name - __typename - statesOrProvinces { - abbreviation - entityId - name - __typename - } } } } @@ -68,7 +50,7 @@ interface Props { }; } -export const getRegisterCustomerQuery = cache(async ({ address, customer }: Props = {}) => { +export const getRegisterCustomerQuery = cache(async ({ address, customer }: Props) => { const customerAccessToken = await getSessionCustomerAccessToken(); const response = await client.fetch({ @@ -85,13 +67,9 @@ export const getRegisterCustomerQuery = cache(async ({ address, customer }: Prop const addressFields = response.data.site.settings?.formFields.shippingAddress; const customerFields = response.data.site.settings?.formFields.customer; - const countries = response.data.geography.countries; - const defaultCountry = response.data.site.settings?.contact?.country; - - const reCaptchaSettings = await bypassReCaptcha(response.data.site.settings?.reCaptcha); - if (!addressFields || !customerFields || !countries) { + if (!addressFields || !customerFields) { return null; } @@ -99,7 +77,5 @@ export const getRegisterCustomerQuery = cache(async ({ address, customer }: Prop addressFields, customerFields, countries, - defaultCountry, - reCaptchaSettings, }; }); diff --git a/core/app/[locale]/(default)/(auth)/register/page.tsx b/core/app/[locale]/(default)/(auth)/register/page.tsx index 3e272f9247..0d307de76e 100644 --- a/core/app/[locale]/(default)/(auth)/register/page.tsx +++ b/core/app/[locale]/(default)/(auth)/register/page.tsx @@ -1,21 +1,43 @@ +import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; -import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; +import { DynamicFormSection } from '@/vibes/soul/sections/dynamic-form-section'; +import { + formFieldTransformer, + injectCountryCodeOptions, +} from '~/data-transformers/form-field-transformer'; +import { + CUSTOMER_FIELDS_TO_EXCLUDE, + REGISTER_CUSTOMER_FORM_LAYOUT, + transformFieldsToLayout, +} from '~/data-transformers/form-field-transformer/utils'; +import { exists } from '~/lib/utils'; -import { RegisterCustomerForm } from './_components/register-customer-form'; +import { ADDRESS_FIELDS_NAME_PREFIX, CUSTOMER_FIELDS_NAME_PREFIX } from './_actions/prefixes'; +import { registerCustomer } from './_actions/register-customer'; import { getRegisterCustomerQuery } from './page-data'; -export async function generateMetadata() { - const t = await getTranslations('Register'); +interface Props { + params: Promise<{ locale: string }>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + + const t = await getTranslations({ locale, namespace: 'Auth.Register' }); return { title: t('title'), }; } -export default async function Register() { - const t = await getTranslations('Register'); +export default async function Register({ params }: Props) { + const { locale } = await params; + + setRequestLocale(locale); + + const t = await getTranslations('Auth.Register'); const registerCustomerData = await getRegisterCustomerQuery({ address: { sortBy: 'SORT_ORDER' }, @@ -26,19 +48,56 @@ export default async function Register() { notFound(); } - const { addressFields, customerFields, reCaptchaSettings } = registerCustomerData; - const reCaptcha = await bypassReCaptcha(reCaptchaSettings); + const { addressFields, customerFields, countries } = registerCustomerData; + + const fields = transformFieldsToLayout( + [ + ...addressFields.map((field) => { + if (!field.isBuiltIn) { + return { + ...field, + name: `${ADDRESS_FIELDS_NAME_PREFIX}${field.label}`, + }; + } + + return field; + }), + ...customerFields.map((field) => { + if (!field.isBuiltIn) { + return { + ...field, + name: `${CUSTOMER_FIELDS_NAME_PREFIX}${field.label}`, + }; + } + + return field; + }), + ].filter((field) => !CUSTOMER_FIELDS_TO_EXCLUDE.includes(field.entityId)), + REGISTER_CUSTOMER_FORM_LAYOUT, + ) + .map((field) => { + if (Array.isArray(field)) { + return field.map(formFieldTransformer).filter(exists); + } + + return formFieldTransformer(field); + }) + .filter(exists) + .map((field) => { + if (Array.isArray(field)) { + return field.map((f) => injectCountryCodeOptions(f, countries ?? [])); + } + + return injectCountryCodeOptions(field, countries ?? []); + }) + .filter(exists); return ( -
-

{t('heading')}

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

{t('refineBy')}

- {/* TODO: Make subtle variant */} - -
-
    - {refinements.map((refinement) => ( -
  • - removeRefinement(refinement)} /> -
  • - ))} -
-
- ); -}; diff --git a/core/app/[locale]/(default)/(faceted)/_components/sort-by.tsx b/core/app/[locale]/(default)/(faceted)/_components/sort-by.tsx deleted file mode 100644 index 33adc1f932..0000000000 --- a/core/app/[locale]/(default)/(faceted)/_components/sort-by.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import { useSearchParams } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { useTransition } from 'react'; - -import { Select } from '~/components/ui/form'; -import { usePathname, useRouter } from '~/i18n/routing'; - -export function SortBy() { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const [isPending, startTransition] = useTransition(); - - const t = useTranslations('FacetedGroup.SortBy'); - const value = searchParams.get('sort') ?? 'featured'; - - const onSort = (sortValue: string) => { - const params = new URLSearchParams(searchParams); - - params.set('sort', sortValue); - params.delete('before'); - params.delete('after'); - - startTransition(() => { - router.push(`${pathname}?${params.toString()}`); - }); - }; - - return ( -
- - - - {isRequired && ( - <> - - {t(fieldNameById ?? 'empty')} - - - {t(fieldNameById)} - - - )} - - ); -}; diff --git a/core/app/[locale]/(default)/account/settings/_components/update-settings-form.tsx b/core/app/[locale]/(default)/account/settings/_components/update-settings-form.tsx deleted file mode 100644 index 68b778a8c8..0000000000 --- a/core/app/[locale]/(default)/account/settings/_components/update-settings-form.tsx +++ /dev/null @@ -1,381 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, MouseEvent, useRef, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import { toast } from 'react-hot-toast'; - -import { ExistingResultType } from '~/client/util'; -import { - Checkboxes, - createFieldName, - DateField, - FieldWrapper, - getPreviouslySubmittedValue, - MultilineText, - NumbersOnly, - Password, - Picklist, - RadioButtons, -} from '~/components/form-fields'; -import { - createDatesValidationHandler, - createMultilineTextValidationHandler, - createNumbersInputValidationHandler, - createPasswordValidationHandler, - createPreSubmitCheckboxesValidationHandler, - createPreSubmitPicklistValidationHandler, - createRadioButtonsValidationHandler, - createTextInputValidationHandler, -} from '~/components/form-fields/shared/field-handlers'; -import { Link } from '~/components/link'; -import { Button } from '~/components/ui/button'; -import { Form, FormSubmit } from '~/components/ui/form'; - -import { updateCustomer } from '../_actions/update-customer'; -import { getCustomerSettingsQuery } from '../page-data'; - -import { TextField } from './text-field'; - -type CustomerInfo = ExistingResultType['customerInfo']; -type CustomerFields = ExistingResultType['customerFields']; -type AddressFields = ExistingResultType['addressFields']; - -interface FormProps { - addressFields: AddressFields; - customerInfo: CustomerInfo; - customerFields: CustomerFields; -} - -interface SumbitMessages { - messages: { - submit: string; - submitting: string; - }; -} - -export enum FieldNameToFieldId { - email = 1, - firstName = 4, - lastName, -} - -type FieldUnionType = keyof typeof FieldNameToFieldId; - -const isExistedField = (name: unknown): name is FieldUnionType => { - if (typeof name === 'string' && name in FieldNameToFieldId) { - return true; - } - - return false; -}; - -const SubmitButton = ({ messages }: SumbitMessages) => { - const { pending } = useFormStatus(); - - return ( - - ); -}; - -export const UpdateSettingsForm = ({ addressFields, customerFields, customerInfo }: FormProps) => { - const form = useRef(null); - - const [textInputValid, setTextInputValid] = useState>({}); - const [multiTextValid, setMultiTextValid] = useState>({}); - const [numbersInputValid, setNumbersInputValid] = useState>({}); - const [radioButtonsValid, setRadioButtonsValid] = useState>({}); - const [picklistValid, setPicklistValid] = useState>({}); - const [checkboxesValid, setCheckboxesValid] = useState>({}); - const [datesValid, setDatesValid] = useState>({}); - const [passwordValid, setPasswordValid] = useState>({}); - - const t = useTranslations('Account.Settings'); - - const handleTextInputValidation = (e: ChangeEvent) => { - const fieldId = Number(e.target.id.split('-')[1]); - - const validityState = e.target.validity; - const validationStatus = validityState.valueMissing || validityState.typeMismatch; - - setTextInputValid({ ...textInputValid, [fieldId]: !validationStatus }); - }; - const handleMultiTextValidation = createMultilineTextValidationHandler( - setMultiTextValid, - multiTextValid, - ); - const handleNumbersInputValidation = createNumbersInputValidationHandler( - setNumbersInputValid, - numbersInputValid, - ); - const handleDatesValidation = createDatesValidationHandler(setDatesValid, datesValid); - const handleRadioButtonsChange = createRadioButtonsValidationHandler( - setRadioButtonsValid, - radioButtonsValid, - ); - const validatePicklistFields = createPreSubmitPicklistValidationHandler( - customerFields, - setPicklistValid, - ); - const validateCheckboxFields = createPreSubmitCheckboxesValidationHandler( - customerFields, - setCheckboxesValid, - ); - const handlePasswordValidation = createPasswordValidationHandler( - setPasswordValid, - customerFields, - ); - const handleCustomTextValidation = createTextInputValidationHandler( - setTextInputValid, - textInputValid, - ); - const preSubmitFieldsValidation = ( - e: MouseEvent & { target: HTMLButtonElement }, - ) => { - if (e.target.nodeName === 'BUTTON' && e.target.type === 'submit') { - validatePicklistFields(form.current); - validateCheckboxFields(form.current); - } - }; - - const onSubmit = async (formData: FormData) => { - const { status, message } = await updateCustomer(formData); - - if (status === 'error') { - toast.error(message, { - icon: , - }); - - return; - } - - toast.success(message, { - icon: , - }); - - window.scrollTo({ - top: 0, - behavior: 'smooth', - }); - }; - - return ( -
-
- {addressFields.map((field) => { - const fieldName = FieldNameToFieldId[field.entityId] ?? ''; - - if (!isExistedField(fieldName)) { - return null; - } - - return ( - - ); - })} -
- field.entityId === FieldNameToFieldId.email)?.label ?? - '' - } - name="customer-email" - onChange={handleTextInputValidation} - type="email" - /> -
- {customerFields - .filter(({ isBuiltIn }) => !isBuiltIn) - .map((field) => { - const fieldId = field.entityId; - const fieldName = createFieldName(field, 'customer'); - const previouslySubmittedField = customerInfo.formFields.find( - ({ entityId: id }) => id === fieldId, - ); - - switch (field.__typename) { - case 'NumberFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).NumberFormField; - - return ( - - - - ); - } - - case 'CheckboxesFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).CheckboxesFormField; - - return ( - - - - ); - } - - case 'MultilineTextFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).MultilineTextFormField; - - return ( - - - - ); - } - - case 'DateFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).DateFormField; - - return ( - - - - ); - } - - case 'RadioButtonsFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).MultipleChoiceFormField; - - return ( - - - - ); - } - - case 'PicklistFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).MultipleChoiceFormField; - - return ( - - - - ); - } - - case 'TextFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).TextFormField; - - return ( - - id === fieldId)?.label ?? ''} - name={fieldName} - onChange={handleCustomTextValidation} - type="text" - /> - - ); - } - - case 'PasswordFormField': { - const submittedValue = - getPreviouslySubmittedValue(previouslySubmittedField).PasswordFormField; - - return ( - - - - ); - } - - default: - return null; - } - })} -
- - - - - - {t('changePassword')} - -
-
-
- ); -}; diff --git a/core/app/[locale]/(default)/account/settings/change-password/_actions/change-password.ts b/core/app/[locale]/(default)/account/settings/change-password/_actions/change-password.ts deleted file mode 100644 index 5676e71375..0000000000 --- a/core/app/[locale]/(default)/account/settings/change-password/_actions/change-password.ts +++ /dev/null @@ -1,94 +0,0 @@ -'use server'; - -import { getTranslations } from 'next-intl/server'; -import { z } from 'zod'; - -import { getSessionCustomerAccessToken } from '~/auth'; -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; - -const ChangePasswordFieldsSchema = z.object({ - customerId: z.string(), - customerToken: z.string(), - currentPassword: z.string().min(1), - newPassword: z.string().min(1), - confirmPassword: z.string().min(1), -}); - -const CustomerChangePasswordSchema = ChangePasswordFieldsSchema.omit({ - customerId: true, - customerToken: true, -}); - -const CustomerChangePasswordMutation = graphql(` - mutation CustomerChangePasswordMutation($input: ChangePasswordInput!) { - customer { - changePassword(input: $input) { - errors { - ... on ValidationError { - message - path - } - ... on CustomerDoesNotExistError { - message - } - ... on CustomerPasswordError { - message - } - ... on CustomerNotLoggedInError { - message - } - } - } - } - } -`); - -interface ChangePasswordResponse { - status: 'success' | 'error'; - message: string; -} - -export const changePassword = async (formData: FormData): Promise => { - const t = await getTranslations('Account.Settings.ChangePassword'); - const customerAccessToken = await getSessionCustomerAccessToken(); - - try { - const parsedData = CustomerChangePasswordSchema.parse({ - newPassword: formData.get('new-password'), - currentPassword: formData.get('current-password'), - confirmPassword: formData.get('confirm-password'), - }); - - const response = await client.fetch({ - document: CustomerChangePasswordMutation, - variables: { - input: { - currentPassword: parsedData.currentPassword, - newPassword: parsedData.newPassword, - }, - }, - customerAccessToken, - }); - - const result = response.data.customer.changePassword; - - if (result.errors.length > 0) { - result.errors.forEach((error) => { - // Throw the first error message, as we should only handle one error at a time - throw new Error(error.message); - }); - } - - return { status: 'success', message: t('confirmChangePassword') }; - } catch (error: unknown) { - if (error instanceof Error || error instanceof z.ZodError) { - return { - status: 'error', - message: error.message, - }; - } - - return { status: 'error', message: t('error') }; - } -}; diff --git a/core/app/[locale]/(default)/account/settings/change-password/_components/change-password-form.tsx b/core/app/[locale]/(default)/account/settings/change-password/_components/change-password-form.tsx deleted file mode 100644 index 78c563b7de..0000000000 --- a/core/app/[locale]/(default)/account/settings/change-password/_components/change-password-form.tsx +++ /dev/null @@ -1,241 +0,0 @@ -'use client'; - -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { ChangeEvent, useRef, useState } from 'react'; -import { useFormStatus } from 'react-dom'; -import { toast } from 'react-hot-toast'; -import { z } from 'zod'; - -import { logout } from '~/components/header/_actions/logout'; -import { Link } from '~/components/link'; -import { Button } from '~/components/ui/button'; -import { - Field, - FieldControl, - FieldLabel, - FieldMessage, - Form, - FormSubmit, - Input, -} from '~/components/ui/form'; - -import { changePassword } from '../_actions/change-password'; - -const ChangePasswordFieldsSchema = z.object({ - customerId: z.string(), - customerToken: z.string(), - currentPassword: z.string().min(1), - newPassword: z.string().min(1), - confirmPassword: z.string().min(1), -}); - -const CustomerChangePasswordSchema = ChangePasswordFieldsSchema.omit({ - customerId: true, - customerToken: true, -}); - -type Passwords = z.infer; - -const validateAgainstConfirmPassword = ({ - newPassword, - confirmPassword, -}: { - newPassword: Passwords['newPassword']; - confirmPassword: Passwords['confirmPassword']; -}): boolean => newPassword === confirmPassword; - -const validateAgainstCurrentPassword = ({ - newPassword, - currentPassword, -}: { - newPassword: Passwords['newPassword']; - currentPassword: Passwords['currentPassword']; -}): boolean => newPassword !== currentPassword; - -const validatePasswords = ( - validationField: 'new-password' | 'confirm-password', - formData?: FormData, -) => { - if (!formData) { - return false; - } - - if (validationField === 'new-password') { - return CustomerChangePasswordSchema.omit({ confirmPassword: true }) - .refine(validateAgainstCurrentPassword) - .safeParse({ - currentPassword: formData.get('current-password'), - newPassword: formData.get('new-password'), - }).success; - } - - return CustomerChangePasswordSchema.refine(validateAgainstConfirmPassword).safeParse({ - currentPassword: formData.get('current-password'), - newPassword: formData.get('new-password'), - confirmPassword: formData.get('confirm-password'), - }).success; -}; - -const SubmitButton = () => { - const { pending } = useFormStatus(); - const t = useTranslations('Account.Settings.ChangePassword'); - - return ( - - ); -}; - -export const ChangePasswordForm = () => { - const form = useRef(null); - const t = useTranslations('Account.Settings.ChangePassword'); - - const [isCurrentPasswordValid, setIsCurrentPasswordValid] = useState(true); - const [isNewPasswordValid, setIsNewPasswordValid] = useState(true); - const [isConfirmPasswordValid, setIsConfirmPasswordValid] = useState(true); - - const handleCurrentPasswordChange = (e: ChangeEvent) => - setIsCurrentPasswordValid(!e.target.validity.valueMissing); - - const validateNewAndConfirmPasswords = (formData: FormData) => { - const newPasswordValid = validatePasswords('new-password', formData); - const confirmPassword = formData.get('confirm-password'); - const confirmPasswordValid = confirmPassword - ? validatePasswords('confirm-password', formData) - : true; - - setIsNewPasswordValid(newPasswordValid); - setIsConfirmPasswordValid(confirmPasswordValid); - }; - - const handlePasswordChange = (e: ChangeEvent) => { - let formData; - - if (e.target.form) { - formData = new FormData(e.target.form); - } - - if (formData) { - validateNewAndConfirmPasswords(formData); - } - }; - - const handleChangePassword = async (formData: FormData) => { - const { status, message } = await changePassword(formData); - - if (status === 'error') { - toast.error(message, { - icon: , - }); - - return; - } - - toast.success(message, { - icon: , - }); - - await logout(); - }; - - return ( -
- - - {t('currentPasswordLabel')} - - - - - - {t('notEmptyMessage')} - - - - - {t('newPasswordLabel')} - - - - - - {t('notEmptyMessage')} - - {!isNewPasswordValid && ( - - {t('newPasswordValidationMessage')} - - )} - - - - {t('confirmPasswordLabel')} - - - - - - {t('notEmptyMessage')} - - {!isConfirmPasswordValid && ( - - {t('confirmPasswordValidationMessage')} - - )} - -
- - - - -
-
- ); -}; diff --git a/core/app/[locale]/(default)/account/settings/change-password/page.tsx b/core/app/[locale]/(default)/account/settings/change-password/page.tsx deleted file mode 100644 index c89fd80410..0000000000 --- a/core/app/[locale]/(default)/account/settings/change-password/page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { getTranslations, setRequestLocale } from 'next-intl/server'; - -import { TabHeading } from '../../_components/tab-heading'; - -import { ChangePasswordForm } from './_components/change-password-form'; - -export async function generateMetadata() { - const t = await getTranslations('Account.Settings.ChangePassword'); - - return { - title: t('title'), - }; -} - -interface Props { - params: Promise<{ locale: string }>; -} - -export default async function ChangePassword({ params }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - - return ( -
- - -
- ); -} - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/account/settings/page-data.tsx b/core/app/[locale]/(default)/account/settings/page-data.tsx index bb5c76ffc4..43dfa5d323 100644 --- a/core/app/[locale]/(default)/account/settings/page-data.tsx +++ b/core/app/[locale]/(default)/account/settings/page-data.tsx @@ -1,13 +1,10 @@ -import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; -import { FormFieldValuesFragment } from '~/client/fragments/form-fields-values'; -import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql, VariablesOf } from '~/client/graphql'; import { TAGS } from '~/client/tags'; -import { FormFieldsFragment } from '~/components/form-fields/fragment'; +import { FormFieldsFragment } from '~/data-transformers/form-field-transformer/fragment'; const CustomerSettingsQuery = graphql( ` @@ -19,41 +16,10 @@ const CustomerSettingsQuery = graphql( ) { customer { entityId - company email firstName lastName - phone - formFields { - entityId - name - __typename - ... on CheckboxesFormFieldValue { - valueEntityIds - values - } - ... on DateFormFieldValue { - date { - utc - } - } - ... on MultipleChoiceFormFieldValue { - valueEntityId - value - } - ... on NumberFormFieldValue { - number - } - ... on PasswordFormFieldValue { - password - } - ... on TextFormFieldValue { - text - } - ... on MultilineTextFormFieldValue { - multilineText - } - } + company } site { settings { @@ -97,7 +63,7 @@ export const getCustomerSettingsQuery = cache(async ({ address, customer }: Prop customerFilters: customer?.filters, customerSortBy: customer?.sortBy, }, - fetchOptions: { cache: 'no-store' }, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, customerAccessToken, }); @@ -115,72 +81,3 @@ export const getCustomerSettingsQuery = cache(async ({ address, customer }: Prop customerInfo, }; }); - -const GetCustomerAddressesQuery = graphql( - ` - query GetCustomerAddresses($after: String, $before: String, $first: Int, $last: Int) { - customer { - entityId - addresses(before: $before, after: $after, first: $first, last: $last) { - pageInfo { - ...PaginationFragment - } - collectionInfo { - totalItems - } - edges { - node { - entityId - firstName - lastName - address1 - address2 - city - stateOrProvince - countryCode - phone - postalCode - company - formFields { - ...FormFieldValuesFragment - } - } - } - } - } - } - `, - [PaginationFragment, FormFieldValuesFragment], -); - -export interface CustomerAddressesArgs { - after?: string; - before?: string; - limit?: number; -} - -export const getCustomerAddresses = cache( - async ({ before = '', after = '', limit = 9 }: CustomerAddressesArgs) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; - - const response = await client.fetch({ - document: GetCustomerAddressesQuery, - variables: { ...paginationArgs }, - customerAccessToken, - fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, - }); - - const addresses = response.data.customer?.addresses; - - if (!addresses) { - return undefined; - } - - return { - pageInfo: addresses.pageInfo, - addressesCount: addresses.collectionInfo?.totalItems ?? 0, - addresses: removeEdgesAndNodes({ edges: addresses.edges }), - }; - }, -); diff --git a/core/app/[locale]/(default)/account/settings/page.tsx b/core/app/[locale]/(default)/account/settings/page.tsx index c5eda14ae4..b37db8e530 100644 --- a/core/app/[locale]/(default)/account/settings/page.tsx +++ b/core/app/[locale]/(default)/account/settings/page.tsx @@ -1,34 +1,52 @@ +import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; -import { TabHeading } from '../_components/tab-heading'; +import { AccountSettingsSection } from '@/vibes/soul/sections/account-settings'; -import { UpdateSettingsForm } from './_components/update-settings-form'; +import { changePassword } from './_actions/change-password'; +import { updateCustomer } from './_actions/update-customer'; import { getCustomerSettingsQuery } from './page-data'; -export async function generateMetadata() { - const t = await getTranslations('Account.Settings'); +interface Props { + params: Promise<{ locale: string }>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + + const t = await getTranslations({ locale, namespace: 'Account.Settings' }); return { title: t('title'), }; } -export default async function Settings() { - const customerSettings = await getCustomerSettingsQuery({ - address: { filters: { entityIds: [4, 5, 6, 7] } }, - }); +export default async function Settings({ params }: Props) { + const { locale } = await params; + + setRequestLocale(locale); + + const t = await getTranslations('Account.Settings'); + + const customerSettings = await getCustomerSettingsQuery(); if (!customerSettings) { notFound(); } return ( -
- - -
+ ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/_actions/add-to-cart.tsx b/core/app/[locale]/(default)/account/wishlists/[id]/_actions/add-to-cart.tsx new file mode 100644 index 0000000000..2cd0664423 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/[id]/_actions/add-to-cart.tsx @@ -0,0 +1,93 @@ +'use server'; + +import { BigCommerceAPIError, BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { getTranslations } from 'next-intl/server'; +import { z } from 'zod'; + +import { Link } from '~/components/link'; +import { addToOrCreateCart } from '~/lib/cart'; +import { MissingCartError } from '~/lib/cart/error'; + +interface State { + lastResult: SubmissionResult | null; + successMessage?: React.ReactNode; + errorMessage?: string; +} + +const schema = z.object({ + productId: z.number(), + variantId: z.number().optional(), +}); + +export async function addWishlistItemToCart(prevState: State, formData: FormData): Promise { + const t = await getTranslations('Product.ProductDetails'); + const submission = parseWithZod(formData, { schema }); + + if (submission.status !== 'success') { + return { lastResult: submission.reply() }; + } + + try { + const { productId, variantId } = schema.parse(submission.value); + const quantity = 1; + + await addToOrCreateCart({ + lineItems: [ + { + productEntityId: productId, + variantEntityId: variantId, + quantity, + }, + ], + }); + + return { + lastResult: submission.reply(), + successMessage: t.rich('successMessage', { + cartItems: quantity, + cartLink: (chunks) => ( + + {chunks} + + ), + }), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof MissingCartError) { + return { + ...prevState, + lastResult: { status: 'error' }, + errorMessage: t('missingCart'), + }; + } + + if (error instanceof BigCommerceGQLError) { + return { + ...prevState, + lastResult: { status: 'error' }, + errorMessage: error.message.includes('variant ID is required') + ? t('variantRequiredError') + : t('unknownError'), + }; + } + + if (error instanceof BigCommerceAPIError) { + return { + ...prevState, + lastResult: { status: 'error' }, + errorMessage: t('unknownError'), + }; + } + + return { + ...prevState, + lastResult: { status: 'error' }, + errorMessage: t('unknownError'), + }; + } +} diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/_components/visibility-switch.tsx b/core/app/[locale]/(default)/account/wishlists/[id]/_components/visibility-switch.tsx new file mode 100644 index 0000000000..e29a33a790 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/[id]/_components/visibility-switch.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useActionState, useEffect, useTransition } from 'react'; + +import { Switch } from '@/vibes/soul/form/switch'; +import { toast } from '@/vibes/soul/primitives/toaster'; +import { Wishlist } from '@/vibes/soul/sections/wishlist-details'; + +import { toggleWishlistVisibility } from '../../_actions/change-wishlist-visibility'; + +export const WishlistVisibilitySwitch = ({ + id, + visibility: { isPublic, publicLabel, privateLabel }, +}: Wishlist) => { + const [state, formAction] = useActionState(toggleWishlistVisibility, { lastResult: null }); + const [isPending, startTransition] = useTransition(); + const onCheckedChange = (value: boolean) => { + startTransition(() => { + const formData = new FormData(); + + formData.append('wishlistId', id); + formData.append('wishlistIsPublic', value ? 'true' : 'false'); + + formAction(formData); + }); + }; + + useEffect(() => { + if (state.lastResult?.status === 'error' && Boolean(state.errorMessage)) { + toast.error(state.errorMessage); + } + }, [state]); + + return ( + + ); +}; diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-actions.tsx b/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-actions.tsx new file mode 100644 index 0000000000..f4d748291d --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-actions.tsx @@ -0,0 +1,86 @@ +import { SwitchSkeleton } from '@/vibes/soul/form/switch'; +import { Streamable } from '@/vibes/soul/lib/streamable'; +import * as Skeleton from '@/vibes/soul/primitives/skeleton'; +import { Wishlist } from '@/vibes/soul/sections/wishlist-details'; +import { + WishlistShareButton, + WishlistShareButtonSkeleton, +} from '~/components/wishlist/share-button'; + +import { WishlistAction, WishlistActionsMenu } from '../../_components/wishlist-actions-menu'; + +import { WishlistVisibilitySwitch } from './visibility-switch'; + +interface Props { + wishlist: Wishlist; + isMobileUser: Streamable; + shareLabel: string; + shareCloseLabel: string; + shareCopyLabel: string; + shareModalTitle: string; + shareSuccessMessage: string; + shareCopiedMessage: string; + shareDisabledTooltip: string; + menuActions: WishlistAction[]; + actionsTitle?: string; +} + +export const WishlistActions = ({ + wishlist, + isMobileUser, + shareLabel, + shareCloseLabel, + shareCopyLabel, + shareModalTitle, + shareSuccessMessage, + shareCopiedMessage, + shareDisabledTooltip, + menuActions, + actionsTitle, +}: Props) => { + const { publicUrl } = wishlist; + + return ( +
+
+
+ +
+
+ {publicUrl != null && publicUrl !== '' && ( + + )} + +
+
+
+ ); +}; + +export function WishlistActionsSkeleton() { + return ( +
+
+
+ +
+
+ + +
+
+
+ ); +} diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider.tsx b/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider.tsx new file mode 100644 index 0000000000..8388d43327 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { PropsWithChildren, Suspense } from 'react'; +import { z } from 'zod'; + +import { Streamable, useStreamable } from '@/vibes/soul/lib/streamable'; +import { EventsProvider } from '~/components/analytics/events'; +import { useAnalytics } from '~/lib/analytics/react'; + +interface AddToCartContext { + id: number; + name: string; + brand: string; + sku?: string; + currency: string; + price: number; +} + +const AddToCartSchema = z.object({ + productId: z.number({ coerce: true }), + variantId: z.number({ coerce: true }), +}); + +export function WishlistAnalyticsProvider( + props: PropsWithChildren<{ data: Streamable }>, +) { + return ( + + + + ); +} + +function WishlistAnalyticsProviderResolved({ + children, + data, +}: PropsWithChildren<{ data: Streamable }>) { + const analytics = useAnalytics(); + const products = useStreamable(data); + + const onAddToCart = (payload?: FormData) => { + const parsedPayload = AddToCartSchema.safeParse(Object.fromEntries(payload?.entries() ?? [])); + + if (parsedPayload.success) { + const { productId, variantId: variant_id } = parsedPayload.data; + const product = products.find(({ id }) => id === productId); + + if (product) { + const { id, name, brand, sku, price, currency } = product; + + analytics?.cart.productAdded({ + currency, + value: 1 * price, + items: [ + { + id: id.toString(), + name, + brand, + sku, + price, + quantity: 1, + variant_id, + }, + ], + }); + } + } + }; + + return {children}; +} diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts b/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts new file mode 100644 index 0000000000..ae14ab4173 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts @@ -0,0 +1,59 @@ +import { cache } from 'react'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; +import { WishlistPaginatedItemsFragment } from '~/components/wishlist/fragment'; +import { getPreferredCurrencyCode } from '~/lib/currency'; + +const WishlistDetailsQuery = graphql( + ` + query WishlistDetailsQuery( + $first: Int + $after: String + $last: Int + $before: String + $entityId: Int! + $currencyCode: currencyCode + ) { + customer { + wishlists(filters: { entityIds: [$entityId] }) { + edges { + node { + ...WishlistPaginatedItemsFragment + } + } + } + } + } + `, + [WishlistPaginatedItemsFragment], +); + +interface Pagination { + limit: number; + before: string | null; + after: string | null; +} + +export const getCustomerWishlist = cache(async (entityId: number, pagination: Pagination) => { + const { before, after, limit = 9 } = pagination; + const customerAccessToken = await getSessionCustomerAccessToken(); + const currencyCode = await getPreferredCurrencyCode(); + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + const response = await client.fetch({ + document: WishlistDetailsQuery, + variables: { ...paginationArgs, currencyCode, entityId }, + customerAccessToken, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, + }); + + const wishlist = response.data.customer?.wishlists.edges?.[0]?.node; + + if (!wishlist) { + return null; + } + + return wishlist; +}); diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx b/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx new file mode 100644 index 0000000000..6ea2baa869 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx @@ -0,0 +1,143 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { SearchParams } from 'nuqs'; +import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'; + +import { Streamable } from '@/vibes/soul/lib/streamable'; +import { CursorPaginationInfo } from '@/vibes/soul/primitives/cursor-pagination'; +import { Wishlist, WishlistDetails } from '@/vibes/soul/sections/wishlist-details'; +import { ExistingResultType } from '~/client/util'; +import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; +import { wishlistDetailsTransformer } from '~/data-transformers/wishlists-transformer'; +import { redirect } from '~/i18n/routing'; +import { isMobileUser } from '~/lib/user-agent'; + +import { removeWishlistItem } from '../_actions/remove-wishlist-item'; +import { getDeleteWishlistModal, getRenameWishlistModal } from '../modals'; + +import { addWishlistItemToCart } from './_actions/add-to-cart'; +import { WishlistActions, WishlistActionsSkeleton } from './_components/wishlist-actions'; +import { WishlistAnalyticsProvider } from './_components/wishlist-analytics-provider'; +import { getCustomerWishlist } from './page-data'; + +interface Props { + params: Promise<{ locale: string; id: string }>; + searchParams: Promise; +} + +const defaultWishlistItemsLimit = 10; +const searchParamsCache = createSearchParamsCache({ + tag: parseAsString, + before: parseAsString, + after: parseAsString, + limit: parseAsInteger.withDefault(defaultWishlistItemsLimit), +}); + +async function getWishlist( + id: string, + t: ExistingResultType>, + pt: ExistingResultType>, + searchParamsPromise: Promise, + locale: string, +): Promise { + const entityId = Number(id); + const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); + const formatter = await getFormatter(); + const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + + if (!wishlist) { + return redirect({ href: '/account/wishlists/', locale }); + } + + return wishlistDetailsTransformer(wishlist, t, pt, formatter); +} + +const getAnalyticsData = async (id: string, searchParamsPromise: Promise) => { + const entityId = Number(id); + const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); + const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + + if (!wishlist) { + return []; + } + + return removeEdgesAndNodes(wishlist.items) + .map(({ product }) => product) + .filter((product) => product !== null) + .map((product) => { + return { + id: product.entityId, + name: product.name, + sku: product.sku, + brand: product.brand?.name ?? '', + price: product.prices?.price.value ?? 0, + currency: product.prices?.price.currencyCode ?? '', + }; + }); +}; + +async function getPaginationInfo( + id: string, + searchParamsPromise: Promise, +): Promise { + const entityId = Number(id); + const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); + const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + + return pageInfoTransformer(wishlist?.items.pageInfo ?? defaultPageInfo); +} + +export default async function WishlistPage({ params, searchParams }: Props) { + const { locale, id } = await params; + + setRequestLocale(locale); + + const t = await getTranslations('Wishlist'); + const pt = await getTranslations('Product.ProductDetails'); + const wishlistActions = (wishlist?: Wishlist) => { + if (!wishlist) { + return ; + } + + return ( + + ); + }; + + return ( + getAnalyticsData(id, searchParams))}> + getPaginationInfo(id, searchParams))} + prevHref="/account/wishlists" + removeAction={removeWishlistItem} + removeButtonTitle={t('removeButtonTitle')} + wishlist={Streamable.from(() => getWishlist(id, t, pt, searchParams, locale))} + /> + + ); +} diff --git a/core/app/[locale]/(default)/account/wishlists/_actions/change-wishlist-visibility.ts b/core/app/[locale]/(default)/account/wishlists/_actions/change-wishlist-visibility.ts new file mode 100644 index 0000000000..46ff104696 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/_actions/change-wishlist-visibility.ts @@ -0,0 +1,94 @@ +'use server'; + +import { BigCommerceAuthError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { revalidateTag } from 'next/cache'; +import { getTranslations } from 'next-intl/server'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { TAGS } from '~/client/tags'; + +import { UpdateWishlistMutation } from './mutation'; +import { toggleWishlistVisibilitySchema } from './schema'; + +interface State { + lastResult: SubmissionResult | null; + successMessage?: string; + errorMessage?: string; +} + +export async function toggleWishlistVisibility( + prevState: Awaited, + formData: FormData, +): Promise { + const customerAccessToken = await getSessionCustomerAccessToken(); + const t = await getTranslations('Wishlist'); + const submission = parseWithZod(formData, { schema: toggleWishlistVisibilitySchema }); + + if (submission.status !== 'success') { + return { + ...prevState, + lastResult: submission.reply(), + }; + } + + if (!customerAccessToken) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.unauthorized')] }), + errorMessage: t('Errors.unauthorized'), + }; + } + + try { + const { wishlistId, wishlistIsPublic } = toggleWishlistVisibilitySchema.parse(submission.value); + const isPublic = wishlistIsPublic === 'true'; + + const response = await client.fetch({ + document: UpdateWishlistMutation, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + variables: { wishlistId, input: { isPublic } }, + }); + + const result = response.data.wishlist.updateWishlist?.result; + + if (result?.isPublic !== isPublic) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.updateFailed')] }), + errorMessage: t('Errors.updateFailed'), + }; + } + + revalidateTag(TAGS.customer); + + return { + lastResult: submission.reply(), + successMessage: t('Result.updateSuccess'), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceAuthError) { + const authErrorMessage = t('Errors.unauthorized'); + + return { + ...prevState, + lastResult: submission.reply({ formErrors: [authErrorMessage] }), + errorMessage: authErrorMessage, + }; + } + + const errorMessage = t('Errors.unexpected'); + + return { + ...prevState, + lastResult: submission.reply({ formErrors: [errorMessage] }), + errorMessage, + }; + } +} diff --git a/core/app/[locale]/(default)/account/wishlists/_actions/delete-wishlist.ts b/core/app/[locale]/(default)/account/wishlists/_actions/delete-wishlist.ts new file mode 100644 index 0000000000..b63ec01a89 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/_actions/delete-wishlist.ts @@ -0,0 +1,88 @@ +'use server'; + +import { BigCommerceAuthError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { revalidateTag } from 'next/cache'; +import { getTranslations } from 'next-intl/server'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { TAGS } from '~/client/tags'; +import { serverToast } from '~/lib/server-toast'; + +import { DeleteWishlistMutation } from './mutation'; +import { deleteWishlistSchema } from './schema'; + +interface State { + lastResult: SubmissionResult | null; + successMessage?: string; +} + +export async function deleteWishlist( + prevState: Awaited, + formData: FormData, +): Promise { + const customerAccessToken = await getSessionCustomerAccessToken(); + const t = await getTranslations('Wishlist'); + const submission = parseWithZod(formData, { schema: deleteWishlistSchema }); + + if (submission.status !== 'success') { + return { + ...prevState, + lastResult: submission.reply(), + }; + } + + if (!customerAccessToken) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.unauthorized')] }), + }; + } + + try { + const { wishlistId } = deleteWishlistSchema.parse(submission.value); + + const response = await client.fetch({ + document: DeleteWishlistMutation, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + variables: { wishlistId }, + }); + + const result = response.data.wishlist.deleteWishlists?.result; + + if (result !== 'success') { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.deleteFailed')] }), + }; + } + + revalidateTag(TAGS.customer); + + // Server toast has to be used here since the item is being deleted. When revalidateTag is called, + // the wishlist items will update, and the element node containing the useEffect will be removed. + await serverToast.success(t('Result.deleteSuccess')); + + return { + lastResult: submission.reply(), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceAuthError) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.unauthorized')] }), + }; + } + + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.unexpected')] }), + }; + } +} diff --git a/core/app/[locale]/(default)/account/wishlists/_actions/mutation.ts b/core/app/[locale]/(default)/account/wishlists/_actions/mutation.ts new file mode 100644 index 0000000000..55491298e4 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/_actions/mutation.ts @@ -0,0 +1,51 @@ +import { graphql } from '~/client/graphql'; + +export const CreateWishlistMutation = graphql(` + mutation CreateWishlistMutation($input: CreateWishlistInput!) { + wishlist { + createWishlist(input: $input) { + result { + entityId + name + isPublic + } + } + } + } +`); + +export const UpdateWishlistMutation = graphql(` + mutation UpdateWishlistMutation($wishlistId: Int!, $input: WishlistUpdateDataInput!) { + wishlist { + updateWishlist(input: { entityId: $wishlistId, data: $input }) { + result { + entityId + name + isPublic + } + } + } + } +`); + +export const DeleteWishlistItemsMutation = graphql(` + mutation DeleteWishlistItemsMutation($wishlistId: Int!, $itemIds: [Int!]!) { + wishlist { + deleteWishlistItems(input: { entityId: $wishlistId, itemEntityIds: $itemIds }) { + result { + entityId + } + } + } + } +`); + +export const DeleteWishlistMutation = graphql(` + mutation DeleteWishlistMutation($wishlistId: Int!) { + wishlist { + deleteWishlists(input: { entityIds: [$wishlistId] }) { + result + } + } + } +`); diff --git a/core/app/[locale]/(default)/account/wishlists/_actions/new-wishlist.ts b/core/app/[locale]/(default)/account/wishlists/_actions/new-wishlist.ts new file mode 100644 index 0000000000..1df54efdc6 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/_actions/new-wishlist.ts @@ -0,0 +1,83 @@ +'use server'; + +import { BigCommerceAuthError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { revalidateTag } from 'next/cache'; +import { getTranslations } from 'next-intl/server'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { TAGS } from '~/client/tags'; + +import { CreateWishlistMutation } from './mutation'; +import { newWishlistSchema } from './schema'; + +interface State { + lastResult: SubmissionResult | null; + successMessage?: string; +} + +export async function newWishlist(prevState: Awaited, formData: FormData): Promise { + const customerAccessToken = await getSessionCustomerAccessToken(); + const t = await getTranslations('Wishlist'); + const schema = newWishlistSchema({ required_error: t('Errors.nameRequired') }); + const submission = parseWithZod(formData, { schema }); + + if (submission.status !== 'success') { + return { + ...prevState, + lastResult: submission.reply(), + }; + } + + if (!customerAccessToken) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.unauthorized')] }), + }; + } + + try { + const { wishlistName, wishlistIsPublic, wishlistItems } = schema.parse(submission.value); + const isPublic = wishlistIsPublic === 'true'; + + const response = await client.fetch({ + document: CreateWishlistMutation, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + variables: { input: { name: wishlistName, isPublic, items: wishlistItems } }, + }); + + const result = response.data.wishlist.createWishlist?.result; + + if (result?.name !== wishlistName) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.updateFailed')] }), + }; + } + + revalidateTag(TAGS.customer); + + return { + lastResult: submission.reply(), + successMessage: t('Result.createSuccess'), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceAuthError) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.unauthorized')] }), + }; + } + + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.unexpected')] }), + }; + } +} diff --git a/core/app/[locale]/(default)/account/wishlists/_actions/remove-wishlist-item.ts b/core/app/[locale]/(default)/account/wishlists/_actions/remove-wishlist-item.ts new file mode 100644 index 0000000000..5bee617081 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/_actions/remove-wishlist-item.ts @@ -0,0 +1,93 @@ +'use server'; + +import { BigCommerceAuthError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { revalidateTag } from 'next/cache'; +import { getTranslations } from 'next-intl/server'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { TAGS } from '~/client/tags'; +import { serverToast } from '~/lib/server-toast'; + +import { DeleteWishlistItemsMutation } from './mutation'; +import { removeWishlistItemSchema } from './schema'; + +interface State { + lastResult: SubmissionResult | null; + errorMessage?: string; +} + +export async function removeWishlistItem( + prevState: Awaited, + formData: FormData, +): Promise { + const customerAccessToken = await getSessionCustomerAccessToken(); + const t = await getTranslations('Wishlist'); + const submission = parseWithZod(formData, { schema: removeWishlistItemSchema }); + + if (submission.status !== 'success') { + return { + ...prevState, + lastResult: { status: 'error' }, + errorMessage: t('Errors.unexpected'), + }; + } + + if (!customerAccessToken) { + return { + ...prevState, + lastResult: { status: 'error' }, + errorMessage: t('Errors.unauthorized'), + }; + } + + try { + const { wishlistId, wishlistItemId } = removeWishlistItemSchema.parse(submission.value); + + const response = await client.fetch({ + document: DeleteWishlistItemsMutation, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + variables: { wishlistId, itemIds: [wishlistItemId] }, + }); + + const result = response.data.wishlist.deleteWishlistItems?.result; + + if (!result) { + return { + ...prevState, + lastResult: { status: 'error' }, + errorMessage: t('Errors.removeProductFailed'), + }; + } + + revalidateTag(TAGS.customer); + + // Server toast has to be used here since the item is being deleted. When revalidateTag is called, + // the wishlist items will update, and the element node containing the useEffect will be removed. + await serverToast.success(t('Result.removeItemSuccess')); + + return { + lastResult: submission.reply(), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceAuthError) { + return { + ...prevState, + lastResult: { status: 'error' }, + errorMessage: t('Errors.unauthorized'), + }; + } + + return { + ...prevState, + lastResult: { status: 'error' }, + errorMessage: t('Errors.unexpected'), + }; + } +} diff --git a/core/app/[locale]/(default)/account/wishlists/_actions/rename-wishlist.ts b/core/app/[locale]/(default)/account/wishlists/_actions/rename-wishlist.ts new file mode 100644 index 0000000000..0446c4e6b0 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/_actions/rename-wishlist.ts @@ -0,0 +1,85 @@ +'use server'; + +import { BigCommerceAuthError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { revalidateTag } from 'next/cache'; +import { getTranslations } from 'next-intl/server'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { TAGS } from '~/client/tags'; + +import { UpdateWishlistMutation } from './mutation'; +import { renameWishlistSchema } from './schema'; + +interface State { + lastResult: SubmissionResult | null; + successMessage?: string; +} + +export async function renameWishlist( + prevState: Awaited, + formData: FormData, +): Promise { + const customerAccessToken = await getSessionCustomerAccessToken(); + const t = await getTranslations('Wishlist'); + const schema = renameWishlistSchema({ required_error: t('Errors.nameRequired') }); + const submission = parseWithZod(formData, { schema }); + + if (submission.status !== 'success') { + return { + ...prevState, + lastResult: submission.reply(), + }; + } + + if (!customerAccessToken) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.unauthorized')] }), + }; + } + + try { + const { wishlistId, wishlistName } = schema.parse(submission.value); + + const response = await client.fetch({ + document: UpdateWishlistMutation, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + variables: { wishlistId, input: { name: wishlistName } }, + }); + + const result = response.data.wishlist.updateWishlist?.result; + + if (result?.name !== wishlistName) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.updateFailed')] }), + }; + } + + revalidateTag(TAGS.customer); + + return { + lastResult: submission.reply(), + successMessage: t('Result.updateSuccess'), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceAuthError) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.unauthorized')] }), + }; + } + + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.unexpected')] }), + }; + } +} diff --git a/core/app/[locale]/(default)/account/wishlists/_actions/schema.ts b/core/app/[locale]/(default)/account/wishlists/_actions/schema.ts new file mode 100644 index 0000000000..23fa59a54f --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/_actions/schema.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +const wishlistId = z.number().nonnegative().min(1); + +const wishlistItemSchema = z.object({ + productEntityId: z.number().nonnegative().min(1), + variantEntityId: z.number().nonnegative().min(1).optional(), +}); + +export const newWishlistSchema = ({ + required_error = 'Wish list name cannot be empty.', +}: { + required_error?: string; +}) => + z.object({ + wishlistName: z.string({ required_error }).trim().nonempty(), + wishlistIsPublic: z.enum(['true', 'false']).optional(), + wishlistItems: z.array(wishlistItemSchema).optional(), + }); + +export const renameWishlistSchema = ({ + required_error = 'Wish list name cannot be empty.', +}: { + required_error?: string; +}) => + z.object({ + wishlistId, + wishlistName: z.string({ required_error }).trim().nonempty(), + }); + +export const removeWishlistItemSchema = z.object({ + wishlistId, + wishlistItemId: z.number().nonnegative().min(1), +}); + +export const toggleWishlistVisibilitySchema = z.object({ + wishlistId, + wishlistIsPublic: z.enum(['true', 'false']), +}); + +export const deleteWishlistSchema = z.object({ + wishlistId, +}); diff --git a/core/app/[locale]/(default)/account/wishlists/_components/new-wishlist-button.tsx b/core/app/[locale]/(default)/account/wishlists/_components/new-wishlist-button.tsx new file mode 100644 index 0000000000..1ee0c67a23 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/_components/new-wishlist-button.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useState } from 'react'; + +import { Button } from '@/vibes/soul/primitives/button'; +import { toast } from '@/vibes/soul/primitives/toaster'; +import { Modal, ModalFormState } from '~/components/modal'; + +import { WishlistModalProps } from './wishlist-actions-menu'; + +interface Props { + label: string; + variant?: 'primary' | 'tertiary'; + modal: WishlistModalProps; +} + +export const NewWishlistButton = ({ modal, variant = 'primary', label }: Props) => { + const [isOpen, setOpen] = useState(false); + const { formAction: action, ...props } = modal; + const onSuccess = ({ successMessage }: ModalFormState) => { + if (successMessage !== '' && successMessage !== undefined) { + toast.success(successMessage); + setOpen(false); + } + }; + + const onError = ({ errorMessage }: ModalFormState) => { + if (errorMessage !== '' && errorMessage !== undefined) { + toast.error(errorMessage); + } + }; + + if (!action) { + return null; + } + + return ( + + {label} + + } + {...props} + /> + ); +}; diff --git a/core/app/[locale]/(default)/account/wishlists/_components/wishlist-actions-menu.tsx b/core/app/[locale]/(default)/account/wishlists/_components/wishlist-actions-menu.tsx new file mode 100644 index 0000000000..6fc56ff205 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/_components/wishlist-actions-menu.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { EllipsisIcon } from 'lucide-react'; +import { useReducer } from 'react'; + +import { Button } from '@/vibes/soul/primitives/button'; +import { DropdownMenu } from '@/vibes/soul/primitives/dropdown-menu'; +import { toast } from '@/vibes/soul/primitives/toaster'; +import { Modal, ModalButton, ModalFormAction, ModalFormState } from '~/components/modal'; + +import { getShareWishlistModal } from '../modals'; + +interface WishlistActionBase { + className?: string; + label: string | React.ReactNode; + disabled?: boolean; + variant?: 'default' | 'danger'; +} + +export interface WishlistModalProps { + title: string; + children: React.ReactNode; + hideHeader?: boolean; + buttons?: ModalButton[]; + formAction?: ModalFormAction; +} + +interface WishlistModalAction extends WishlistActionBase { + key?: string; + modal: WishlistModalProps; +} + +interface WishlistMenuAction extends WishlistActionBase { + action?: string | ((e: React.MouseEvent) => void); +} + +export type WishlistAction = WishlistModalAction | WishlistMenuAction; + +interface Props { + actionsTitle?: string; + share?: { + wishlistName: string; + label: string; + publicUrl: string; + modalTitle: string; + copiedMessage: string; + isMobileUser: boolean; + isPublic: boolean; + successMessage: string; + disabledTooltip: string; + closeLabel: string; + copyLabel: string; + }; + items: WishlistAction[]; +} + +function reducer(state: Record, action: { modal: string; open: boolean }) { + return { + ...state, + [action.modal]: action.open, + }; +} + +function getShareMenuItemProps( + share: Props['share'], + key: string, + nativeShare: (name: string, publicUrl: string) => Promise, + copyToClipboard: (publicUrl: string) => Promise, +): WishlistAction | undefined { + if (!share) { + return undefined; + } + + if (share.isMobileUser) { + return { + label: share.label, + disabled: !share.isPublic, + action: () => { + void nativeShare(share.wishlistName, share.publicUrl); + }, + }; + } + + return { + label: share.label, + disabled: !share.isPublic, + key, + modal: getShareWishlistModal( + share.modalTitle, + share.copyLabel, + share.closeLabel, + share.publicUrl, + () => { + void copyToClipboard(share.publicUrl); + }, + ), + }; +} + +export const WishlistActionsMenu = ({ actionsTitle, items, share }: Props) => { + const [state, dispatch] = useReducer(reducer, {}); + const shareModalKey = 'share-dropdown-modal'; + const getShareUrl = (publicUrl: string) => String(new URL(publicUrl, window.location.origin)); + const nativeShare = async (title: string, publicUrl: string) => { + try { + await navigator.share({ url: getShareUrl(publicUrl), title }); + toast.success(share?.successMessage); + } catch { + // noop + } + }; + + const copyToClipboard = async (publicUrl: string) => { + try { + await navigator.clipboard.writeText(getShareUrl(publicUrl)); + toast.success(share?.copiedMessage); + dispatch({ modal: shareModalKey, open: false }); + } catch { + // noop + } + }; + + const shareProps = getShareMenuItemProps(share, shareModalKey, nativeShare, copyToClipboard); + const shareMenuItem = shareProps ? [shareProps] : []; + const menuItems = [...shareMenuItem, ...items].map((item, index) => { + if ('modal' in item) { + const key = item.key ?? `dropdown-modal-${index}`; + + return { + ...item, + key, + action: () => dispatch({ modal: key, open: true }), + }; + } + + return item; + }); + + const modals = menuItems.filter((item) => 'modal' in item); + + const handleModalFormSuccess = (modal: string) => { + return ({ successMessage }: ModalFormState) => { + if (successMessage !== undefined && successMessage !== '') { + toast.success(successMessage); + dispatch({ modal, open: false }); + } + }; + }; + + return ( + <> + + + + {modals.map(({ key, modal: { formAction: action, ...modalProps } }) => ( + dispatch({ modal: key, open })} + {...modalProps} + /> + ))} + + ); +}; diff --git a/core/app/[locale]/(default)/account/wishlists/modals.tsx b/core/app/[locale]/(default)/account/wishlists/modals.tsx new file mode 100644 index 0000000000..bddd0504f9 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/modals.tsx @@ -0,0 +1,134 @@ +import { getTranslations } from 'next-intl/server'; + +import { Wishlist } from '@/vibes/soul/sections/wishlist-details'; +import { ExistingResultType } from '~/client/util'; +import { ChangeWishlistVisibilityModal } from '~/components/wishlist/modals/change-visibility'; +import { DeleteWishlistModal } from '~/components/wishlist/modals/delete'; +import { NewWishlistModal } from '~/components/wishlist/modals/new'; +import { RenameWishlistModal } from '~/components/wishlist/modals/rename'; +import { ShareWishlistModal } from '~/components/wishlist/modals/share'; + +import { toggleWishlistVisibility } from './_actions/change-wishlist-visibility'; +import { deleteWishlist } from './_actions/delete-wishlist'; +import { newWishlist } from './_actions/new-wishlist'; +import { renameWishlist } from './_actions/rename-wishlist'; +import { WishlistModalProps } from './_components/wishlist-actions-menu'; + +const bold = (chunks: React.ReactNode) => {chunks}; + +export const getNewWishlistModal = ( + t: ExistingResultType>, +): WishlistModalProps => ({ + children: ( + + ), + title: t('Modal.newTitle'), + formAction: newWishlist, + buttons: [ + { + label: t('Modal.cancel'), + type: 'cancel', + }, + { + label: t('Modal.create'), + type: 'submit', + }, + ], +}); + +export const getRenameWishlistModal = ( + wishlist: Wishlist, + t: ExistingResultType>, +): WishlistModalProps => ({ + children: ( + + ), + title: t('Modal.renameTitle', { name: wishlist.name }), + formAction: renameWishlist, + buttons: [ + { + label: t('Modal.cancel'), + type: 'cancel', + }, + { + label: t('Modal.save'), + type: 'submit', + }, + ], +}); + +export const getChangeWishlistVisibilityModal = ( + wishlist: Wishlist, + t: ExistingResultType>, +): WishlistModalProps => { + const name = wishlist.name; + const title = wishlist.visibility.isPublic + ? t('Modal.changeVisibilityPrivateTitle', { name }) + : t('Modal.changeVisibilityPublicTitle', { name }); + + const message = wishlist.visibility.isPublic + ? t.rich('Modal.makePrivateContent', { name, bold }) + : t.rich('Modal.makePublicContent', { name, bold }); + + return { + children: , + title, + formAction: toggleWishlistVisibility, + hideHeader: true, + buttons: [ + { + label: t('Modal.cancel'), + type: 'cancel', + }, + { + label: wishlist.visibility.isPublic ? t('makePrivate') : t('makePublic'), + type: 'submit', + }, + ], + }; +}; + +export const getShareWishlistModal = ( + title: string, + copyLabel: string, + closeLabel: string, + publicUrl: string, + action: () => void | Promise, +): WishlistModalProps => ({ + children: , + title, + buttons: [ + { type: 'cancel', label: closeLabel }, + { label: copyLabel, variant: 'primary', action }, + ], +}); + +export const getDeleteWishlistModal = ( + wishlist: Wishlist, + t: ExistingResultType>, +): WishlistModalProps => ({ + children: ( + + ), + title: t('Modal.deleteTitle', { name: wishlist.name }), + formAction: deleteWishlist, + hideHeader: true, + buttons: [ + { + label: t('Modal.cancel'), + type: 'cancel', + }, + { + label: t('Modal.delete'), + variant: 'danger', + type: 'submit', + }, + ], +}); diff --git a/core/app/[locale]/(default)/account/wishlists/page-data.ts b/core/app/[locale]/(default)/account/wishlists/page-data.ts new file mode 100644 index 0000000000..02a1c3f7b1 --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/page-data.ts @@ -0,0 +1,54 @@ +import { cache } from 'react'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; +import { WishlistsFragment } from '~/components/wishlist/fragment'; +import { getPreferredCurrencyCode } from '~/lib/currency'; + +const WishlistsPageQuery = graphql( + ` + query WishlistsPageQuery( + $first: Int + $after: String + $last: Int + $before: String + $filters: WishlistFiltersInput + $currencyCode: currencyCode + ) { + customer { + wishlists(first: $first, after: $after, last: $last, before: $before, filters: $filters) { + ...WishlistsFragment + } + } + } + `, + [WishlistsFragment], +); + +interface Pagination { + limit: number; + before: string | null; + after: string | null; +} + +export const getCustomerWishlists = cache(async ({ limit = 9, before, after }: Pagination) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + const currencyCode = await getPreferredCurrencyCode(); + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + const response = await client.fetch({ + document: WishlistsPageQuery, + variables: { ...paginationArgs, currencyCode }, + customerAccessToken, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, + }); + + const wishlists = response.data.customer?.wishlists; + + if (!wishlists) { + return null; + } + + return wishlists; +}); diff --git a/core/app/[locale]/(default)/account/wishlists/page.tsx b/core/app/[locale]/(default)/account/wishlists/page.tsx new file mode 100644 index 0000000000..5fba791cfa --- /dev/null +++ b/core/app/[locale]/(default)/account/wishlists/page.tsx @@ -0,0 +1,130 @@ +import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { SearchParams } from 'nuqs'; +import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'; + +import { Streamable } from '@/vibes/soul/lib/streamable'; +import { CursorPaginationInfo } from '@/vibes/soul/primitives/cursor-pagination'; +import * as Skeleton from '@/vibes/soul/primitives/skeleton'; +import { Wishlist } from '@/vibes/soul/sections/wishlist-details'; +import { WishlistsSection } from '@/vibes/soul/sections/wishlists-section'; +import { ExistingResultType } from '~/client/util'; +import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; +import { wishlistsTransformer } from '~/data-transformers/wishlists-transformer'; +import { isMobileUser } from '~/lib/user-agent'; + +import { NewWishlistButton } from './_components/new-wishlist-button'; +import { WishlistActionsMenu } from './_components/wishlist-actions-menu'; +import { + getChangeWishlistVisibilityModal, + getDeleteWishlistModal, + getNewWishlistModal, + getRenameWishlistModal, +} from './modals'; +import { getCustomerWishlists } from './page-data'; + +interface Props { + params: Promise<{ locale: string }>; + searchParams: Promise; +} + +const defaultWishlistsLimit = 10; +const searchParamsCache = createSearchParamsCache({ + tag: parseAsString, + before: parseAsString, + after: parseAsString, + limit: parseAsInteger.withDefault(defaultWishlistsLimit), +}); + +async function listWishlists( + searchParamsPromise: Promise, + t: ExistingResultType>, +): Promise { + const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); + const formatter = await getFormatter(); + const wishlists = await getCustomerWishlists(searchParamsParsed); + + if (!wishlists) { + return []; + } + + return wishlistsTransformer(wishlists, t, formatter); +} + +async function getPaginationInfo( + searchParamsPromise: Promise, +): Promise { + const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); + const wishlists = await getCustomerWishlists(searchParamsParsed); + + return pageInfoTransformer(wishlists?.pageInfo ?? defaultPageInfo); +} + +export default async function Wishlists({ params, searchParams }: Props) { + const { locale } = await params; + + setRequestLocale(locale); + + const t = await getTranslations('Wishlist'); + const isMobile = await isMobileUser(); + const newWishlistModal = getNewWishlistModal(t); + + return ( + } + emptyStateCallToAction={ + + } + emptyStateTitle={t('noWishlists')} + emptyWishlistStateText={t('emptyWishlist')} + itemActions={{ + component: (wishlist) => { + if (!wishlist) { + return ; + } + + return ( + + ); + }, + }} + paginationInfo={Streamable.from(() => getPaginationInfo(searchParams))} + title={t('title')} + viewWishlistLabel={t('viewWishlist')} + wishlists={Streamable.from(() => listWishlists(searchParams, t))} + /> + ); +} diff --git a/core/app/[locale]/(default)/blog/[blogId]/_components/print-button.tsx b/core/app/[locale]/(default)/blog/[blogId]/_components/print-button.tsx deleted file mode 100644 index 54ad5bf9e8..0000000000 --- a/core/app/[locale]/(default)/blog/[blogId]/_components/print-button.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import { Printer } from 'lucide-react'; -import { useTranslations } from 'next-intl'; - -export const PrintButton = () => { - const t = useTranslations('Blog.SharingLinks'); - - return ( - - ); -}; diff --git a/core/app/[locale]/(default)/blog/[blogId]/_components/sharing-links.tsx b/core/app/[locale]/(default)/blog/[blogId]/_components/sharing-links.tsx deleted file mode 100644 index 7ebfa30934..0000000000 --- a/core/app/[locale]/(default)/blog/[blogId]/_components/sharing-links.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { SiFacebook, SiLinkedin, SiPinterest, SiX } from '@icons-pack/react-simple-icons'; -import { Mail } from 'lucide-react'; -import { useTranslations } from 'next-intl'; - -import { FragmentOf, graphql } from '~/client/graphql'; - -import { PrintButton } from './print-button'; - -export const SharingLinksFragment = graphql(` - fragment SharingLinksFragment on Site { - content { - blog { - post(entityId: $entityId) { - entityId - thumbnailImage { - url: urlTemplate(lossy: true) - } - seo { - pageTitle - } - } - } - } - settings { - url { - vanityUrl - } - } - } -`); - -interface Props { - data: FragmentOf; -} - -export const SharingLinks = ({ data }: Props) => { - const t = useTranslations('Blog.SharingLinks'); - - const blogPost = data.content.blog?.post; - - if (!blogPost) { - return null; - } - - const encodedTitle = encodeURIComponent(blogPost.seo.pageTitle); - const encodedUrl = encodeURIComponent( - `${data.settings?.url.vanityUrl || ''}/blog/${blogPost.entityId}/`, - ); - - return ( - - ); -}; diff --git a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts index 6b25c564ed..ec480d77b0 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts +++ b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts @@ -1,48 +1,46 @@ import { cache } from 'react'; import { client } from '~/client'; -import { graphql } from '~/client/graphql'; +import { graphql, VariablesOf } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; -import { SharingLinksFragment } from './_components/sharing-links'; - -const BlogPageQuery = graphql( - ` - query BlogPageQuery($entityId: Int!) { - site { - content { - blog { - post(entityId: $entityId) { - author - htmlBody - name - publishedDate { - utc - } - tags - thumbnailImage { - altText - url: urlTemplate(lossy: true) - } - seo { - pageTitle - metaDescription - metaKeywords - } +const BlogPageQuery = graphql(` + query BlogPageQuery($entityId: Int!) { + site { + content { + blog { + name + path + post(entityId: $entityId) { + author + htmlBody + name + publishedDate { + utc + } + tags + thumbnailImage { + altText + url: urlTemplate(lossy: true) + } + seo { + pageTitle + metaDescription + metaKeywords } } } - ...SharingLinksFragment } } - `, - [SharingLinksFragment], -); + } +`); + +type Variables = VariablesOf; -export const getBlogPageData = cache(async ({ entityId }: { entityId: number }) => { +export const getBlogPageData = cache(async (variables: Variables) => { const response = await client.fetch({ document: BlogPageQuery, - variables: { entityId }, + variables, fetchOptions: { next: { revalidate } }, }); @@ -52,5 +50,5 @@ export const getBlogPageData = cache(async ({ entityId }: { entityId: number }) return null; } - return response.data.site; + return blog; }); diff --git a/core/app/[locale]/(default)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index 2513469518..e6bda68e8e 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page.tsx +++ b/core/app/[locale]/(default)/blog/[blogId]/page.tsx @@ -1,16 +1,18 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getFormatter } from 'next-intl/server'; +import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { cache } from 'react'; -import { Image } from '~/components/image'; -import { Link } from '~/components/link'; -import { Tag } from '~/components/ui/tag'; +import { BlogPostContent, BlogPostContentBlogPost } from '@/vibes/soul/sections/blog-post-content'; +import { Breadcrumb } from '@/vibes/soul/sections/breadcrumbs'; -import { SharingLinks } from './_components/sharing-links'; import { getBlogPageData } from './page-data'; +const cachedBlogPageDataVariables = cache((blogId: string) => ({ entityId: Number(blogId) })); + interface Props { params: Promise<{ + locale: string; blogId: string; }>; } @@ -18,8 +20,10 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { blogId } = await params; - const data = await getBlogPageData({ entityId: Number(blogId) }); - const blogPost = data?.content.blog?.post; + const variables = cachedBlogPageDataVariables(blogId); + + const blog = await getBlogPageData(variables); + const blogPost = blog?.post; if (!blogPost) { return {}; @@ -34,64 +38,73 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function Blog({ params }: Props) { - const { blogId } = await params; - +async function getBlogPost(props: Props): Promise { const format = await getFormatter(); - const data = await getBlogPageData({ entityId: Number(blogId) }); - const blogPost = data?.content.blog?.post; + const { blogId } = await props.params; - if (!blogPost) { + const variables = cachedBlogPageDataVariables(blogId); + + const blog = await getBlogPageData(variables); + const blogPost = blog?.post; + + if (!blog || !blogPost) { return notFound(); } + return { + author: blogPost.author ?? undefined, + title: blogPost.name, + content: blogPost.htmlBody, + date: format.dateTime(new Date(blogPost.publishedDate.utc)), + image: blogPost.thumbnailImage + ? { alt: blogPost.thumbnailImage.altText, src: blogPost.thumbnailImage.url } + : undefined, + tags: blogPost.tags.map((tag) => ({ + label: tag, + link: { + href: `${blog.path}?tag=${tag}`, + }, + })), + }; +} + +async function getBlogPostBreadcrumbs(props: Props): Promise { + const t = await getTranslations('Blog'); + + const { blogId } = await props.params; + + const variables = cachedBlogPageDataVariables(blogId); + + const blog = await getBlogPageData(variables); + const blogPost = blog?.post; + + if (!blog || !blogPost) { + return notFound(); + } + + return [ + { + label: t('home'), + href: '/', + }, + { + label: blog.name, + href: blog.path, + }, + { + label: blogPost.name, + href: '#', + }, + ]; +} + +export default async function Blog(props: Props) { + const { locale } = await props.params; + + setRequestLocale(locale); + return ( -
-

{blogPost.name}

- -
- - {format.dateTime(new Date(blogPost.publishedDate.utc))} - - - {Boolean(blogPost.author) && ( - , by {blogPost.author} - )} -
- - {blogPost.thumbnailImage ? ( -
- {blogPost.thumbnailImage.altText} -
- ) : ( -
-

- {blogPost.name} -

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

{blogPosts.name}

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

{blogPosts.name}

- -
- {blogPosts.posts.items.map((post) => { - return ; - })} -
- - -
- ); -} - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/cart/_actions/add-shipping-cost.ts b/core/app/[locale]/(default)/cart/_actions/add-shipping-cost.ts new file mode 100644 index 0000000000..cd6566f838 --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/add-shipping-cost.ts @@ -0,0 +1,55 @@ +'use server'; + +import { revalidateTag } from 'next/cache'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; + +const SelectCheckoutShippingOptionMutation = graphql(` + mutation SelectCheckoutShippingOption($input: SelectCheckoutShippingOptionInput!) { + checkout { + selectCheckoutShippingOption(input: $input) { + checkout { + entityId + } + } + } + } +`); + +interface Props { + checkoutEntityId: string; + consignmentEntityId: string; + shippingOptionEntityId: string; +} + +export const addShippingCost = async ({ + checkoutEntityId, + consignmentEntityId, + shippingOptionEntityId, +}: Props) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const response = await client.fetch({ + document: SelectCheckoutShippingOptionMutation, + variables: { + input: { + checkoutEntityId, + consignmentEntityId, + data: { + shippingOptionEntityId, + }, + }, + }, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + }); + + const result = response.data.checkout.selectCheckoutShippingOption?.checkout; + + revalidateTag(TAGS.checkout); + + return result; +}; diff --git a/core/app/[locale]/(default)/cart/_actions/add-shipping-info.ts b/core/app/[locale]/(default)/cart/_actions/add-shipping-info.ts new file mode 100644 index 0000000000..87200afc1f --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/add-shipping-info.ts @@ -0,0 +1,141 @@ +'use server'; + +import { revalidateTag } from 'next/cache'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; + +const AddCheckoutShippingConsignmentsMutation = graphql(` + mutation AddCheckoutShippingConsignments($input: AddCheckoutShippingConsignmentsInput!) { + checkout { + addCheckoutShippingConsignments(input: $input) { + checkout { + entityId + shippingConsignments { + availableShippingOptions { + cost { + value + } + description + entityId + } + } + } + } + } + } +`); + +interface AddProps { + checkoutEntityId: string; + address: { + countryCode: string; + city?: string; + stateOrProvince?: string; + postalCode?: string; + }; + lineItems: Array<{ quantity: number; lineItemEntityId: string }>; +} + +export const addCheckoutShippingConsignments = async ({ + checkoutEntityId, + address, + lineItems, +}: AddProps) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const response = await client.fetch({ + document: AddCheckoutShippingConsignmentsMutation, + variables: { + input: { + checkoutEntityId, + data: { + consignments: [ + { + address: { + ...address, + shouldSaveAddress: false, + }, + lineItems, + }, + ], + }, + }, + }, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + }); + + revalidateTag(TAGS.checkout); + + return response.data.checkout.addCheckoutShippingConsignments?.checkout; +}; + +const UpdateCheckoutShippingConsignmentMutation = graphql(` + mutation UpdateCheckoutShippingConsignment($input: UpdateCheckoutShippingConsignmentInput!) { + checkout { + updateCheckoutShippingConsignment(input: $input) { + checkout { + entityId + shippingConsignments { + availableShippingOptions { + cost { + value + } + description + entityId + } + } + } + } + } + } +`); + +interface UpdateProps { + checkoutEntityId: string; + shippingId: string; + address: { + countryCode: string; + city?: string; + stateOrProvince?: string; + postalCode?: string; + }; + lineItems: Array<{ quantity: number; lineItemEntityId: string }>; +} + +export const updateCheckoutShippingConsignment = async ({ + checkoutEntityId, + address, + shippingId, + lineItems, +}: UpdateProps) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const response = await client.fetch({ + document: UpdateCheckoutShippingConsignmentMutation, + variables: { + input: { + checkoutEntityId, + consignmentEntityId: shippingId, + data: { + consignment: { + address: { + ...address, + shouldSaveAddress: false, + }, + lineItems, + }, + }, + }, + }, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + }); + + revalidateTag(TAGS.checkout); + + return response.data.checkout.updateCheckoutShippingConsignment?.checkout; +}; diff --git a/core/app/[locale]/(default)/cart/_actions/apply-coupon-code.ts b/core/app/[locale]/(default)/cart/_actions/apply-coupon-code.ts new file mode 100644 index 0000000000..604e9e3d5c --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/apply-coupon-code.ts @@ -0,0 +1,51 @@ +'use server'; + +import { revalidateTag } from 'next/cache'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql, VariablesOf } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; + +const ApplyCheckoutCouponMutation = graphql(` + mutation ApplyCheckoutCouponMutation($applyCheckoutCouponInput: ApplyCheckoutCouponInput!) { + checkout { + applyCheckoutCoupon(input: $applyCheckoutCouponInput) { + checkout { + entityId + } + } + } + } +`); + +type Variables = VariablesOf; + +interface Props { + checkoutEntityId: Variables['applyCheckoutCouponInput']['checkoutEntityId']; + couponCode: Variables['applyCheckoutCouponInput']['data']['couponCode']; +} + +export const applyCouponCode = async ({ checkoutEntityId, couponCode }: Props) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const response = await client.fetch({ + document: ApplyCheckoutCouponMutation, + variables: { + applyCheckoutCouponInput: { + checkoutEntityId, + data: { + couponCode, + }, + }, + }, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + }); + + const checkout = response.data.checkout.applyCheckoutCoupon?.checkout; + + revalidateTag(TAGS.checkout); + + return checkout; +}; diff --git a/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts b/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts index a1362c5c95..f762f3d6d7 100644 --- a/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts +++ b/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts @@ -1,42 +1,27 @@ 'use server'; -import { getLocale } from 'next-intl/server'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { getLocale, getTranslations } from 'next-intl/server'; import { z } from 'zod'; -import { getSessionCustomerAccessToken } from '~/auth'; -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; import { redirect } from '~/i18n/routing'; +import { getCartId } from '~/lib/cart'; -const CheckoutRedirectMutation = graphql(` - mutation CheckoutRedirectMutation($cartId: String!) { - cart { - createCartRedirectUrls(input: { cartEntityId: $cartId }) { - redirectUrls { - redirectedCheckoutUrl - } - } - } - } -`); - -export const redirectToCheckout = async (formData: FormData) => { +export const redirectToCheckout = async ( + _lastResult: SubmissionResult | null, + formData: FormData, +): Promise => { const locale = await getLocale(); - const cartId = z.string().parse(formData.get('cartId')); - const customerAccessToken = await getSessionCustomerAccessToken(); + const t = await getTranslations('Cart.Errors'); - const { data } = await client.fetch({ - document: CheckoutRedirectMutation, - variables: { cartId }, - fetchOptions: { cache: 'no-store' }, - customerAccessToken, - }); + const submission = parseWithZod(formData, { schema: z.object({}) }); - const url = data.cart.createCartRedirectUrls.redirectUrls?.redirectedCheckoutUrl; + const cartId = await getCartId(); - if (!url) { - throw new Error('Invalid checkout url.'); + if (!cartId) { + return submission.reply({ formErrors: [t('cartNotFound')] }); } - redirect({ href: url, locale }); + return redirect({ href: '/checkout', locale }); }; diff --git a/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts b/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts new file mode 100644 index 0000000000..9d0ef45efa --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts @@ -0,0 +1,51 @@ +'use server'; + +import { revalidateTag } from 'next/cache'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql, VariablesOf } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; + +const UnapplyCheckoutCouponMutation = graphql(` + mutation UnapplyCheckoutCouponMutation($unapplyCheckoutCouponInput: UnapplyCheckoutCouponInput!) { + checkout { + unapplyCheckoutCoupon(input: $unapplyCheckoutCouponInput) { + checkout { + entityId + } + } + } + } +`); + +type Variables = VariablesOf; + +interface Props { + checkoutEntityId: Variables['unapplyCheckoutCouponInput']['checkoutEntityId']; + couponCode: Variables['unapplyCheckoutCouponInput']['data']['couponCode']; +} + +export const removeCouponCode = async ({ checkoutEntityId, couponCode }: Props) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const response = await client.fetch({ + document: UnapplyCheckoutCouponMutation, + variables: { + unapplyCheckoutCouponInput: { + checkoutEntityId, + data: { + couponCode, + }, + }, + }, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + }); + + const checkout = response.data.checkout.unapplyCheckoutCoupon?.checkout; + + revalidateTag(TAGS.checkout); + + return checkout; +}; diff --git a/core/app/[locale]/(default)/cart/_actions/remove-item.ts b/core/app/[locale]/(default)/cart/_actions/remove-item.ts index a28b9a57ab..f02a24dd66 100644 --- a/core/app/[locale]/(default)/cart/_actions/remove-item.ts +++ b/core/app/[locale]/(default)/cart/_actions/remove-item.ts @@ -1,12 +1,13 @@ 'use server'; -import { revalidateTag } from 'next/cache'; -import { cookies } from 'next/headers'; +import { unstable_expireTag } from 'next/cache'; +import { getTranslations } from 'next-intl/server'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { TAGS } from '~/client/tags'; +import { clearCartId, getCartId } from '~/lib/cart'; const DeleteCartLineItemMutation = graphql(` mutation DeleteCartLineItemMutation($input: DeleteCartLineItemInput!) { @@ -26,49 +27,42 @@ type DeleteCartLineItemInput = Variables['input']; export async function removeItem({ lineItemEntityId, }: Omit) { + const t = await getTranslations('Cart.Errors'); + const customerAccessToken = await getSessionCustomerAccessToken(); - try { - const cookieStore = await cookies(); - const cartId = cookieStore.get('cartId')?.value; + const cartId = await getCartId(); - if (!cartId) { - return { status: 'error', error: 'No cartId cookie found' }; - } + if (!cartId) { + throw new Error(t('cartNotFound')); + } - if (!lineItemEntityId) { - return { status: 'error', error: 'No lineItemEntityId found' }; - } + if (!lineItemEntityId) { + throw new Error(t('lineItemNotFound')); + } - const response = await client.fetch({ - document: DeleteCartLineItemMutation, - variables: { - input: { - cartEntityId: cartId, - lineItemEntityId, - }, + const response = await client.fetch({ + document: DeleteCartLineItemMutation, + variables: { + input: { + cartEntityId: cartId, + lineItemEntityId, }, - customerAccessToken, - fetchOptions: { cache: 'no-store' }, - }); + }, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + }); - const cart = response.data.cart.deleteCartLineItem?.cart; + const cart = response.data.cart.deleteCartLineItem?.cart; - // If we remove the last item in a cart the cart is deleted - // so we need to remove the cartId cookie - // TODO: We need to figure out if it actually failed. - if (!cart) { - cookieStore.delete('cartId'); - } + // If we remove the last item in a cart the cart is deleted + // so we need to remove the cartId cookie + // TODO: We need to figure out if it actually failed. + if (!cart) { + await clearCartId(); + } - revalidateTag(TAGS.cart); + unstable_expireTag(TAGS.cart); - return { status: 'success', data: cart }; - } catch (error: unknown) { - if (error instanceof Error) { - return { status: 'error', error: error.message }; - } - - return { status: 'error' }; - } + return cart; } diff --git a/core/app/[locale]/(default)/cart/_actions/update-coupon-code.ts b/core/app/[locale]/(default)/cart/_actions/update-coupon-code.ts new file mode 100644 index 0000000000..01611007f7 --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/update-coupon-code.ts @@ -0,0 +1,136 @@ +'use server'; + +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { getTranslations } from 'next-intl/server'; + +import { couponCodeActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; +import { getCartId } from '~/lib/cart'; + +import { getCart } from '../page-data'; + +import { applyCouponCode } from './apply-coupon-code'; +import { removeCouponCode } from './remove-coupon-code'; + +export const updateCouponCode = async ( + prevState: Awaited<{ + couponCodes: string[]; + lastResult: SubmissionResult | null; + }>, + formData: FormData, +): Promise<{ + couponCodes: string[]; + lastResult: SubmissionResult | null; +}> => { + const t = await getTranslations('Cart.CheckoutSummary.CouponCode'); + const submission = parseWithZod(formData, { + schema: couponCodeActionFormDataSchema({ required_error: t('invalidCouponCode') }), + }); + + const cartId = await getCartId(); + + if (!cartId) { + return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; + } + + const cart = await getCart({ cartId }); + const checkout = cart.site.checkout; + + if (!checkout) { + return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; + } + + const checkoutEntityId = checkout.entityId; + + if (!checkoutEntityId) { + return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; + } + + if (submission.status !== 'success') { + return { + ...prevState, + lastResult: submission.reply(), + }; + } + + switch (submission.value.intent) { + case 'apply': { + try { + await applyCouponCode({ + checkoutEntityId, + couponCode: submission.value.couponCode, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + ...prevState, + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => { + if (message.includes('Incorrect or mismatch:')) { + return t('invalidCouponCode'); + } + + return message; + }), + }), + }; + } + + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + const couponCode = submission.value.couponCode; + + return { + couponCodes: [...prevState.couponCodes, couponCode], + lastResult: submission.reply({ resetForm: true }), + }; + } + + case 'delete': { + try { + await removeCouponCode({ + checkoutEntityId, + couponCode: submission.value.couponCode, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + ...prevState, + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), + }; + } + + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + const couponCode = submission.value.couponCode; + + return { + couponCodes: prevState.couponCodes.filter((item) => item !== couponCode), + lastResult: submission.reply({ resetForm: true }), + }; + } + + default: { + return prevState; + } + } +}; diff --git a/core/app/[locale]/(default)/cart/_actions/update-line-item.ts b/core/app/[locale]/(default)/cart/_actions/update-line-item.ts new file mode 100644 index 0000000000..2d01a290a8 --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/update-line-item.ts @@ -0,0 +1,399 @@ +'use server'; + +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { FragmentOf } from 'gql.tada'; +import { getTranslations } from 'next-intl/server'; + +import { CartLineItem } from '@/vibes/soul/sections/cart'; +import { cartLineItemActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; + +import { DigitalItemFragment, PhysicalItemFragment } from '../page-data'; + +import { removeItem } from './remove-item'; +import { CartSelectedOptionsInput, updateQuantity } from './update-quantity'; + +type LineItem = { + selectedOptions: + | FragmentOf['selectedOptions'] + | FragmentOf['selectedOptions']; + productEntityId: number; + variantEntityId: number | null; +} & CartLineItem; + +export const updateLineItem = async ( + prevState: Awaited<{ + lineItems: LineItem[]; + lastResult: SubmissionResult | null; + }>, + formData: FormData, +): Promise<{ + lineItems: LineItem[]; + lastResult: SubmissionResult | null; +}> => { + const t = await getTranslations('Cart.Errors'); + + const submission = parseWithZod(formData, { schema: cartLineItemActionFormDataSchema }); + + if (submission.status !== 'success') { + return { + ...prevState, + lastResult: submission.reply(), + }; + } + + const cartLineItem = prevState.lineItems.find((item) => item.id === submission.value.id); + + if (!cartLineItem) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('lineItemNotFound')] }), + }; + } + + switch (submission.value.intent) { + case 'increment': { + const parsedSelectedOptions = cartLineItem.selectedOptions.reduce( + (accum, option) => { + let multipleChoicesOptionInput; + let checkboxOptionInput; + let numberFieldOptionInput; + let textFieldOptionInput; + let multiLineTextFieldOptionInput; + let dateFieldOptionInput; + + switch (option.__typename) { + case 'CartSelectedMultipleChoiceOption': + multipleChoicesOptionInput = { + optionEntityId: option.entityId, + optionValueEntityId: option.valueEntityId, + }; + + if (accum.multipleChoices) { + return { + ...accum, + multipleChoices: [...accum.multipleChoices, multipleChoicesOptionInput], + }; + } + + return { + ...accum, + multipleChoices: [multipleChoicesOptionInput], + }; + + case 'CartSelectedCheckboxOption': + checkboxOptionInput = { + optionEntityId: option.entityId, + optionValueEntityId: option.valueEntityId, + }; + + if (accum.checkboxes) { + return { + ...accum, + checkboxes: [...accum.checkboxes, checkboxOptionInput], + }; + } + + return { ...accum, checkboxes: [checkboxOptionInput] }; + + case 'CartSelectedNumberFieldOption': + numberFieldOptionInput = { + optionEntityId: option.entityId, + number: option.number, + }; + + if (accum.numberFields) { + return { + ...accum, + numberFields: [...accum.numberFields, numberFieldOptionInput], + }; + } + + return { ...accum, numberFields: [numberFieldOptionInput] }; + + case 'CartSelectedTextFieldOption': + textFieldOptionInput = { + optionEntityId: option.entityId, + text: option.text, + }; + + if (accum.textFields) { + return { + ...accum, + textFields: [...accum.textFields, textFieldOptionInput], + }; + } + + return { ...accum, textFields: [textFieldOptionInput] }; + + case 'CartSelectedMultiLineTextFieldOption': + multiLineTextFieldOptionInput = { + optionEntityId: option.entityId, + text: option.text, + }; + + if (accum.multiLineTextFields) { + return { + ...accum, + multiLineTextFields: [ + ...accum.multiLineTextFields, + multiLineTextFieldOptionInput, + ], + }; + } + + return { + ...accum, + multiLineTextFields: [multiLineTextFieldOptionInput], + }; + + case 'CartSelectedDateFieldOption': + dateFieldOptionInput = { + optionEntityId: option.entityId, + date: new Date(String(option.date.utc)).toISOString(), + }; + + if (accum.dateFields) { + return { + ...accum, + dateFields: [...accum.dateFields, dateFieldOptionInput], + }; + } + + return { ...accum, dateFields: [dateFieldOptionInput] }; + } + + return accum; + }, + {}, + ); + + try { + await updateQuantity({ + lineItemEntityId: cartLineItem.id, + productEntityId: cartLineItem.productEntityId, + variantEntityId: cartLineItem.variantEntityId, + selectedOptions: parsedSelectedOptions, + quantity: cartLineItem.quantity + 1, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + ...prevState, + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), + }; + } + + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + const item = submission.value; + + return { + lineItems: prevState.lineItems.map((lineItem) => + lineItem.id === item.id ? { ...lineItem, quantity: lineItem.quantity + 1 } : lineItem, + ), + lastResult: submission.reply({ resetForm: true }), + }; + } + + case 'decrement': { + const parsedSelectedOptions = cartLineItem.selectedOptions.reduce( + (accum, option) => { + let multipleChoicesOptionInput; + let checkboxOptionInput; + let numberFieldOptionInput; + let textFieldOptionInput; + let multiLineTextFieldOptionInput; + let dateFieldOptionInput; + + switch (option.__typename) { + case 'CartSelectedMultipleChoiceOption': + multipleChoicesOptionInput = { + optionEntityId: option.entityId, + optionValueEntityId: option.valueEntityId, + }; + + if (accum.multipleChoices) { + return { + ...accum, + multipleChoices: [...accum.multipleChoices, multipleChoicesOptionInput], + }; + } + + return { + ...accum, + multipleChoices: [multipleChoicesOptionInput], + }; + + case 'CartSelectedCheckboxOption': + checkboxOptionInput = { + optionEntityId: option.entityId, + optionValueEntityId: option.valueEntityId, + }; + + if (accum.checkboxes) { + return { + ...accum, + checkboxes: [...accum.checkboxes, checkboxOptionInput], + }; + } + + return { ...accum, checkboxes: [checkboxOptionInput] }; + + case 'CartSelectedNumberFieldOption': + numberFieldOptionInput = { + optionEntityId: option.entityId, + number: option.number, + }; + + if (accum.numberFields) { + return { + ...accum, + numberFields: [...accum.numberFields, numberFieldOptionInput], + }; + } + + return { ...accum, numberFields: [numberFieldOptionInput] }; + + case 'CartSelectedTextFieldOption': + textFieldOptionInput = { + optionEntityId: option.entityId, + text: option.text, + }; + + if (accum.textFields) { + return { + ...accum, + textFields: [...accum.textFields, textFieldOptionInput], + }; + } + + return { ...accum, textFields: [textFieldOptionInput] }; + + case 'CartSelectedMultiLineTextFieldOption': + multiLineTextFieldOptionInput = { + optionEntityId: option.entityId, + text: option.text, + }; + + if (accum.multiLineTextFields) { + return { + ...accum, + multiLineTextFields: [ + ...accum.multiLineTextFields, + multiLineTextFieldOptionInput, + ], + }; + } + + return { + ...accum, + multiLineTextFields: [multiLineTextFieldOptionInput], + }; + + case 'CartSelectedDateFieldOption': + dateFieldOptionInput = { + optionEntityId: option.entityId, + date: new Date(String(option.date.utc)).toISOString(), + }; + + if (accum.dateFields) { + return { + ...accum, + dateFields: [...accum.dateFields, dateFieldOptionInput], + }; + } + + return { ...accum, dateFields: [dateFieldOptionInput] }; + } + + return accum; + }, + {}, + ); + + try { + await updateQuantity({ + lineItemEntityId: cartLineItem.id, + productEntityId: cartLineItem.productEntityId, + variantEntityId: cartLineItem.variantEntityId, + selectedOptions: parsedSelectedOptions, + quantity: cartLineItem.quantity - 1, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + ...prevState, + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), + }; + } + + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + const item = submission.value; + + return { + lineItems: prevState.lineItems.map((lineItem) => + lineItem.id === item.id ? { ...lineItem, quantity: lineItem.quantity - 1 } : lineItem, + ), + lastResult: submission.reply({ resetForm: true }), + }; + } + + case 'delete': { + try { + await removeItem({ lineItemEntityId: submission.value.id }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + ...prevState, + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), + }; + } + + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + const deletedItem = submission.value; + + return { + lineItems: prevState.lineItems.filter((item) => item.id !== deletedItem.id), + lastResult: submission.reply({ resetForm: true }), + }; + } + + default: { + return prevState; + } + } +}; diff --git a/core/app/[locale]/(default)/cart/_actions/update-quantity.ts b/core/app/[locale]/(default)/cart/_actions/update-quantity.ts new file mode 100644 index 0000000000..6a36c88c60 --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/update-quantity.ts @@ -0,0 +1,93 @@ +'use server'; + +import { unstable_expirePath } from 'next/cache'; +import { getTranslations } from 'next-intl/server'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql, VariablesOf } from '~/client/graphql'; +import { getCartId } from '~/lib/cart'; + +import { removeItem } from './remove-item'; + +const UpdateCartLineItemMutation = graphql(` + mutation UpdateCartLineItem($input: UpdateCartLineItemInput!) { + cart { + updateCartLineItem(input: $input) { + cart { + entityId + } + } + } + } +`); + +type CartLineItemInput = ReturnType>; +export type CartSelectedOptionsInput = ReturnType< + typeof graphql.scalar<'CartSelectedOptionsInput'> +>; +type Variables = VariablesOf; +type UpdateCartLineItemInput = Variables['input']; + +interface UpdateProductQuantityParams extends CartLineItemInput { + lineItemEntityId: UpdateCartLineItemInput['lineItemEntityId']; +} + +export const updateQuantity = async ({ + lineItemEntityId, + productEntityId, + quantity, + variantEntityId, + selectedOptions, +}: UpdateProductQuantityParams) => { + const t = await getTranslations('Cart.Errors'); + + const customerAccessToken = await getSessionCustomerAccessToken(); + + const cartId = await getCartId(); + + if (!cartId) { + throw new Error(t('cartNotFound')); + } + + if (!lineItemEntityId) { + throw new Error(t('lineItemNotFound')); + } + + if (quantity === 0) { + const result = await removeItem({ lineItemEntityId }); + + return result; + } + + const cartLineItemData = Object.assign( + { quantity, productEntityId }, + variantEntityId && { variantEntityId }, + selectedOptions && { selectedOptions }, + ); + + const response = await client.fetch({ + document: UpdateCartLineItemMutation, + variables: { + input: { + cartEntityId: cartId, + lineItemEntityId, + data: { + lineItem: cartLineItemData, + }, + }, + }, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + }); + + const cart = response.data.cart.updateCartLineItem?.cart; + + if (!cart) { + throw new Error(t('failedToUpdateQuantity')); + } + + unstable_expirePath('/cart'); + + return cart; +}; diff --git a/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts b/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts new file mode 100644 index 0000000000..bb1c1d34a0 --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts @@ -0,0 +1,173 @@ +'use server'; + +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { parseWithZod } from '@conform-to/zod'; +import { getTranslations } from 'next-intl/server'; + +import { shippingActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; +import { ShippingFormState } from '@/vibes/soul/sections/cart/shipping-form'; +import { getCartId } from '~/lib/cart'; + +import { getCart } from '../page-data'; + +import { addShippingCost } from './add-shipping-cost'; +import { + addCheckoutShippingConsignments, + updateCheckoutShippingConsignment, +} from './add-shipping-info'; + +export const updateShippingInfo = async ( + prevState: Awaited, + formData: FormData, +): Promise => { + const t = await getTranslations('Cart.CheckoutSummary.Shipping'); + + const submission = parseWithZod(formData, { + schema: shippingActionFormDataSchema, + }); + + const cartId = await getCartId(); + + if (!cartId) { + return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; + } + + const cart = await getCart({ cartId }); + const checkout = cart.site.checkout; + + if (!checkout || !cart.site.cart) { + return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; + } + + const checkoutEntityId = checkout.entityId; + + if (!checkoutEntityId) { + return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; + } + + if (submission.status !== 'success') { + return { + ...prevState, + lastResult: submission.reply(), + }; + } + + const lineItems = [ + ...cart.site.cart.lineItems.physicalItems, + ...cart.site.cart.lineItems.digitalItems, + ].map((item) => ({ + lineItemEntityId: item.entityId, + quantity: item.quantity, + })); + + const shippingConsignment = + checkout.shippingConsignments?.find((consignment) => consignment.selectedShippingOption) || + checkout.shippingConsignments?.[0]; + + const shippingId = shippingConsignment?.entityId; + + switch (submission.value.intent) { + case 'add-address': { + try { + const result = shippingId + ? await updateCheckoutShippingConsignment({ + checkoutEntityId, + address: { + countryCode: submission.value.country, + city: submission.value.city, + stateOrProvince: submission.value.state, + postalCode: submission.value.postalCode, + }, + lineItems, + shippingId, + }) + : await addCheckoutShippingConsignments({ + checkoutEntityId, + address: { + countryCode: submission.value.country, + city: submission.value.city, + stateOrProvince: submission.value.state, + postalCode: submission.value.postalCode, + }, + lineItems, + }); + + const updatedShippingConsignment = result ? result.shippingConsignments?.[0] : undefined; + + if (!updatedShippingConsignment?.availableShippingOptions) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('cartNotFound')] }), + }; + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + ...prevState, + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), + }; + } + + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + return { + ...prevState, + lastResult: submission.reply({ resetForm: true }), + }; + } + + case 'add-shipping': { + try { + if (!shippingConsignment?.entityId) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('cartNotFound')] }), + }; + } + + await addShippingCost({ + checkoutEntityId, + consignmentEntityId: shippingConsignment.entityId, + shippingOptionEntityId: submission.value.shippingOption, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + ...prevState, + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), + }; + } + + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + return { + ...prevState, + lastResult: submission.reply({ resetForm: true }), + }; + } + + default: { + return prevState; + } + } +}; diff --git a/core/app/[locale]/(default)/cart/_components/cart-analytics-provider.tsx b/core/app/[locale]/(default)/cart/_components/cart-analytics-provider.tsx new file mode 100644 index 0000000000..2a1e89b7f3 --- /dev/null +++ b/core/app/[locale]/(default)/cart/_components/cart-analytics-provider.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { PropsWithChildren, Suspense } from 'react'; +import { z } from 'zod'; + +import { Streamable, useStreamable } from '@/vibes/soul/lib/streamable'; +import { EventsProvider } from '~/components/analytics/events'; +import { useAnalytics } from '~/lib/analytics/react'; + +interface AddToCartContext { + entityId: string; + id: number; + name: string; + brand: string; + sku?: string; + currency: string; + price: number; +} + +const AddToCartSchema = z.object({ + id: z.string(), + quantity: z.number({ coerce: true }).default(1), +}); + +export function CartAnalyticsProvider( + props: PropsWithChildren<{ data: Streamable }>, +) { + return ( + + + + ); +} + +function CartAnalyticsProviderResolved({ + children, + data, +}: PropsWithChildren<{ data: Streamable }>) { + const analytics = useAnalytics(); + const products = useStreamable(data); + + const onAddToCart = (payload?: FormData) => { + const parsedPayload = AddToCartSchema.safeParse(Object.fromEntries(payload?.entries() ?? [])); + + if (parsedPayload.success) { + const { id, quantity } = parsedPayload.data; + + const product = products.find(({ entityId }) => entityId === id); + + if (product) { + const { id: productId, name, brand, sku, price, currency } = product; + + analytics?.cart.productAdded({ + currency, + value: quantity * price, + items: [ + { + id: productId.toString(), + name, + brand, + sku, + price, + quantity, + }, + ], + }); + } + } + }; + + const onRemoveFromCart = (payload?: FormData) => { + const parsedPayload = AddToCartSchema.safeParse(Object.fromEntries(payload?.entries() ?? [])); + + if (parsedPayload.success) { + const { id, quantity } = parsedPayload.data; + + const product = products.find(({ entityId }) => entityId === id); + + if (product) { + const { id: productId, name, brand, sku, price, currency } = product; + + analytics?.cart.productRemoved({ + currency, + value: quantity * price, + items: [ + { + id: productId.toString(), + name, + brand, + sku, + price, + quantity, + }, + ], + }); + } + } + }; + + return ( + + {children} + + ); +} diff --git a/core/app/[locale]/(default)/cart/_components/cart-item.tsx b/core/app/[locale]/(default)/cart/_components/cart-item.tsx deleted file mode 100644 index bb35121f1d..0000000000 --- a/core/app/[locale]/(default)/cart/_components/cart-item.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { useFormatter } from 'next-intl'; - -import { FragmentOf, graphql } from '~/client/graphql'; -import { Image } from '~/components/image'; - -import { ItemQuantity } from './item-quantity'; -import { RemoveItem } from './remove-item'; - -const PhysicalItemFragment = graphql(` - fragment PhysicalItemFragment on CartPhysicalItem { - name - brand - sku - image { - url: urlTemplate(lossy: true) - } - entityId - quantity - productEntityId - variantEntityId - extendedListPrice { - currencyCode - value - } - extendedSalePrice { - currencyCode - value - } - originalPrice { - currencyCode - value - } - listPrice { - currencyCode - value - } - selectedOptions { - __typename - entityId - name - ... on CartSelectedMultipleChoiceOption { - value - valueEntityId - } - ... on CartSelectedCheckboxOption { - value - valueEntityId - } - ... on CartSelectedNumberFieldOption { - number - } - ... on CartSelectedMultiLineTextFieldOption { - text - } - ... on CartSelectedTextFieldOption { - text - } - ... on CartSelectedDateFieldOption { - date { - utc - } - } - } - } -`); - -const DigitalItemFragment = graphql(` - fragment DigitalItemFragment on CartDigitalItem { - name - brand - sku - image { - url: urlTemplate(lossy: true) - } - entityId - quantity - productEntityId - variantEntityId - extendedListPrice { - currencyCode - value - } - extendedSalePrice { - currencyCode - value - } - originalPrice { - currencyCode - value - } - listPrice { - currencyCode - value - } - selectedOptions { - __typename - entityId - name - ... on CartSelectedMultipleChoiceOption { - value - valueEntityId - } - ... on CartSelectedCheckboxOption { - value - valueEntityId - } - ... on CartSelectedNumberFieldOption { - number - } - ... on CartSelectedMultiLineTextFieldOption { - text - } - ... on CartSelectedTextFieldOption { - text - } - ... on CartSelectedDateFieldOption { - date { - utc - } - } - } - } -`); - -export const CartItemFragment = graphql( - ` - fragment CartItemFragment on CartLineItems { - physicalItems { - ...PhysicalItemFragment - } - digitalItems { - ...DigitalItemFragment - } - } - `, - [PhysicalItemFragment, DigitalItemFragment], -); - -type FragmentResult = FragmentOf; -type PhysicalItem = FragmentResult['physicalItems'][number]; -type DigitalItem = FragmentResult['digitalItems'][number]; - -export type Product = PhysicalItem | DigitalItem; - -interface Props { - product: Product; - currencyCode: string; -} - -export const CartItem = ({ currencyCode, product }: Props) => { - const format = useFormatter(); - - return ( -
  • -
    -
    - {product.image?.url ? ( - {product.name} - ) : ( -
    - )} -
    - -
    -

    {product.brand}

    -
    -
    -

    {product.name}

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

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

    - ) : null} -

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

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

    {t('heading')}

    -
    -

    {t('empty')}

    -

    {t('emptyDetails')}

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

    {t('noAvailableOptions')}

    -
    - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/shipping-options/submit-button.tsx b/core/app/[locale]/(default)/cart/_components/shipping-options/submit-button.tsx deleted file mode 100644 index 3780b1c656..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-options/submit-button.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useTranslations } from 'next-intl'; -import { useFormStatus } from 'react-dom'; - -import { Button } from '~/components/ui/button'; - -export const SubmitButton = () => { - const t = useTranslations('Cart.SubmitShippingCost'); - const { pending } = useFormStatus(); - - return ( - - ); -}; diff --git a/core/app/[locale]/(default)/cart/_components/shipping-options/submit-shipping-costs.ts b/core/app/[locale]/(default)/cart/_components/shipping-options/submit-shipping-costs.ts deleted file mode 100644 index 0dc7719b1d..0000000000 --- a/core/app/[locale]/(default)/cart/_components/shipping-options/submit-shipping-costs.ts +++ /dev/null @@ -1,70 +0,0 @@ -'use server'; - -import { revalidateTag } from 'next/cache'; -import { z } from 'zod'; - -import { getSessionCustomerAccessToken } from '~/auth'; -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; -import { TAGS } from '~/client/tags'; - -const SelectCheckoutShippingOptionMutation = graphql(` - mutation SelectCheckoutShippingOption($input: SelectCheckoutShippingOptionInput!) { - checkout { - selectCheckoutShippingOption(input: $input) { - checkout { - entityId - } - } - } - } -`); - -const ShippingCostSchema = z.object({ - shippingOption: z.string(), -}); - -export const submitShippingCosts = async ( - formData: FormData, - checkoutEntityId: string, - consignmentEntityId: string, -) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - - try { - const parsedData = ShippingCostSchema.parse({ - shippingOption: formData.get('shippingOption'), - }); - - const response = await client.fetch({ - document: SelectCheckoutShippingOptionMutation, - variables: { - input: { - checkoutEntityId, - consignmentEntityId, - data: { - shippingOptionEntityId: parsedData.shippingOption, - }, - }, - }, - customerAccessToken, - fetchOptions: { cache: 'no-store' }, - }); - - const shippingCost = response.data.checkout.selectCheckoutShippingOption?.checkout; - - if (!shippingCost?.entityId) { - return { status: 'error', error: 'Failed to submit shipping cost.' }; - } - - revalidateTag(TAGS.checkout); - - return { status: 'success', data: shippingCost }; - } catch (error: unknown) { - if (error instanceof Error || error instanceof z.ZodError) { - return { status: 'error', error: error.message }; - } - - return { status: 'error', error: 'Failed to submit shipping cost.' }; - } -}; diff --git a/core/app/[locale]/(default)/cart/loading.tsx b/core/app/[locale]/(default)/cart/loading.tsx new file mode 100644 index 0000000000..1f0e533b4f --- /dev/null +++ b/core/app/[locale]/(default)/cart/loading.tsx @@ -0,0 +1,9 @@ +import { useTranslations } from 'next-intl'; + +import { CartSkeleton } from '@/vibes/soul/sections/cart'; + +export default function Loading() { + const t = useTranslations('Cart'); + + return ; +} diff --git a/core/app/[locale]/(default)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts new file mode 100644 index 0000000000..21e5f5c260 --- /dev/null +++ b/core/app/[locale]/(default)/cart/page-data.ts @@ -0,0 +1,302 @@ +import { cache } from 'react'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql, VariablesOf } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; +import { TAGS } from '~/client/tags'; + +export const PhysicalItemFragment = graphql(` + fragment PhysicalItemFragment on CartPhysicalItem { + name + brand + sku + image { + url: urlTemplate(lossy: true) + } + entityId + quantity + productEntityId + variantEntityId + extendedListPrice { + currencyCode + value + } + extendedSalePrice { + currencyCode + value + } + originalPrice { + currencyCode + value + } + listPrice { + currencyCode + value + } + selectedOptions { + __typename + entityId + name + ... on CartSelectedMultipleChoiceOption { + value + valueEntityId + } + ... on CartSelectedCheckboxOption { + value + valueEntityId + } + ... on CartSelectedNumberFieldOption { + number + } + ... on CartSelectedMultiLineTextFieldOption { + text + } + ... on CartSelectedTextFieldOption { + text + } + ... on CartSelectedDateFieldOption { + date { + utc + } + } + } + url + } +`); + +export const DigitalItemFragment = graphql(` + fragment DigitalItemFragment on CartDigitalItem { + name + brand + sku + image { + url: urlTemplate(lossy: true) + } + entityId + quantity + productEntityId + variantEntityId + extendedListPrice { + currencyCode + value + } + extendedSalePrice { + currencyCode + value + } + originalPrice { + currencyCode + value + } + listPrice { + currencyCode + value + } + selectedOptions { + __typename + entityId + name + ... on CartSelectedMultipleChoiceOption { + value + valueEntityId + } + ... on CartSelectedCheckboxOption { + value + valueEntityId + } + ... on CartSelectedNumberFieldOption { + number + } + ... on CartSelectedMultiLineTextFieldOption { + text + } + ... on CartSelectedTextFieldOption { + text + } + ... on CartSelectedDateFieldOption { + date { + utc + } + } + } + url + } +`); + +const MoneyFieldsFragment = graphql(` + fragment MoneyFieldsFragment on Money { + currencyCode + value + } +`); + +const ShippingInfoFragment = graphql(` + fragment ShippingInfoFragment on Checkout { + entityId + shippingConsignments { + entityId + availableShippingOptions { + cost { + value + } + description + entityId + isRecommended + } + selectedShippingOption { + entityId + description + cost { + value + } + } + address { + city + countryCode + stateOrProvince + postalCode + } + } + handlingCostTotal { + value + } + shippingCostTotal { + currencyCode + value + } + } +`); + +const GeographyFragment = graphql( + ` + fragment GeographyFragment on Geography { + countries { + entityId + name + code + statesOrProvinces { + entityId + name + abbreviation + } + } + } + `, + [], +); + +const CartPageQuery = graphql( + ` + query CartPageQuery($cartId: String) { + site { + settings { + url { + checkoutUrl + } + } + cart(entityId: $cartId) { + entityId + version + currencyCode + discountedAmount { + ...MoneyFieldsFragment + } + lineItems { + physicalItems { + ...PhysicalItemFragment + } + digitalItems { + ...DigitalItemFragment + } + totalQuantity + } + } + checkout(entityId: $cartId) { + entityId + subtotal { + ...MoneyFieldsFragment + } + grandTotal { + ...MoneyFieldsFragment + } + taxTotal { + ...MoneyFieldsFragment + } + cart { + currencyCode + } + coupons { + code + discountedAmount { + ...MoneyFieldsFragment + } + } + ...ShippingInfoFragment + } + } + geography { + ...GeographyFragment + } + } + `, + [ + PhysicalItemFragment, + DigitalItemFragment, + MoneyFieldsFragment, + ShippingInfoFragment, + GeographyFragment, + ], +); + +type Variables = VariablesOf; + +export const getCart = async (variables: Variables) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const { data } = await client.fetch({ + document: CartPageQuery, + variables, + customerAccessToken, + fetchOptions: { + cache: 'no-store', + next: { + tags: [TAGS.cart, TAGS.checkout], + }, + }, + }); + + return data; +}; + +const SupportedShippingDestinationsQuery = graphql(` + query SupportedShippingDestinations { + site { + settings { + shipping { + supportedShippingDestinations { + countries { + entityId + code + name + statesOrProvinces { + entityId + name + abbreviation + } + } + } + } + } + } + } +`); + +export const getShippingCountries = cache(async () => { + const { data } = await client.fetch({ + document: SupportedShippingDestinationsQuery, + fetchOptions: { next: { revalidate } }, + }); + + return data.site.settings?.shipping?.supportedShippingDestinations.countries ?? []; +}); diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index 5f357f4aaf..9461fae7e3 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -1,103 +1,297 @@ -import { cookies } from 'next/headers'; -import { getTranslations } from 'next-intl/server'; +import { Metadata } from 'next'; +import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; -import { getSessionCustomerAccessToken } from '~/auth'; -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; -import { TAGS } from '~/client/tags'; +import { Streamable } from '@/vibes/soul/lib/streamable'; +import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart'; +import { CartAnalyticsProvider } from '~/app/[locale]/(default)/cart/_components/cart-analytics-provider'; +import { getCartId } from '~/lib/cart'; +import { Slot } from '~/lib/makeswift/slot'; +import { exists } from '~/lib/utils'; -import { CartItem, CartItemFragment } from './_components/cart-item'; +import { redirectToCheckout } from './_actions/redirect-to-checkout'; +import { updateCouponCode } from './_actions/update-coupon-code'; +import { updateLineItem } from './_actions/update-line-item'; +import { updateShippingInfo } from './_actions/update-shipping-info'; import { CartViewed } from './_components/cart-viewed'; -import { CheckoutButton } from './_components/checkout-button'; -import { CheckoutSummary, CheckoutSummaryFragment } from './_components/checkout-summary'; -import { EmptyCart } from './_components/empty-cart'; -import { GeographyFragment } from './_components/shipping-estimator/fragment'; - -const CartPageQuery = graphql( - ` - query CartPageQuery($cartId: String) { - site { - cart(entityId: $cartId) { - entityId - currencyCode - lineItems { - ...CartItemFragment - } - } - checkout(entityId: $cartId) { - ...CheckoutSummaryFragment - } - } - geography { - ...GeographyFragment - } - } - `, - [CartItemFragment, CheckoutSummaryFragment, GeographyFragment], -); - -export async function generateMetadata() { - const t = await getTranslations('Cart'); +import { CheckoutPreconnect } from './_components/checkout-preconnect'; +import { getCart, getShippingCountries } from './page-data'; + +interface Props { + params: Promise<{ locale: string }>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + + const t = await getTranslations({ locale, namespace: 'Cart' }); return { title: t('title'), }; } -export default async function Cart() { - const cookieStore = await cookies(); +const getAnalyticsData = async (cartId: string) => { + const data = await getCart({ cartId }); - const cartId = cookieStore.get('cartId')?.value; + const cart = data.site.cart; - if (!cartId) { - return ; + if (!cart) { + return []; } - const t = await getTranslations('Cart'); + const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems]; - const customerAccessToken = await getSessionCustomerAccessToken(); - - const { data } = await client.fetch({ - document: CartPageQuery, - variables: { cartId }, - customerAccessToken, - fetchOptions: { - cache: 'no-store', - next: { - tags: [TAGS.cart, TAGS.checkout], - }, - }, + return lineItems.map((item) => { + return { + entityId: item.entityId, + id: item.productEntityId, + name: item.name, + brand: item.brand ?? '', + sku: item.sku ?? '', + price: item.listPrice.value, + quantity: item.quantity, + currency: item.listPrice.currencyCode, + }; }); +}; + +// eslint-disable-next-line complexity +export default async function Cart({ params }: Props) { + const { locale } = await params; + + setRequestLocale(locale); + + const t = await getTranslations('Cart'); + const format = await getFormatter(); + const cartId = await getCartId(); + + const emptyState = ( + <> + + + + + ); + + if (!cartId) { + return emptyState; + } + + const data = await getCart({ cartId }); const cart = data.site.cart; const checkout = data.site.checkout; - const geography = data.geography; if (!cart) { - return ; + return emptyState; } const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems]; + const formattedLineItems = lineItems.map((item) => ({ + id: item.entityId, + quantity: item.quantity, + price: format.number(item.listPrice.value, { + style: 'currency', + currency: item.listPrice.currencyCode, + }), + subtitle: item.selectedOptions + .map((option) => { + switch (option.__typename) { + case 'CartSelectedMultipleChoiceOption': + case 'CartSelectedCheckboxOption': + return `${option.name}: ${option.value}`; + + case 'CartSelectedNumberFieldOption': + return `${option.name}: ${option.number}`; + + case 'CartSelectedMultiLineTextFieldOption': + case 'CartSelectedTextFieldOption': + return `${option.name}: ${option.text}`; + + case 'CartSelectedDateFieldOption': + return `${option.name}: ${format.dateTime(new Date(option.date.utc))}`; + + default: + return ''; + } + }) + .join(', '), + title: item.name, + image: { src: item.image?.url || '', alt: item.name }, + href: new URL(item.url).pathname, + selectedOptions: item.selectedOptions, + productEntityId: item.productEntityId, + variantEntityId: item.variantEntityId, + })); + + const totalCouponDiscount = + checkout?.coupons.reduce((sum, coupon) => sum + coupon.discountedAmount.value, 0) ?? 0; + + const shippingConsignment = + checkout?.shippingConsignments?.find((consignment) => consignment.selectedShippingOption) || + checkout?.shippingConsignments?.[0]; + + const shippingCountries = await getShippingCountries(); + + const countries = shippingCountries.map((country) => ({ + value: country.code, + label: country.name, + })); + + const statesOrProvinces = shippingCountries.map((country) => ({ + country: country.code, + states: country.statesOrProvinces.map((state) => ({ + value: state.entityId.toString(), + label: state.name, + })), + })); + + const showShippingForm = + shippingConsignment?.address && !shippingConsignment.selectedShippingOption; + + const checkoutUrl = data.site.settings?.url.checkoutUrl; + return ( -
    -

    {t('heading')}

    -
    -
      - {lineItems.map((product) => ( - - ))} -
    - -
    - {checkout && } - - -
    -
    - -
    + <> + + getAnalyticsData(cartId))}> + {checkoutUrl ? : null} + 0 + ? { + label: t('CheckoutSummary.discounts'), + value: `-${format.number(cart.discountedAmount.value, { + style: 'currency', + currency: cart.currencyCode, + })}`, + } + : null, + totalCouponDiscount > 0 + ? { + label: t('CheckoutSummary.CouponCode.couponCode'), + value: `-${format.number(totalCouponDiscount, { + style: 'currency', + currency: cart.currencyCode, + })}`, + } + : null, + checkout?.taxTotal && { + label: t('CheckoutSummary.tax'), + value: format.number(checkout.taxTotal.value, { + style: 'currency', + currency: cart.currencyCode, + }), + }, + ].filter(exists), + }} + checkoutAction={redirectToCheckout} + checkoutLabel={t('proceedToCheckout')} + couponCode={{ + action: updateCouponCode, + couponCodes: checkout?.coupons.map((coupon) => coupon.code) ?? [], + ctaLabel: t('CheckoutSummary.CouponCode.apply'), + label: t('CheckoutSummary.CouponCode.couponCode'), + removeLabel: t('CheckoutSummary.CouponCode.removeCouponCode'), + }} + decrementLineItemLabel={t('decrement')} + deleteLineItemLabel={t('removeItem')} + emptyState={{ + title: t('Empty.title'), + subtitle: t('Empty.subtitle'), + cta: { label: t('Empty.cta'), href: '/shop-all' }, + }} + incrementLineItemLabel={t('increment')} + key={`${cart.entityId}-${cart.version}`} + lineItemAction={updateLineItem} + lineItemActionPendingLabel={t('cartUpdateInProgress')} + shipping={{ + action: updateShippingInfo, + countries, + states: statesOrProvinces, + address: shippingConsignment?.address + ? { + country: shippingConsignment.address.countryCode, + city: + shippingConsignment.address.city !== '' + ? (shippingConsignment.address.city ?? undefined) + : undefined, + state: + shippingConsignment.address.stateOrProvince !== '' + ? (shippingConsignment.address.stateOrProvince ?? undefined) + : undefined, + postalCode: + shippingConsignment.address.postalCode !== '' + ? (shippingConsignment.address.postalCode ?? undefined) + : undefined, + } + : undefined, + shippingOptions: shippingConsignment?.availableShippingOptions + ? shippingConsignment.availableShippingOptions.map((option) => ({ + label: option.description, + value: option.entityId, + price: format.number(option.cost.value, { + style: 'currency', + currency: checkout?.cart?.currencyCode, + }), + })) + : undefined, + shippingOption: shippingConsignment?.selectedShippingOption + ? { + value: shippingConsignment.selectedShippingOption.entityId, + label: shippingConsignment.selectedShippingOption.description, + price: format.number(shippingConsignment.selectedShippingOption.cost.value, { + style: 'currency', + currency: checkout?.cart?.currencyCode, + }), + } + : undefined, + showShippingForm, + shippingLabel: t('CheckoutSummary.Shipping.shipping'), + addLabel: t('CheckoutSummary.Shipping.add'), + changeLabel: t('CheckoutSummary.Shipping.change'), + countryLabel: t('CheckoutSummary.Shipping.country'), + cityLabel: t('CheckoutSummary.Shipping.city'), + stateLabel: t('CheckoutSummary.Shipping.state'), + postalCodeLabel: t('CheckoutSummary.Shipping.postalCode'), + updateShippingOptionsLabel: t('CheckoutSummary.Shipping.updatedShippingOptions'), + viewShippingOptionsLabel: t('CheckoutSummary.Shipping.viewShippingOptions'), + cancelLabel: t('CheckoutSummary.Shipping.cancel'), + editAddressLabel: t('CheckoutSummary.Shipping.editAddress'), + shippingOptionsLabel: t('CheckoutSummary.Shipping.shippingOptions'), + updateShippingLabel: t('CheckoutSummary.Shipping.updateShipping'), + addShippingLabel: t('CheckoutSummary.Shipping.addShipping'), + noShippingOptionsLabel: t('CheckoutSummary.Shipping.noShippingOptions'), + }} + summaryTitle={t('CheckoutSummary.title')} + title={t('title')} + /> + + + + ); } - -export const runtime = 'edge'; diff --git a/core/app/[locale]/(default)/checkout/route.ts b/core/app/[locale]/(default)/checkout/route.ts new file mode 100644 index 0000000000..b99f40bd7e --- /dev/null +++ b/core/app/[locale]/(default)/checkout/route.ts @@ -0,0 +1,80 @@ +import { BigCommerceAuthError } from '@bigcommerce/catalyst-client'; +import { unstable_rethrow as rethrow } from 'next/navigation'; +import { NextRequest, NextResponse } from 'next/server'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { getChannelIdFromLocale } from '~/channels.config'; +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { redirect } from '~/i18n/routing'; +import { getVisitIdCookie, getVisitorIdCookie } from '~/lib/analytics/bigcommerce'; +import { getCartId } from '~/lib/cart'; + +const CheckoutRedirectMutation = graphql(` + mutation CheckoutRedirectMutation($cartId: String!, $visitId: UUID, $visitorId: UUID) { + cart { + createCartRedirectUrls( + input: { cartEntityId: $cartId, visitId: $visitId, visitorId: $visitorId } + ) { + errors { + ... on NotFoundError { + __typename + } + } + redirectUrls { + redirectedCheckoutUrl + } + } + } + } +`); + +export async function GET(req: NextRequest, { params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const cartId = req.nextUrl.searchParams.get('cartId') ?? (await getCartId()); + const customerAccessToken = await getSessionCustomerAccessToken(); + const channelId = getChannelIdFromLocale(locale); + + if (!cartId) { + return redirect({ href: '/cart', locale }); + } + + const visitId = await getVisitIdCookie(); + const visitorId = await getVisitorIdCookie(); + + try { + const { data } = await client.fetch({ + document: CheckoutRedirectMutation, + variables: { cartId, visitId, visitorId }, + fetchOptions: { cache: 'no-store' }, + customerAccessToken, + channelId, + }); + + if ( + data.cart.createCartRedirectUrls.errors.length > 0 || + !data.cart.createCartRedirectUrls.redirectUrls + ) { + return redirect({ href: '/cart', locale }); + } + + return redirect({ + href: data.cart.createCartRedirectUrls.redirectUrls.redirectedCheckoutUrl, + locale, + }); + } catch (error) { + rethrow(error); + + if (error instanceof BigCommerceAuthError) { + return redirect({ href: '/logout?redirectTo=/checkout/', locale }); + } + + // eslint-disable-next-line no-console + console.error(error); + + return NextResponse.json( + { message: 'Server error' }, + { status: 500, statusText: 'Server error' }, + ); + } +} diff --git a/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts b/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts deleted file mode 100644 index 2233635968..0000000000 --- a/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts +++ /dev/null @@ -1,77 +0,0 @@ -'use server'; - -import { revalidateTag } from 'next/cache'; -import { cookies } from 'next/headers'; - -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 cookieStore = await cookies(); - const cartId = cookieStore.get('cartId')?.value; - - let cart; - - try { - cart = await getCart(cartId); - - if (cart) { - const addCartLineItemResponse = await addCartLineItem(cart.entityId, { - lineItems: [ - { - productEntityId, - quantity: 1, - }, - ], - }); - - assertAddCartLineItemErrors(addCartLineItemResponse); - - cart = addCartLineItemResponse.data.cart.addCartLineItems?.cart; - - if (!cart?.entityId) { - return { status: 'error', error: 'Failed to add product to cart.' }; - } - - revalidateTag(TAGS.cart); - - return { status: 'success', data: cart }; - } - - 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.' }; - } - - cookieStore.set({ - name: 'cartId', - value: cart.entityId, - httpOnly: true, - sameSite: 'lax', - secure: true, - path: '/', - }); - - revalidateTag(TAGS.cart); - - return { status: 'success', data: cart }; - } catch (error: unknown) { - if (error instanceof Error) { - return { status: 'error', error: error.message }; - } - - return { status: 'error', error: 'Something went wrong. Please try again.' }; - } -}; diff --git a/core/app/[locale]/(default)/compare/_actions/add-to-cart.tsx b/core/app/[locale]/(default)/compare/_actions/add-to-cart.tsx new file mode 100644 index 0000000000..d04cdc1536 --- /dev/null +++ b/core/app/[locale]/(default)/compare/_actions/add-to-cart.tsx @@ -0,0 +1,95 @@ +'use server'; + +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { getTranslations } from 'next-intl/server'; +import { ReactNode } from 'react'; + +import { compareAddToCartFormDataSchema } from '@/vibes/soul/primitives/compare-card/schema'; +import { Link } from '~/components/link'; +import { addToOrCreateCart } from '~/lib/cart'; +import { MissingCartError } from '~/lib/cart/error'; + +interface State { + lastResult: SubmissionResult | null; + successMessage?: ReactNode; + errorMessage?: string; +} + +export const addToCart = async ( + prevState: State, + payload: FormData, +): Promise<{ + lastResult: SubmissionResult | null; + successMessage?: ReactNode; +}> => { + const t = await getTranslations('Compare'); + + const submission = parseWithZod(payload, { schema: compareAddToCartFormDataSchema }); + + if (submission.status !== 'success') { + return { lastResult: submission.reply() }; + } + + const productEntityId = Number(submission.value.id); + const quantity = 1; + + try { + await addToOrCreateCart({ + lineItems: [ + { + productEntityId, + quantity, + }, + ], + }); + + return { + lastResult: submission.reply(), + successMessage: t.rich('successMessage', { + cartItems: quantity, + cartLink: (chunks) => ( + + {chunks} + + ), + }), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => { + if (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. + return message.replace('Not enough stock: ', '').replace(/\(\w.+\)\s{1}/, ''); + } + + return message; + }), + }), + }; + } + + if (error instanceof MissingCartError) { + return { + lastResult: submission.reply({ formErrors: [t('missingCart')] }), + }; + } + + if (error instanceof Error) { + return { + lastResult: submission.reply({ formErrors: [error.message] }), + }; + } + + return { + lastResult: submission.reply({ formErrors: [t('unknownError')] }), + }; + } +}; diff --git a/core/app/[locale]/(default)/compare/_components/add-to-cart/fragment.ts b/core/app/[locale]/(default)/compare/_components/add-to-cart/fragment.ts deleted file mode 100644 index b90dc04582..0000000000 --- a/core/app/[locale]/(default)/compare/_components/add-to-cart/fragment.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { graphql } from '~/client/graphql'; -import { AddToCartButtonFragment } from '~/components/add-to-cart-button/fragment'; - -export const AddToCartFragment = graphql( - ` - fragment AddToCartFragment on Product { - entityId - ...AddToCartButtonFragment - } - `, - [AddToCartButtonFragment], -); 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 deleted file mode 100644 index 6cbbf4102e..0000000000 --- a/core/app/[locale]/(default)/compare/_components/add-to-cart/index.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import { FragmentOf } from 'gql.tada'; -import { AlertCircle, Check } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { useId, useTransition } from 'react'; -import { toast } from 'react-hot-toast'; - -import { AddToCartButton } from '~/components/add-to-cart-button'; -import { useCart } from '~/components/header/cart-provider'; -import { Link } from '~/components/link'; - -import { addToCart } from '../../_actions/add-to-cart'; - -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) => { - event.preventDefault(); - - const formData = new FormData(event.currentTarget); - const quantity = Number(formData.get('quantity')); - - // Optimistic update - cart.increment(quantity); - toast.success( - () => ( -
    - - {t.rich('success', { - cartItems: quantity, - cartLink: (chunks) => ( - - {chunks} - - ), - })} - -
    - ), - { icon: , id: toastId }, - ); - - startTransition(async () => { - const result = await addToCart(formData); - - if (result.error) { - cart.decrement(quantity); - - toast.error(result.error, { - icon: , - id: toastId, - }); - } - }); - }; - - return ( -
    - - - - - - ); -}; diff --git a/core/app/[locale]/(default)/compare/_components/compare-analytics-provider.tsx b/core/app/[locale]/(default)/compare/_components/compare-analytics-provider.tsx new file mode 100644 index 0000000000..90ad3e295b --- /dev/null +++ b/core/app/[locale]/(default)/compare/_components/compare-analytics-provider.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { PropsWithChildren, Suspense } from 'react'; +import { z } from 'zod'; + +import { Streamable, useStreamable } from '@/vibes/soul/lib/streamable'; +import { EventsProvider } from '~/components/analytics/events'; +import { useAnalytics } from '~/lib/analytics/react'; + +interface AddToCartContext { + id: number; + name: string; + brand: string; + sku?: string; + currency: string; + price: number; +} + +const AddToCartSchema = z.object({ + id: z.number({ coerce: true }), + quantity: z.number({ coerce: true }).default(1), +}); + +export function CompareAnalyticsProvider( + props: PropsWithChildren<{ data: Streamable }>, +) { + return ( + + + + ); +} + +function CompareAnalyticsProviderResolved({ + children, + data, +}: PropsWithChildren<{ data: Streamable }>) { + const analytics = useAnalytics(); + const products = useStreamable(data); + + const onAddToCart = (payload?: FormData) => { + const parsedPayload = AddToCartSchema.safeParse(Object.fromEntries(payload?.entries() ?? [])); + + if (parsedPayload.success) { + const { id: productId, quantity } = parsedPayload.data; + const product = products.find(({ id }) => id === productId); + + if (product) { + const { id, name, brand, sku, price, currency } = product; + + analytics?.cart.productAdded({ + currency, + value: 1 * price, + items: [ + { + id: id.toString(), + name, + brand, + sku, + price, + quantity, + }, + ], + }); + } + } + }; + + return {children}; +} diff --git a/core/app/[locale]/(default)/compare/page-data.ts b/core/app/[locale]/(default)/compare/page-data.ts new file mode 100644 index 0000000000..9b0acac0f7 --- /dev/null +++ b/core/app/[locale]/(default)/compare/page-data.ts @@ -0,0 +1,77 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { cache } from 'react'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; +import { CurrencyCode } from '~/components/header/fragment'; +import { ProductCardFragment } from '~/components/product-card/fragment'; + +export const MAX_COMPARE_LIMIT = 10; + +const ComparedProductsQuery = graphql( + ` + query ComparedProductsQuery($entityIds: [Int!], $first: Int, $currencyCode: currencyCode) { + site { + products(entityIds: $entityIds, first: $first) { + edges { + node { + ...ProductCardFragment + description + sku + weight { + value + unit + } + condition + customFields { + edges { + node { + entityId + name + value + } + } + } + productOptions(first: 1) { + edges { + node { + entityId + } + } + } + inventory { + isInStock + } + availabilityV2 { + status + } + } + } + } + } + } + `, + [ProductCardFragment], +); + +export const getComparedProducts = cache( + async (productIds: number[] = [], currencyCode?: CurrencyCode, customerAccessToken?: string) => { + if (productIds.length === 0) { + return []; + } + + const { data } = await client.fetch({ + document: ComparedProductsQuery, + variables: { + entityIds: productIds, + first: productIds.length ? MAX_COMPARE_LIMIT : 0, + currencyCode, + }, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + }); + + return removeEdgesAndNodes(data.site.products); + }, +); diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index 46f2803daa..13d8e868d3 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -1,23 +1,17 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { getFormatter, getTranslations } from 'next-intl/server'; +import { Metadata } from 'next'; +import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; import * as z from 'zod'; +import { Streamable } from '@/vibes/soul/lib/streamable'; +import { CompareSection } from '@/vibes/soul/sections/compare-section'; 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 { Image } from '~/components/image'; -import { Link } from '~/components/link'; -import { SearchForm } from '~/components/search-form'; -import { Button } from '~/components/ui/button'; -import { Rating } from '~/components/ui/rating'; -import { cn } from '~/lib/utils'; +import { pricesTransformer } from '~/data-transformers/prices-transformer'; +import { getPreferredCurrencyCode } from '~/lib/currency'; -import { AddToCart } from './_components/add-to-cart'; -import { AddToCartFragment } from './_components/add-to-cart/fragment'; - -const MAX_COMPARE_LIMIT = 10; +import { addToCart } from './_actions/add-to-cart'; +import { CompareAnalyticsProvider } from './_components/compare-analytics-provider'; +import { getComparedProducts } from './page-data'; const CompareParamsSchema = z.object({ ids: z @@ -36,341 +30,107 @@ const CompareParamsSchema = z.object({ .transform((value) => value?.map((id) => parseInt(id, 10))), }); -const ComparePageQuery = graphql( - ` - query ComparePageQuery($entityIds: [Int!], $first: Int) { - site { - products(entityIds: $entityIds, first: $first) { - edges { - node { - entityId - name - path - brand { - name - } - defaultImage { - altText - url: urlTemplate(lossy: true) - } - reviewSummary { - numberOfReviews - averageRating - } - productOptions(first: 3) { - edges { - node { - entityId - } - } - } - description - inventory { - aggregated { - availableToSell - } - } - ...AddToCartFragment - ...PricingFragment - } - } - } - } - } - `, - [AddToCartFragment, PricingFragment], -); +interface Props { + params: Promise<{ locale: string }>; + searchParams: Promise<{ + ids?: string | string[]; + }>; +} -export async function generateMetadata() { - const t = await getTranslations('Compare'); +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + + const t = await getTranslations({ locale, namespace: 'Compare' }); return { title: t('title'), }; } -interface Props { - searchParams: Promise>; -} - export default async function Compare(props: Props) { - const searchParams = await props.searchParams; - const t = await getTranslations('Compare'); - const format = await getFormatter(); - const customerAccessToken = await getSessionCustomerAccessToken(); + const { locale } = await props.params; - const parsed = CompareParamsSchema.parse(searchParams); - const productIds = parsed.ids?.filter((id) => !Number.isNaN(id)); + setRequestLocale(locale); - const { data } = await client.fetch({ - document: ComparePageQuery, - variables: { - entityIds: productIds ?? [], - first: productIds?.length ? MAX_COMPARE_LIMIT : 0, - }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); + const t = await getTranslations('Compare'); - const products = removeEdgesAndNodes(data.site.products).map((product) => ({ - ...product, - productOptions: removeEdgesAndNodes(product.productOptions), - })); + const streamableProducts = Streamable.from(async () => { + const customerAccessToken = await getSessionCustomerAccessToken(); + const currencyCode = await getPreferredCurrencyCode(); + + const searchParams = await props.searchParams; + const parsed = CompareParamsSchema.parse(searchParams); + const productIds = parsed.ids?.filter((id) => !Number.isNaN(id)); + + const products = await getComparedProducts(productIds, currencyCode, customerAccessToken); + const format = await getFormatter(); + + return products.map((product) => ({ + id: product.entityId.toString(), + title: product.name, + href: product.path, + image: product.defaultImage + ? { src: product.defaultImage.url, alt: product.defaultImage.altText } + : undefined, + price: pricesTransformer(product.prices, format), + subtitle: product.brand?.name ?? undefined, + rating: product.reviewSummary.averageRating, + description:
    , + customFields: [ + { name: t('sku'), value: product.sku }, + { name: t('weight'), value: `${product.weight?.value} ${product.weight?.unit}` }, + ...removeEdgesAndNodes(product.customFields).map(({ name, value }) => ({ name, value })), + ], + hasVariants: removeEdgesAndNodes(product.productOptions).length > 0, + isPreorder: product.availabilityV2.status === 'Preorder', + disabled: product.availabilityV2.status === 'Unavailable' || !product.inventory.isInStock, + })); + }); - if (!products.length) { - return ( -
    -
    -

    {t('nothingToCompare')}

    -

    {t('helpingText')}

    - -
    -
    - ); - } + const streamableAnalyticsData = Streamable.from(async () => { + const customerAccessToken = await getSessionCustomerAccessToken(); + const currencyCode = await getPreferredCurrencyCode(); + + const searchParams = await props.searchParams; + const parsed = CompareParamsSchema.parse(searchParams); + const productIds = parsed.ids?.filter((id) => !Number.isNaN(id)); + + const products = await getComparedProducts(productIds, currencyCode, customerAccessToken); + + return products.map((product) => { + return { + id: product.entityId, + name: product.name, + sku: product.sku, + brand: product.brand?.name ?? '', + price: product.prices?.price.value ?? 0, + currency: product.prices?.price.currencyCode ?? '', + }; + }); + }); return ( - <> -

    - {t('heading', { quantity: products.length })} -

    - -
    - - - - - - - - - - {products.map((product) => ( - - ))} - - - {products.map((product) => { - if (product.defaultImage) { - return ( - - ); - } - - return ( - - ); - })} - - - {products.map((product) => ( - - ))} - - - {products.map((product) => ( - - ))} - - - {products.map((product) => { - const showPriceRange = - product.prices?.priceRange.min.value !== product.prices?.priceRange.max.value; - - return ( - - ); - })} - - - {products.map((product) => { - if (product.productOptions.length) { - return ( - - ); - } - - return ( - - ); - })} - - - - - - - - {products.map((product) => ( - - - - - - {products.map((product) => ( - - ))} - - - - - - {products.map((product) => ( - - ))} - - - {products.map((product) => { - if (product.productOptions.length) { - return ( - - ); - } - - return ( - - ); - })} - - -
    {t('Table.caption')}
    - {product.name} -
    - - {product.defaultImage.altText} - - - -
    -

    {t('Table.noImage')}

    -
    - -
    - {product.brand?.name} -
    - {product.name} -
    - {product.prices && ( -

    - {showPriceRange ? ( - <> - {format.number(product.prices.priceRange.min.value, { - style: 'currency', - currency: product.prices.price.currencyCode, - })}{' '} - -{' '} - {format.number(product.prices.priceRange.max.value, { - style: 'currency', - currency: product.prices.price.currencyCode, - })} - - ) : ( - <> - {product.prices.retailPrice?.value !== undefined && ( - <> - {t('Table.Prices.msrp')}:{' '} - - {format.number(product.prices.retailPrice.value, { - style: 'currency', - currency: product.prices.price.currencyCode, - })} - -
    - - )} - {product.prices.salePrice?.value !== undefined && - product.prices.basePrice?.value !== undefined ? ( - <> - {t('Table.Prices.was')}:{' '} - - {format.number(product.prices.basePrice.value, { - style: 'currency', - currency: product.prices.price.currencyCode, - })} - -
    - <> - {t('Table.Prices.now')}:{' '} - {format.number(product.prices.price.value, { - style: 'currency', - currency: product.prices.price.currencyCode, - })} - - - ) : ( - product.prices.price.value && ( - <> - {format.number(product.prices.price.value, { - style: 'currency', - currency: product.prices.price.currencyCode, - })} - - ) - )} - - )} -

    - )} -
    - - - -
    - {t('Table.description')} -
    - ))} -
    - {t('Table.rating')} -
    -

    - -

    -
    - {t('Table.availability')} -
    - {product.inventory.aggregated?.availableToSell || 'N/A'} -
    - - - -
    -
    - + + + ); } -export const runtime = 'edge'; +// Disabled to circumvent a bug in Next.js and PPR +// More info: https://github.com/vercel/next.js/issues/59407 +export const experimental_ppr = false; diff --git a/core/app/[locale]/(default)/error.tsx b/core/app/[locale]/(default)/error.tsx new file mode 100644 index 0000000000..de9c3456a8 --- /dev/null +++ b/core/app/[locale]/(default)/error.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +import { Error as ErrorSection } from '@/vibes/soul/sections/error'; + +interface Props { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function Error({ reset }: Props) { + const t = useTranslations('Error'); + + return ( + + ); +} diff --git a/core/app/[locale]/(default)/layout.tsx b/core/app/[locale]/(default)/layout.tsx index a1dc8354ac..a1cc36b4f4 100644 --- a/core/app/[locale]/(default)/layout.tsx +++ b/core/app/[locale]/(default)/layout.tsx @@ -1,9 +1,8 @@ import { setRequestLocale } from 'next-intl/server'; -import { PropsWithChildren, Suspense } from 'react'; +import { PropsWithChildren } from 'react'; -import { Footer } from '~/components/footer/footer'; -import { Header, HeaderSkeleton } from '~/components/header'; -import { Cart } from '~/components/header/cart'; +import { Footer } from '~/components/footer'; +import { Header } from '~/components/header'; interface Props extends PropsWithChildren { params: Promise<{ locale: string }>; @@ -16,17 +15,13 @@ export default async function DefaultLayout({ params, children }: Props) { return ( <> - }> -
    } /> - +
    -
    - {children} -
    +
    {children}
    - -