diff --git a/.changeset/sync-makeswift-1-7-0.md b/.changeset/sync-makeswift-1-7-0.md new file mode 100644 index 0000000000..7eb0e58f09 --- /dev/null +++ b/.changeset/sync-makeswift-1-7-0.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-b2b-makeswift": minor +--- + +Pulls in changes from the `@bigcommerce/catalyst-makeswift@1.7.0` release. For more information, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob/c0ccea448eb99171a7505671d5f956a611512750/core/CHANGELOG.md#170). diff --git a/.claude/skills/release-catalyst-patch/SKILL.md b/.claude/skills/release-catalyst-patch/SKILL.md new file mode 100644 index 0000000000..ec489bb1de --- /dev/null +++ b/.claude/skills/release-catalyst-patch/SKILL.md @@ -0,0 +1,233 @@ +--- +name: release-catalyst-patch +description: > + Release a single Catalyst package patch in isolation, without bundling it with + other queued changesets in the open Version Packages PR. Use when the user says + "/release-catalyst-patch", "isolate a patch release", "publish only one package", + or wants to ship a single package's changeset ahead of the normal release cadence. + Performs the full flow locally (`changeset version`, build, `changeset publish`, + push, GitHub release) so the remaining queued changesets stay untouched in the + Version Packages PR. +--- + +# Release Catalyst Patch (single-package isolation) + +The Changesets GitHub Action picks one mode per push to `canary`: if any unconsumed +changesets exist, it opens/refreshes the Version Packages PR and **does not** publish. +That's why we publish locally — we keep the other changesets in `.changeset/` so the +Version Packages PR still tracks them, but we ship just the one we want now. + +Execute stages in order. Pause for user input where indicated. **Never execute the +`changeset publish` command yourself** — provide it to the user to run. + +## Stage 0: Confirm scope + +Identify the changeset to release and the target package + version. + +```bash +ls .changeset/ +git log --oneline -10 # find the PR/commit that added the changeset +cat .changeset/.md # confirm the package + bump type +npm view version dist-tags # confirm current published state +``` + +Confirm with the user: +- Which `.changeset/*.md` file is being released +- The target package and the resulting version (e.g., `1.0.2` → `1.0.3`) +- That **all other** changesets in `.changeset/` should remain queued for the next regular release + +If the package has notes in the [Package-specific notes](#package-specific-notes) section +below, review them before continuing. + +## Stage 1: Branch + quarantine + +Work on a release branch so the workflow doesn't trigger mid-flow. + +```bash +git switch canary && git pull +git switch -c release/- +``` + +Move the **other** changesets **outside** the repo. A subdirectory inside `.changeset/` +gets parsed as a malformed changeset by the CLI and crashes `changeset version`: + +```bash +mkdir -p /tmp/changeset-hold +mv .changeset/.md .changeset/.md ... /tmp/changeset-hold/ +ls .changeset/ # should leave only config.json + the one changeset to release +``` + +## Stage 2: Run `changeset version` + +Requires `GITHUB_TOKEN` because the repo uses `@changesets/changelog-github`. The `gh` +CLI token works. + +```bash +pnpm install --frozen-lockfile +GITHUB_TOKEN=$(gh auth token) pnpm changeset version +``` + +Restore the held changesets immediately: + +```bash +mv /tmp/changeset-hold/*.md .changeset/ +rmdir /tmp/changeset-hold +``` + +Verify the diff: + +```bash +git status +git diff packages//package.json packages//CHANGELOG.md +``` + +Expect: bumped `package.json`, new CHANGELOG entry, deleted only the one changeset. +The other changesets should be back in `.changeset/` and untouched. + +## Stage 3: Build + +Run the root build. Turborepo handles env passthrough from `.env.local` via the +pipeline config, so package builds get the variables they need without manual sourcing. + +```bash +pnpm build +``` + +If a package has build-time secrets that must be present, see the +[Package-specific notes](#package-specific-notes) section for verification steps. + +## Stage 4: Commit + +```bash +git add -A +git commit -m "Version Packages (\`canary\`)" +``` + +Match the message format the changesets bot uses, since this is a manual stand-in for it. + +## Stage 5: Hand the publish command to the user + +**Do not run `changeset publish` from the agent.** Publishing to npm is a destructive, +externally-visible action that should be performed by the user. + +Have the user confirm they're logged in, then run the publish themselves. The CLI will +prompt them interactively for the npm OTP. + +```bash +npm whoami # expect their npm username +pnpm exec changeset publish # prompts for OTP interactively +``` + +`changeset publish` is per-package version-aware: it only publishes packages whose local +`package.json` is ahead of npm. Since only one package was bumped, only it ships. It +also creates a local git tag like `@bigcommerce/@`. + +Wait for the user to confirm the publish completed before continuing. + +## Stage 6: Verify npm state + +```bash +npm view version +npm view dist-tags +``` + +`latest` should point to the new version. If it doesn't (rare — only happens if +`publishConfig.tag` is set), advise the user to fix with: + +```bash +npm dist-tag add @ latest +``` + +## Stage 7: Fast-forward canary and push + +This requires a direct push to the protected default branch. **Pause and ask for explicit +user authorization** before pushing — the user's "never push directly to main/master/production" +rule guards against this even though the changesets bot does the same thing during normal +releases. + +```bash +git switch canary +git fetch origin canary +git log --oneline origin/canary..canary # should be 1 ahead +git log --oneline canary..origin/canary # should be 0 behind +git merge --ff-only release/- +``` + +If you're 1 ahead and 0 behind, default `git push` rejects non-fast-forward updates, +which gives the same safety as `--ff-only` on the push side. Apple's git build (≤2.50.x) +does not accept `--ff-only` as a push flag — `git push origin canary` is correct here. + +If a hook blocks the agent push, hand these commands to the user: + +```bash +git push origin canary +git push origin "@bigcommerce/@" +``` + +## Stage 8: Create the GitHub release manually + +CI runs `changeset publish` after the canary push, finds the version already on npm, +and no-ops. Because the action only creates GitHub releases for packages it actually +publishes, **no release is created automatically** in this flow. Make it manually. + +Extract the new CHANGELOG section (everything after the `## ` heading up to +the next `## ` heading) into a notes file. Then: + +```bash +gh release create "@bigcommerce/@" \ + --repo bigcommerce/catalyst \ + --title "@bigcommerce/@" \ + --notes-file /tmp/release-notes.md +``` + +Match the body format of prior releases — just the `### Patch Changes` / +`### Minor Changes` heading and the bullets, no version heading at the top. Compare +against an existing release: + +```bash +gh release view "@bigcommerce/catalyst-core@" --repo bigcommerce/catalyst --json body +``` + +## Stage 9: Final validation + +```bash +git fetch origin canary +git log --oneline origin/canary -3 # version commit on canary +git ls-remote --tags origin "@bigcommerce/@" # tag on origin +gh release view "@bigcommerce/@" --repo bigcommerce/catalyst --json tagName,isDraft,isPrerelease +gh run list --workflow=changesets-release.yml --limit 1 # CI run succeeded +gh pr list --search "Version Packages (canary)" --state open --json number,headRefName,updatedAt # Version Packages PR refreshed +git fetch origin changeset-release/canary +git show --stat origin/changeset-release/canary | head -20 # confirm only the other changesets remain +npm view version dist-tags +``` + +Report: +- Published version + dist-tag +- Canary commit SHA +- Tag pushed +- GitHub release URL +- Confirmation that the Version Packages PR now contains **only** the other changesets — the released one has been dropped from its scope. + +## Stage 10: Cleanup + +```bash +git branch -d release/- +``` + +## Package-specific notes + +### `@bigcommerce/create-catalyst` + +The CLI build (`tsup` via the package's `tsup.config.ts`) inlines `CLI_SEGMENT_WRITE_KEY` +at build time, falling back to the placeholder `'not-a-valid-segment-write-key'` if the +env var is missing. After Stage 3, verify the real key was embedded: + +```bash +grep -c "not-a-valid-segment-write-key" packages/create-catalyst/dist/index.js +# expect: 0 +``` + +If `1`, the env var wasn't loaded. Confirm `CLI_SEGMENT_WRITE_KEY` exists in `.env.local`, +and that the turbo pipeline for `build` declares it under `env` or `passThroughEnv` in +`turbo.json`. Re-run `pnpm build` after fixing. diff --git a/.claude/skills/release-catalyst/SKILL.md b/.claude/skills/release-catalyst/SKILL.md index e522ebd33f..3455cab52c 100644 --- a/.claude/skills/release-catalyst/SKILL.md +++ b/.claude/skills/release-catalyst/SKILL.md @@ -58,7 +58,7 @@ Invoke the `/sync-makeswift` skill, with one addition: during the sync (after me **Determine bump type**: Match the bump type from Stage 1 (e.g., if core went `1.4.2` → `1.5.0`, that's a `minor`). -**Create changeset file** (`.changeset/sync-canary-.md`): +**Create changeset file** (`.changeset/sync-canary-.md`, where `` uses hyphens instead of dots — e.g., `1.6.0` → `sync-canary-1-6-0.md`). Changeset filenames only allow lowercase letters and hyphens; dots are invalid. ```markdown --- diff --git a/.claude/skills/sync-makeswift/SKILL.md b/.claude/skills/sync-makeswift/SKILL.md index 83a71522c7..a07c023e3c 100644 --- a/.claude/skills/sync-makeswift/SKILL.md +++ b/.claude/skills/sync-makeswift/SKILL.md @@ -15,7 +15,7 @@ Execute the following phases in order. Pause for user input where indicated. ```bash git fetch origin git checkout -B sync-integrations-makeswift origin/integrations/makeswift -git merge canary +git merge origin/canary ``` If the merge completes cleanly, skip to changeset cleanup. Otherwise, resolve conflicts. @@ -78,5 +78,6 @@ Switch back to `canary` and delete the local branches that are no longer needed: ```bash git checkout canary +git pull git branch -D sync-integrations-makeswift integrations/makeswift ``` diff --git a/.env.example b/.env.example index 0c8ebd0c5f..be86efb274 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,7 @@ 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= +BIGCOMMERCE_STOREFRONT_UNAUTHENTICATED_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 diff --git a/.github/scripts/__tests__/audit-unlighthouse.test.mts b/.github/scripts/__tests__/audit-unlighthouse.test.mts new file mode 100644 index 0000000000..4d7522c926 --- /dev/null +++ b/.github/scripts/__tests__/audit-unlighthouse.test.mts @@ -0,0 +1,208 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildReport } from '../audit-unlighthouse.mts'; +import type { CiResult } from '../audit-unlighthouse.mts'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const DEFAULT_METRICS: CiResult['summary']['metrics'] = { + 'largest-contentful-paint': { displayValue: '2.5 s' }, + 'cumulative-layout-shift': { displayValue: '0.01' }, + 'first-contentful-paint': { displayValue: '1.2 s' }, + 'total-blocking-time': { displayValue: '100 ms' }, + 'max-potential-fid': { displayValue: '200 ms' }, + interactive: { displayValue: '3.5 s' }, +}; + +function makeCiResult(overrides: { + score?: number; + performance?: number; + accessibility?: number; + 'best-practices'?: number; + seo?: number; + metrics?: CiResult['summary']['metrics']; +} = {}): CiResult { + return { + summary: { + score: overrides.score ?? 0.85, + categories: { + performance: { score: overrides.performance ?? 0.80 }, + accessibility: { score: overrides.accessibility ?? 0.92 }, + 'best-practices': { score: overrides['best-practices'] ?? 1.0 }, + seo: { score: overrides.seo ?? 0.90 }, + }, + metrics: overrides.metrics ?? { ...DEFAULT_METRICS }, + }, + }; +} + +const BASE = makeCiResult(); + +// --------------------------------------------------------------------------- +// Report heading +// --------------------------------------------------------------------------- + +describe('report heading', () => { + it('contains the audit heading', () => { + const markdown = buildReport(BASE, BASE); + + assert.ok(markdown.includes('## Unlighthouse Audit'), 'Missing main heading'); + }); + + it('appends branch label when branch is given', () => { + const markdown = buildReport(BASE, BASE, 'canary'); + + assert.ok( + markdown.includes('## Unlighthouse Audit — `canary`'), + 'Missing branch label in heading', + ); + }); + + it('handles branch names with slashes', () => { + const markdown = buildReport(BASE, BASE, 'integrations/makeswift'); + + assert.ok(markdown.includes('`integrations/makeswift`')); + }); + + it('omits branch label when none provided', () => { + const markdown = buildReport(BASE, BASE); + + assert.ok(!markdown.includes(' — '), 'Should not contain a branch label separator'); + }); + + it('contains the description text', () => { + const markdown = buildReport(BASE, BASE); + + assert.ok(markdown.includes('Unlighthouse scores for the latest commit on this branch.')); + }); +}); + +// --------------------------------------------------------------------------- +// Summary Score section +// --------------------------------------------------------------------------- + +describe('Summary Score section', () => { + it('contains the Summary Score heading', () => { + const markdown = buildReport(BASE, BASE); + + assert.ok(markdown.includes('### Summary Score')); + }); + + it('contains the aggregate score note', () => { + const markdown = buildReport(BASE, BASE); + + assert.ok(markdown.includes('Aggregate score across all categories as reported by Unlighthouse.')); + }); + + it('renders scores as integers on a 1-100 scale', () => { + const desktop = makeCiResult({ score: 0.85 }); + const mobile = makeCiResult({ score: 0.72 }); + const markdown = buildReport(desktop, mobile); + + assert.ok(markdown.includes('| Score | 85 | 72 |')); + }); + + it('rounds fractional scores correctly', () => { + const desktop = makeCiResult({ score: 0.856 }); // rounds to 86 + const markdown = buildReport(desktop, BASE); + + assert.ok(markdown.includes('86'), 'Score 0.856 should round to 86'); + }); + + it('contains the two-column header', () => { + const markdown = buildReport(BASE, BASE); + + assert.ok(markdown.includes('| | Desktop | Mobile |')); + }); +}); + +// --------------------------------------------------------------------------- +// Category Scores section +// --------------------------------------------------------------------------- + +describe('Category Scores section', () => { + it('contains the Category Scores heading', () => { + const markdown = buildReport(BASE, BASE); + + assert.ok(markdown.includes('### Category Scores')); + }); + + it('renders all four categories', () => { + const markdown = buildReport(BASE, BASE); + + assert.ok(markdown.includes('Performance')); + assert.ok(markdown.includes('Accessibility')); + assert.ok(markdown.includes('Best Practices')); + assert.ok(markdown.includes('SEO')); + }); + + it('renders desktop and mobile scores independently', () => { + const desktop = makeCiResult({ performance: 0.80 }); + const mobile = makeCiResult({ performance: 0.93 }); + const markdown = buildReport(desktop, mobile); + + assert.ok( + markdown.includes('| Performance | 80 | 93 |'), + 'Performance row should show desktop then mobile score', + ); + }); + + it('shows all four categories independently', () => { + const desktop = makeCiResult({ seo: 0.88, accessibility: 0.75 }); + const mobile = makeCiResult({ seo: 0.91, accessibility: 0.82 }); + const markdown = buildReport(desktop, mobile); + + assert.ok(markdown.includes('| SEO | 88 | 91 |')); + assert.ok(markdown.includes('| Accessibility | 75 | 82 |')); + }); +}); + +// --------------------------------------------------------------------------- +// Core Web Vitals section +// --------------------------------------------------------------------------- + +describe('Core Web Vitals section', () => { + it('contains the Core Web Vitals heading', () => { + const markdown = buildReport(BASE, BASE); + + assert.ok(markdown.includes('### Core Web Vitals')); + }); + + it('renders all six metrics', () => { + const markdown = buildReport(BASE, BASE); + + assert.ok(markdown.includes('LCP')); + assert.ok(markdown.includes('CLS')); + assert.ok(markdown.includes('FCP')); + assert.ok(markdown.includes('TBT')); + assert.ok(markdown.includes('Max Potential FID')); + assert.ok(markdown.includes('Time to Interactive')); + }); + + it('passes displayValue through unchanged', () => { + const desktop = makeCiResult({ + metrics: { ...DEFAULT_METRICS, 'largest-contentful-paint': { displayValue: '4.8 s' } }, + }); + const markdown = buildReport(desktop, BASE); + + assert.ok(markdown.includes('4.8 s')); + }); + + it('shows — for a metric missing from a result', () => { + const desktopMissingMetric = makeCiResult({ metrics: {} }); + const markdown = buildReport(desktopMissingMetric, BASE); + + assert.ok(markdown.includes('—'), 'Missing metric should show —'); + }); + + it('shows desktop and mobile displayValues per metric row', () => { + const desktop = makeCiResult({ metrics: { ...DEFAULT_METRICS, 'total-blocking-time': { displayValue: '80 ms' } } }); + const mobile = makeCiResult({ metrics: { ...DEFAULT_METRICS, 'total-blocking-time': { displayValue: '320 ms' } } }); + const markdown = buildReport(desktop, mobile); + + assert.ok(markdown.includes('| TBT | 80 ms | 320 ms |')); + }); +}); diff --git a/.github/scripts/__tests__/post-unlighthouse-commit-comment.test.mts b/.github/scripts/__tests__/post-unlighthouse-commit-comment.test.mts new file mode 100644 index 0000000000..92b6ad4d06 --- /dev/null +++ b/.github/scripts/__tests__/post-unlighthouse-commit-comment.test.mts @@ -0,0 +1,162 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const postReport = require('../post-unlighthouse-commit-comment.js') as (args: { + github: ReturnType['github']; + context: ReturnType; + reportPath?: string; +}) => Promise; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface Calls { + createCommitComment: object[]; +} + +function makeGithub() { + const calls: Calls = { createCommitComment: [] }; + const github = { + rest: { + repos: { + createCommitComment: async (args: object) => { + calls.createCommitComment.push(args); + }, + }, + }, + }; + return { github, calls }; +} + +function makeContext({ + owner = 'test-owner', + repo = 'test-repo', + sha = 'abc123', + runId = 99, +}: { + owner?: string; + repo?: string; + sha?: string; + runId?: number; +} = {}) { + return { + repo: { owner, repo }, + runId, + payload: { + deployment: { sha }, + }, + }; +} + +let reportPath: string; + +beforeEach(() => { + const tmpDir = join(tmpdir(), `post-unlighthouse-report-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + reportPath = join(tmpDir, 'report.md'); + writeFileSync(reportPath, '## Unlighthouse Audit — `canary`\n\nSome results.'); +}); + +// --------------------------------------------------------------------------- +// Early exits +// --------------------------------------------------------------------------- + +describe('early exits', () => { + it('does nothing when deployment sha is missing', async () => { + const { github, calls } = makeGithub(); + const context = { ...makeContext(), payload: {} }; + await postReport({ github, context, reportPath }); + + assert.equal(calls.createCommitComment.length, 0); + }); +}); + +// --------------------------------------------------------------------------- +// Commit comment creation +// --------------------------------------------------------------------------- + +describe('commit comment creation', () => { + it('creates a commit comment with the deployment sha', async () => { + const { github, calls } = makeGithub(); + await postReport({ github, context: makeContext({ sha: 'deadbeef' }), reportPath }); + + assert.equal(calls.createCommitComment.length, 1); + assert.equal( + (calls.createCommitComment[0] as { commit_sha: string }).commit_sha, + 'deadbeef', + ); + }); + + it('passes the correct owner and repo', async () => { + const { github, calls } = makeGithub(); + await postReport({ + github, + context: makeContext({ owner: 'my-org', repo: 'my-repo' }), + reportPath, + }); + + assert.equal((calls.createCommitComment[0] as { owner: string }).owner, 'my-org'); + assert.equal((calls.createCommitComment[0] as { repo: string }).repo, 'my-repo'); + }); +}); + +// --------------------------------------------------------------------------- +// Comment body +// --------------------------------------------------------------------------- + +describe('comment body', () => { + it('starts with the canary marker', async () => { + const { github, calls } = makeGithub(); + await postReport({ github, context: makeContext(), reportPath }); + + const body = (calls.createCommitComment[0] as { body: string }).body; + + assert.ok( + body.startsWith('\n'), + `Body should start with marker, got: ${body.slice(0, 60)}`, + ); + }); + + it('includes the report file content', async () => { + const { github, calls } = makeGithub(); + await postReport({ github, context: makeContext(), reportPath }); + + const body = (calls.createCommitComment[0] as { body: string }).body; + + assert.ok(body.includes('## Unlighthouse Audit')); + assert.ok(body.includes('Some results.')); + }); + + it('includes the workflow run link', async () => { + const { github, calls } = makeGithub(); + await postReport({ + github, + context: makeContext({ owner: 'my-org', repo: 'my-repo', runId: 12345 }), + reportPath, + }); + + const body = (calls.createCommitComment[0] as { body: string }).body; + + assert.ok( + body.includes('https://github.com/my-org/my-repo/actions/runs/12345'), + 'Body should contain the workflow run URL', + ); + }); + + it('run link is preceded by a newline', async () => { + const { github, calls } = makeGithub(); + await postReport({ github, context: makeContext(), reportPath }); + + const body = (calls.createCommitComment[0] as { body: string }).body; + const linkIndex = body.indexOf('[Full Unlighthouse report'); + + assert.ok(linkIndex > 0, 'Run link should be present'); + assert.equal(body[linkIndex - 1], '\n', 'Run link should be preceded by a newline'); + }); +}); diff --git a/.github/scripts/__tests__/post-unlighthouse-comment.test.mts b/.github/scripts/__tests__/post-unlighthouse-pr-comment.test.mts similarity index 99% rename from .github/scripts/__tests__/post-unlighthouse-comment.test.mts rename to .github/scripts/__tests__/post-unlighthouse-pr-comment.test.mts index e4b54db698..5f6b3559ab 100644 --- a/.github/scripts/__tests__/post-unlighthouse-comment.test.mts +++ b/.github/scripts/__tests__/post-unlighthouse-pr-comment.test.mts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const postComment = require('../post-unlighthouse-comment.js') as (args: { +const postComment = require('../post-unlighthouse-pr-comment.js') as (args: { github: ReturnType['github']; context: ReturnType; provider?: string; diff --git a/.github/scripts/audit-unlighthouse.mts b/.github/scripts/audit-unlighthouse.mts new file mode 100644 index 0000000000..189ed4ab36 --- /dev/null +++ b/.github/scripts/audit-unlighthouse.mts @@ -0,0 +1,154 @@ +#!/usr/bin/env node +/* eslint-disable no-console, no-restricted-syntax, no-plusplus */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { resolve } from "node:path"; + +interface CiResult { + summary: { + score: number; + categories: Record; + metrics: Record; + }; +} + +function loadCiResult(filePath: string): CiResult { + if (!existsSync(filePath)) { + console.error(`Error: file not found: ${filePath}`); + process.exit(1); + } + + return JSON.parse(readFileSync(filePath, "utf-8")) as CiResult; +} + +function score(value: number): string { + return String(Math.round(value * 100)); +} + +function row(label: string, desktop: string, mobile: string): string { + return `| ${label} | ${desktop} | ${mobile} |`; +} + +const CATEGORY_ORDER = ["performance", "accessibility", "best-practices", "seo"]; + +const CATEGORY_LABELS: Record = { + performance: "Performance", + accessibility: "Accessibility", + "best-practices": "Best Practices", + seo: "SEO", +}; + +const METRIC_ORDER = [ + "largest-contentful-paint", + "cumulative-layout-shift", + "first-contentful-paint", + "total-blocking-time", + "max-potential-fid", + "interactive", +]; + +const METRIC_LABELS: Record = { + "largest-contentful-paint": "LCP", + "cumulative-layout-shift": "CLS", + "first-contentful-paint": "FCP", + "total-blocking-time": "TBT", + "max-potential-fid": "Max Potential FID", + interactive: "Time to Interactive", +}; + +function buildReport( + desktop: CiResult, + mobile: CiResult, + branch?: string, +): string { + const branchLabel = branch ? ` — \`${branch}\`` : ""; + + const lines: string[] = []; + + lines.push(`## Unlighthouse Audit${branchLabel}`); + lines.push("Unlighthouse scores for the latest commit on this branch."); + lines.push(""); + + lines.push("### Summary Score"); + lines.push( + "_Aggregate score across all categories as reported by Unlighthouse._", + ); + lines.push(""); + lines.push("| | Desktop | Mobile |"); + lines.push("|:-|:--------|:-------|"); + lines.push(row("Score", score(desktop.summary.score), score(mobile.summary.score))); + lines.push(""); + + lines.push("### Category Scores"); + lines.push(""); + lines.push("| Category | Desktop | Mobile |"); + lines.push("|:---------|:--------|:-------|"); + + for (const id of CATEGORY_ORDER) { + lines.push( + row( + CATEGORY_LABELS[id] ?? id, + score(desktop.summary.categories[id]?.score ?? 0), + score(mobile.summary.categories[id]?.score ?? 0), + ), + ); + } + + lines.push(""); + + lines.push("### Core Web Vitals"); + lines.push(""); + lines.push("| Metric | Desktop | Mobile |"); + lines.push("|:-------|:--------|:-------|"); + + for (const id of METRIC_ORDER) { + lines.push( + row( + METRIC_LABELS[id] ?? id, + desktop.summary.metrics[id]?.displayValue ?? "—", + mobile.summary.metrics[id]?.displayValue ?? "—", + ), + ); + } + + lines.push(""); + + return lines.join("\n"); +} + +export { buildReport }; +export type { CiResult }; + +const isMain = process.argv[1] === fileURLToPath(import.meta.url); + +if (isMain) { + const { values } = parseArgs({ + options: { + desktop: { type: "string" }, + mobile: { type: "string" }, + branch: { type: "string" }, + output: { type: "string" }, + }, + }); + + if (!values.desktop || !values.mobile) { + console.error( + "Usage: report-unlighthouse.mts --desktop --mobile [--branch ] [--output ]", + ); + process.exit(1); + } + + const desktop = loadCiResult(resolve(values.desktop)); + const mobile = loadCiResult(resolve(values.mobile)); + const markdown = buildReport(desktop, mobile, values.branch); + const outputPath = values.output ? resolve(values.output) : null; + + if (outputPath) { + writeFileSync(outputPath, markdown); + console.error(`Unlighthouse report written to ${outputPath}`); + } else { + process.stdout.write(markdown); + } +} diff --git a/.github/scripts/post-unlighthouse-commit-comment.js b/.github/scripts/post-unlighthouse-commit-comment.js new file mode 100644 index 0000000000..a2a541ead9 --- /dev/null +++ b/.github/scripts/post-unlighthouse-commit-comment.js @@ -0,0 +1,18 @@ +const fs = require('fs'); + +module.exports = async ({ github, context, reportPath = '/tmp/unlighthouse-report.md' }) => { + const sha = context.payload.deployment?.sha; + + if (!sha) return; + + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const marker = ``; + const body = marker + '\n' + fs.readFileSync(reportPath, 'utf-8') + `\n[Full Unlighthouse report →](${runUrl})\n`; + + await github.rest.repos.createCommitComment({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: sha, + body, + }); +}; diff --git a/.github/scripts/post-unlighthouse-comment.js b/.github/scripts/post-unlighthouse-pr-comment.js similarity index 100% rename from .github/scripts/post-unlighthouse-comment.js rename to .github/scripts/post-unlighthouse-pr-comment.js diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 51fe612489..6d0119550c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -52,6 +52,10 @@ jobs: locale-var: TESTS_ALTERNATE_LOCALE artifact-name: playwright-report-alternate-locale + env: + TESTS_LOCALE: ${{ vars[matrix.locale-var] }} + TRAILING_SLASH: ${{ matrix.trailing-slash }} + steps: - name: Checkout code uses: actions/checkout@v4 @@ -87,8 +91,6 @@ jobs: AUTH_SECRET: ${{ secrets.TESTS_AUTH_SECRET }} AUTH_TRUST_HOST: ${{ vars.TESTS_AUTH_TRUST_HOST }} BIGCOMMERCE_TRUSTED_PROXY_SECRET: ${{ secrets.BIGCOMMERCE_TRUSTED_PROXY_SECRET }} - TESTS_LOCALE: ${{ vars[matrix.locale-var] }} - TRAILING_SLASH: ${{ matrix.trailing-slash }} DEFAULT_REVALIDATE_TARGET: ${{ matrix.name == 'default' && '1' || '' }} - name: Run E2E tests diff --git a/.github/workflows/native-hosting.yml b/.github/workflows/native-hosting.yml new file mode 100644 index 0000000000..134e7fc902 --- /dev/null +++ b/.github/workflows/native-hosting.yml @@ -0,0 +1,73 @@ +name: Native Hosting + +on: + push: + branches: [canary] + +jobs: + build-and-deploy: + name: Build and Deploy + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }} + 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: Install Catalyst CLI + run: pnpm add @bigcommerce/catalyst@alpha @opennextjs/cloudflare@latest + working-directory: core + + - name: Convert proxy.ts to middleware.ts + working-directory: core + run: | + mv proxy.ts middleware.ts + sed -i 's/export const proxy/export const middleware/' middleware.ts + sed -i "s/export const config = {/export const config = {\n runtime: 'experimental-edge',/" middleware.ts + + - name: Build monorepo packages + run: pnpm --filter "./packages/*" build + + - name: Generate GraphQL types + run: pnpm run generate + working-directory: core + env: + BIGCOMMERCE_STORE_HASH: ${{ vars.NATIVE_HOSTING_BIGCOMMERCE_STORE_HASH }} + BIGCOMMERCE_STOREFRONT_TOKEN: ${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_STOREFRONT_TOKEN }} + BIGCOMMERCE_CHANNEL_ID: ${{ vars.NATIVE_HOSTING_BIGCOMMERCE_CHANNEL_ID }} + + - name: Build + run: pnpm exec catalyst build + working-directory: core + env: + # CLI env vars + CATALYST_PROJECT_UUID: ${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_PROJECT_UUID }} + # App env vars (needed by Next.js build for GraphQL calls in next.config.ts) + BIGCOMMERCE_STORE_HASH: ${{ vars.NATIVE_HOSTING_BIGCOMMERCE_STORE_HASH }} + BIGCOMMERCE_STOREFRONT_TOKEN: ${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_STOREFRONT_TOKEN }} + BIGCOMMERCE_CHANNEL_ID: ${{ vars.NATIVE_HOSTING_BIGCOMMERCE_CHANNEL_ID }} + AUTH_SECRET: ${{ secrets.NATIVE_HOSTING_AUTH_SECRET }} + + - name: Deploy + run: | + pnpm exec catalyst deploy --prebuilt \ + --secret BIGCOMMERCE_STORE_HASH=${{ vars.NATIVE_HOSTING_BIGCOMMERCE_STORE_HASH }} \ + --secret BIGCOMMERCE_STOREFRONT_TOKEN=${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_STOREFRONT_TOKEN }} \ + --secret BIGCOMMERCE_CHANNEL_ID=${{ vars.NATIVE_HOSTING_BIGCOMMERCE_CHANNEL_ID }} \ + --secret AUTH_SECRET=${{ secrets.NATIVE_HOSTING_AUTH_SECRET }} + working-directory: core + env: + CATALYST_STORE_HASH: ${{ vars.NATIVE_HOSTING_BIGCOMMERCE_STORE_HASH }} + CATALYST_ACCESS_TOKEN: ${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_ACCESS_TOKEN }} + CATALYST_PROJECT_UUID: ${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_PROJECT_UUID }} diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/regression-tests.yml index 327d7d5b49..dd4338a274 100644 --- a/.github/workflows/regression-tests.yml +++ b/.github/workflows/regression-tests.yml @@ -7,18 +7,15 @@ on: env: VERCEL_PROTECTION_BYPASS: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} -concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.deployment_status.target_url }} - cancel-in-progress: true - jobs: detect-provider: name: Detect Deployment Provider runs-on: ubuntu-latest outputs: provider: ${{ steps.detect.outputs.provider }} - is-preview: ${{ steps.detect.outputs.is-preview }} production-url: ${{ steps.detect.outputs.production-url }} + branch-label: ${{ steps.detect.outputs.branch-label }} + is-preview: ${{ steps.detect.outputs.is-preview }} steps: - uses: actions/checkout@v4 @@ -28,11 +25,17 @@ jobs: CREATOR="${{ github.event.deployment_status.creator.login }}" ENVIRONMENT="${{ github.event.deployment.environment }}" + if [[ "$ENVIRONMENT" == "Preview" ]]; then + echo "is-preview=true" >> $GITHUB_OUTPUT + else + echo "is-preview=false" >> $GITHUB_OUTPUT + fi + if [[ "$CREATOR" == "vercel[bot]" ]]; then echo "provider=vercel" >> $GITHUB_OUTPUT + PKG_NAME=$(node -p "require('./core/package.json').name") + if [[ "$ENVIRONMENT" == "Preview" ]]; then - echo "is-preview=true" >> $GITHUB_OUTPUT - PKG_NAME=$(node -p "require('./core/package.json').name") case "$PKG_NAME" in "@bigcommerce/catalyst-core") echo "production-url=https://canary.catalyst-demo.site/" >> $GITHUB_OUTPUT ;; @@ -42,27 +45,32 @@ jobs: echo "::warning::No production URL configured for package: $PKG_NAME. Skipping comparison." echo "production-url=" >> $GITHUB_OUTPUT ;; esac + echo "branch-label=" >> $GITHUB_OUTPUT else - echo "is-preview=false" >> $GITHUB_OUTPUT - echo "production-url=" >> $GITHUB_OUTPUT + case "$PKG_NAME" in + "@bigcommerce/catalyst-core") + echo "branch-label=canary" >> $GITHUB_OUTPUT + echo "production-url=${{ github.event.deployment_status.target_url }}" >> $GITHUB_OUTPUT ;; + "@bigcommerce/catalyst-makeswift") + echo "branch-label=integrations/makeswift" >> $GITHUB_OUTPUT + echo "production-url=${{ github.event.deployment_status.target_url }}" >> $GITHUB_OUTPUT ;; + *) + echo "branch-label=" >> $GITHUB_OUTPUT + echo "production-url=" >> $GITHUB_OUTPUT ;; + esac fi elif [[ "$CREATOR" == "cloudflare-pages[bot]" ]]; then echo "provider=cloudflare" >> $GITHUB_OUTPUT - if [[ "$ENVIRONMENT" == "Preview" ]]; then - echo "is-preview=true" >> $GITHUB_OUTPUT - echo "::warning::Cloudflare production URL not yet configured. Skipping comparison." - echo "production-url=" >> $GITHUB_OUTPUT - else - echo "is-preview=false" >> $GITHUB_OUTPUT - echo "production-url=" >> $GITHUB_OUTPUT - fi + echo "::warning::Cloudflare production URL not yet configured. Skipping comparison." + echo "production-url=" >> $GITHUB_OUTPUT + echo "branch-label=" >> $GITHUB_OUTPUT else echo "::warning::Unknown deployment provider: $CREATOR. Skipping audits." echo "provider=unknown" >> $GITHUB_OUTPUT - echo "is-preview=false" >> $GITHUB_OUTPUT echo "production-url=" >> $GITHUB_OUTPUT + echo "branch-label=" >> $GITHUB_OUTPUT fi unlighthouse-audit-preview: @@ -70,12 +78,12 @@ jobs: needs: [detect-provider] if: needs.detect-provider.outputs.is-preview == 'true' runs-on: ubuntu-latest + concurrency: + group: regression-preview-${{ github.event.deployment.ref }}-${{ matrix.device }} + cancel-in-progress: true strategy: matrix: device: [desktop, mobile] - concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.deployment_status.target_url }}-${{ matrix.device }} - cancel-in-progress: true steps: - uses: actions/checkout@v4 @@ -108,14 +116,14 @@ jobs: unlighthouse-audit-production: name: Unlighthouse Audit Production (${{ needs.detect-provider.outputs.provider }}) - ${{ matrix.device }} needs: [detect-provider] - if: needs.detect-provider.outputs.is-preview == 'true' && needs.detect-provider.outputs.production-url != '' + if: needs.detect-provider.outputs.production-url != '' runs-on: ubuntu-latest + concurrency: + group: regression-production-${{ github.event.deployment.environment == 'Preview' && github.event.deployment.ref || github.event.deployment.sha }}-${{ matrix.device }} + cancel-in-progress: ${{ github.event.deployment.environment == 'Preview' }} strategy: matrix: device: [desktop, mobile] - concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-production-${{ matrix.device }} - cancel-in-progress: true steps: - uses: actions/checkout@v4 @@ -176,7 +184,7 @@ jobs: uses: actions/github-script@v7 with: script: | - const postComment = require('./.github/scripts/post-unlighthouse-comment.js') + const postComment = require('./.github/scripts/post-unlighthouse-pr-comment.js') await postComment({ github, context, @@ -184,3 +192,41 @@ jobs: reportPath: '/tmp/unlighthouse-report.md', metaPath: '/tmp/unlighthouse-meta.json', }) + + unlighthouse-report: + name: Unlighthouse Report (${{ needs.detect-provider.outputs.provider }}) — ${{ needs.detect-provider.outputs.branch-label }} + needs: [detect-provider, unlighthouse-audit-production] + if: needs.detect-provider.outputs.is-preview == 'false' && needs.detect-provider.outputs.production-url != '' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + - name: Download production Unlighthouse artifacts + uses: actions/download-artifact@v4 + with: + pattern: unlighthouse-production-*-report + path: /tmp/unlighthouse-artifacts + merge-multiple: false + + - name: Format report + run: | + node .github/scripts/audit-unlighthouse.mts \ + --desktop /tmp/unlighthouse-artifacts/unlighthouse-production-desktop-report/ci-result.json \ + --mobile /tmp/unlighthouse-artifacts/unlighthouse-production-mobile-report/ci-result.json \ + --branch "${{ needs.detect-provider.outputs.branch-label }}" \ + --output /tmp/unlighthouse-report.md + cat /tmp/unlighthouse-report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Post commit comment + uses: actions/github-script@v7 + with: + script: | + const postReport = require('./.github/scripts/post-unlighthouse-commit-comment.js') + await postReport({ github, context, reportPath: '/tmp/unlighthouse-report.md' }) diff --git a/.gitignore b/.gitignore index a0caaaaac9..7f7e851692 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ coverage/ .history .unlighthouse .bigcommerce +.mcp.json diff --git a/README.md b/README.md index b2e1a58d80..0ddd6b06bd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This branch is intended to be used as a template/starting point for new Catalyst - A [BigCommerce account](https://www.bigcommerce.com/start-your-trial) - A BigCommerce store with B2B Edition enabled, and a Catalyst channel created via [One-Click Catalyst](https://developer.bigcommerce.com/docs/storefront/catalyst/getting-started) -- Node.js version 22 +- Node.js version 24 - Corepack-enabled `pnpm` ```bash diff --git a/core/.env.example b/core/.env.example index 1bb482a6e2..509e17e6c7 100644 --- a/core/.env.example +++ b/core/.env.example @@ -9,6 +9,7 @@ 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= +BIGCOMMERCE_STOREFRONT_UNAUTHENTICATED_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 diff --git a/core/README.md b/core/README.md index 6ce5bf2343..10ded1d1c3 100644 --- a/core/README.md +++ b/core/README.md @@ -47,7 +47,7 @@ Check out the [Catalyst.dev One-Click Catalyst Documentation](https://www.cataly **Requirements:** - A [BigCommerce account](https://www.bigcommerce.com/start-your-trial) -- Node.js version 20 or 22 +- Node.js version 24 - Corepack-enabled `pnpm` ```bash diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx index 34fe8b1027..10d78d92ed 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -121,12 +121,14 @@ export default async function Brand(props: Props) { const loadSearchParams = await createBrandSearchParamsLoader(slug, customerAccessToken); const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; + const sort = typeof searchParams.sort === 'string' ? searchParams.sort : 'featured'; const search = await fetchFacetedSearch( { ...searchParams, ...parsedSearchParams, brand: [slug], + sort, }, currencyCode, customerAccessToken, diff --git a/core/app/[locale]/(default)/account/orders/[id]/page.tsx b/core/app/[locale]/(default)/account/orders/[id]/page.tsx index 20e166389f..a6a6a22928 100644 --- a/core/app/[locale]/(default)/account/orders/[id]/page.tsx +++ b/core/app/[locale]/(default)/account/orders/[id]/page.tsx @@ -20,6 +20,7 @@ export default async function OrderDetails(props: Props) { setRequestLocale(locale); const t = await getTranslations('Account.Orders.Details'); + const tGiftCertificate = await getTranslations('Cart.GiftCertificate'); const format = await getFormatter(); const streamableOrder = Streamable.from(async () => { @@ -29,7 +30,7 @@ export default async function OrderDetails(props: Props) { notFound(); } - return orderDetailsTransformer(order, t, format); + return orderDetailsTransformer(order, t, format, tGiftCertificate); }); return ( diff --git a/core/app/[locale]/(default)/account/orders/page.tsx b/core/app/[locale]/(default)/account/orders/page.tsx index 65d4167426..625a752e5b 100644 --- a/core/app/[locale]/(default)/account/orders/page.tsx +++ b/core/app/[locale]/(default)/account/orders/page.tsx @@ -17,6 +17,7 @@ interface Props { async function getOrders(after?: string, before?: string): Promise { const format = await getFormatter(); + const tGiftCertificate = await getTranslations('Cart.GiftCertificate'); const customerOrdersDetails = await getCustomerOrders({ ...(after && { after }), ...(before && { before }), @@ -28,7 +29,7 @@ async function getOrders(after?: string, before?: string): Promise { const { orders } = customerOrdersDetails; - return ordersTransformer(orders, format); + return ordersTransformer(orders, format, tGiftCertificate); } async function getPaginationInfo(after?: string, before?: string) { diff --git a/core/app/[locale]/(default)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts index c6e47636dc..1d88b721e9 100644 --- a/core/app/[locale]/(default)/cart/page-data.ts +++ b/core/app/[locale]/(default)/cart/page-data.ts @@ -28,6 +28,10 @@ export const PhysicalItemFragment = graphql(` currencyCode value } + discountedAmount { + currencyCode + value + } selectedOptions { __typename entityId @@ -87,6 +91,10 @@ export const DigitalItemFragment = graphql(` currencyCode value } + discountedAmount { + currencyCode + value + } selectedOptions { __typename entityId diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index d997b4f43e..9b76c814af 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -112,7 +112,7 @@ export default async function Cart({ params }: Props) { return { typename: item.__typename, id: item.entityId, - title: item.name, + title: t('GiftCertificate.giftCertificate'), subtitle: `${t('GiftCertificate.to')}: ${item.recipient.name} (${item.recipient.email})${item.message ? `, ${t('GiftCertificate.message')}: ${item.message}` : ''}`, quantity: 1, price: format.number(item.amount.value, { @@ -217,6 +217,13 @@ export default async function Cart({ params }: Props) { const totalCouponDiscount = checkout?.coupons.reduce((sum, coupon) => sum + coupon.discountedAmount.value, 0) ?? 0; + const totalLineItemDiscount = [ + ...cart.lineItems.physicalItems, + ...cart.lineItems.digitalItems, + ].reduce((sum, item) => sum + item.discountedAmount.value, 0); + + const totalDiscount = cart.discountedAmount.value + totalLineItemDiscount; + const giftCertificatesSummary = checkout?.giftCertificates.reduce>((acc, c) => { acc.push({ @@ -284,10 +291,10 @@ export default async function Cart({ params }: Props) { currency: cart.currencyCode, }), }, - cart.discountedAmount.value > 0 + totalDiscount > 0 ? { label: t('CheckoutSummary.discounts'), - value: `-${format.number(cart.discountedAmount.value, { + value: `-${format.number(totalDiscount, { style: 'currency', currency: cart.currencyCode, })}`, diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index 33adca4aed..924ca882c9 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -129,6 +129,8 @@ export default async function Compare(props: Props) { previousLabel={t('previous')} products={streamableProducts} ratingLabel={t('rating')} + showLessLabel={t('showLess')} + showMoreLabel={t('showMore')} title={t('title')} viewOptionsLabel={t('viewOptions')} /> diff --git a/core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx b/core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx index 21917a35e8..1a6ccc3c7b 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx @@ -3,6 +3,7 @@ // eslint-disable-next-line import/no-named-as-default import DOMPurify from 'dompurify'; import { useFormatter } from 'next-intl'; +import { useEffect, useState } from 'react'; import { Product as ProductSchemaType, WithContext } from 'schema-dts'; import { FragmentOf } from '~/client/graphql'; @@ -16,6 +17,16 @@ interface Props { export const ProductReviewSchema = ({ reviews, productId }: Props) => { const format = useFormatter(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + // DOMPurify requires a browser DOM, so we skip SSR and only render on the client. + if (!mounted) { + return null; + } const productReviewSchema: WithContext = { '@context': 'https://schema.org', @@ -43,7 +54,9 @@ export const ProductReviewSchema = ({ reviews, productId }: Props) => { return (