diff --git a/.changeset/sync-makeswift-1-5-0.md b/.changeset/sync-makeswift-1-5-0.md new file mode 100644 index 0000000000..92743cebdc --- /dev/null +++ b/.changeset/sync-makeswift-1-5-0.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-b2b-makeswift": minor +--- + +Pulls in changes from the `@bigcommerce/catalyst-makeswift@1.5.0` release. For more information, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob/74b0917ff728ad8d04dc3085c31826976351c81d/core/CHANGELOG.md#150). diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index eaea5b59cd..a2c300d73d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @bigcommerce/team-catalyst +* @bigcommerce/team-trac diff --git a/.github/scripts/__tests__/bundle-size.test.mts b/.github/scripts/__tests__/bundle-size.test.mts new file mode 100644 index 0000000000..2eb7737a05 --- /dev/null +++ b/.github/scripts/__tests__/bundle-size.test.mts @@ -0,0 +1,866 @@ +import { describe, it, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { writeFileSync, mkdirSync, rmSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + round1, + getGzipSize, + parseManifestEntries, + computeRootLayout, + computeRouteMetrics, + compareReport, + clearSizeCache, + readTurbopackEntries, +} from '../bundle-size.mts'; + +// --------------------------------------------------------------------------- +// Shared temp directory with fixture chunk files. +// Initialized at module load time so testDir is set before any test runs. +// Files are large enough (varied content) to produce measurable gzip sizes. +// --------------------------------------------------------------------------- + +const testDir = join(tmpdir(), `bundle-size-test-${Date.now()}`); + +mkdirSync(testDir, { recursive: true }); + +// Each file gets unique, varied content so gzip produces a non-trivial size. +const makeJs = (prefix: string) => + Array.from( + { length: 30 }, + (_, i) => `export const ${prefix}_${i} = ${JSON.stringify(`${prefix}_v${i}_pad${i * 37 + 13}`)};`, + ).join('\n') + '\n'; + +const makeCss = (prefix: string) => + Array.from( + { length: 20 }, + (_, i) => `.${prefix}-class-${i} { color: hsl(${i * 17}, 50%, 50%); margin: ${i}px; }`, + ).join('\n') + '\n'; + +writeFileSync(join(testDir, 'route.js'), makeJs('route')); +writeFileSync(join(testDir, 'shared.js'), makeJs('shared')); +writeFileSync(join(testDir, 'root-layout.js'), makeJs('root_layout')); +writeFileSync(join(testDir, 'product-layout.js'), makeJs('product_layout')); +writeFileSync(join(testDir, 'route.css'), makeCss('route')); + +after(() => { + rmSync(testDir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// round1 +// --------------------------------------------------------------------------- + +describe('round1', () => { + it('rounds up at .05', () => { + assert.equal(round1(1.25), 1.3); + }); + + it('rounds down below .05', () => { + assert.equal(round1(1.24), 1.2); + }); + + it('returns 0 unchanged', () => { + assert.equal(round1(0), 0); + }); + + it('handles negative values', () => { + assert.equal(round1(-1.25), -1.2); + }); +}); + +// --------------------------------------------------------------------------- +// parseManifestEntries +// --------------------------------------------------------------------------- + +describe('parseManifestEntries', () => { + it('routes /layout entries to layouts', () => { + const { layouts, pages } = parseManifestEntries({ + '/app/layout': ['a.js'], + '/app/about/page': ['b.js'], + }); + + assert.deepEqual(Object.keys(layouts), ['/app/layout']); + assert.deepEqual(Object.keys(pages), ['/app/about/page']); + }); + + it('routes /page entries to pages', () => { + const { pages } = parseManifestEntries({ '/app/contact/page': ['c.js'] }); + + assert.deepEqual(Object.keys(pages), ['/app/contact/page']); + }); + + it('ignores entries ending in neither /layout nor /page', () => { + const { layouts, pages } = parseManifestEntries({ + '/app/route': ['d.js'], + '/api/handler': [], + '/app/loading': ['e.js'], + }); + + assert.deepEqual(Object.keys(layouts), []); + assert.deepEqual(Object.keys(pages), []); + }); + + it('returns empty objects for empty input', () => { + const { layouts, pages } = parseManifestEntries({}); + + assert.deepEqual(layouts, {}); + assert.deepEqual(pages, {}); + }); + + it('handles multiple layouts and pages together', () => { + const { layouts, pages } = parseManifestEntries({ + '/app/layout': ['a.js'], + '/app/products/layout': ['b.js'], + '/app/page': ['c.js'], + '/app/products/page': ['d.js'], + }); + + assert.deepEqual(Object.keys(layouts).sort(), ['/app/layout', '/app/products/layout']); + assert.deepEqual(Object.keys(pages).sort(), ['/app/page', '/app/products/page']); + }); +}); + +// --------------------------------------------------------------------------- +// computeRootLayout +// --------------------------------------------------------------------------- + +describe('computeRootLayout', () => { + beforeEach(() => clearSizeCache()); + + it('selects shortest path as root when multiple layouts exist', () => { + const layouts = { + '/[locale]/products/layout': [], + '/[locale]/layout': [], + '/[locale]/about/deep/layout': [], + }; + const { rootLayoutPath } = computeRootLayout( + Object.keys(layouts), + layouts, + new Set(), + testDir, + ); + + assert.equal(rootLayoutPath, '/[locale]/layout'); + }); + + it('returns null rootLayoutPath when layoutPaths is empty', () => { + const { rootLayoutPath, rootLayoutChunks, rootLayoutJs, rootLayoutCss } = computeRootLayout( + [], + {}, + new Set(), + testDir, + ); + + assert.equal(rootLayoutPath, null); + assert.equal(rootLayoutChunks.size, 0); + assert.equal(rootLayoutJs, 0); + assert.equal(rootLayoutCss, 0); + }); + + it('excludes sharedChunks from rootLayoutChunks', () => { + const layouts = { '/layout': ['shared.js', 'root-layout.js'] }; + const sharedChunks = new Set(['shared.js']); + const { rootLayoutChunks } = computeRootLayout( + ['/layout'], + layouts, + sharedChunks, + testDir, + ); + + assert.ok(!rootLayoutChunks.has('shared.js'), 'shared.js should be excluded'); + assert.ok(rootLayoutChunks.has('root-layout.js'), 'root-layout.js should be included'); + }); + + it('rootLayoutChunks contains all non-shared layout chunks', () => { + const layouts = { '/layout': ['root-layout.js', 'route.js'] }; + const { rootLayoutChunks } = computeRootLayout( + ['/layout'], + layouts, + new Set(), + testDir, + ); + + assert.ok(rootLayoutChunks.has('root-layout.js')); + assert.ok(rootLayoutChunks.has('route.js')); + assert.equal(rootLayoutChunks.size, 2); + }); + + it('computes non-zero sizes when real files exist', () => { + const layouts = { '/layout': ['root-layout.js'] }; + const { rootLayoutJs } = computeRootLayout( + ['/layout'], + layouts, + new Set(), + testDir, + ); + + assert.ok(rootLayoutJs > 0, `Expected rootLayoutJs > 0, got ${rootLayoutJs}`); + }); +}); + +// --------------------------------------------------------------------------- +// computeRouteMetrics +// --------------------------------------------------------------------------- + +describe('computeRouteMetrics', () => { + beforeEach(() => clearSizeCache()); + + it('firstLoadJs equals firstLoadJs arg when all chunks are non-existent', () => { + const pages = { '/app/page': [] }; + const routes = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(), + 100, + testDir, + ); + + assert.equal(routes['/app/page'].firstLoadJs, 100); + }); + + it('firstLoadJs is greater than firstLoadJs arg when real chunk files exist', () => { + const pages = { '/app/page': ['route.js', 'route.css'] }; + const routes = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(), + 0, + testDir, + ); + + const { js, css, firstLoadJs } = routes['/app/page']; + + assert.ok(js > 0, `js should be > 0 (real file exists), got ${js}`); + assert.ok(css > 0, `css should be > 0 (real file exists), got ${css}`); + assert.ok(firstLoadJs > 0, `firstLoadJs should be > 0, got ${firstLoadJs}`); + }); + + it('excludes sharedChunks from route chunk set', () => { + const pages = { '/app/page': ['shared.js', 'route.js'] }; + + // With both chunks in sharedChunks, routeChunks is empty -> js = 0 + const routesAllExcluded = computeRouteMetrics( + pages, + {}, + new Set(['shared.js', 'route.js']), + null, + new Set(), + 0, + testDir, + ); + + assert.equal(routesAllExcluded['/app/page'].js, 0, 'All shared chunks excluded -> js = 0'); + + clearSizeCache(); + + // With no exclusions, real files contribute -> js > 0 + const routesNoneExcluded = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(), + 0, + testDir, + ); + + assert.ok(routesNoneExcluded['/app/page'].js > 0, 'No exclusions -> js > 0'); + }); + + it('excludes rootLayoutChunks from route chunk set', () => { + const pages = { '/app/page': ['root-layout.js', 'route.js'] }; + + // With both chunks in rootLayoutChunks, routeChunks is empty -> js = 0 + const routesAllExcluded = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(['root-layout.js', 'route.js']), + 0, + testDir, + ); + + assert.equal(routesAllExcluded['/app/page'].js, 0, 'All rootLayout chunks excluded -> js = 0'); + + clearSizeCache(); + + // With no rootLayoutChunks excluded, real files contribute -> js > 0 + const routesNoneExcluded = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(), + 0, + testDir, + ); + + assert.ok(routesNoneExcluded['/app/page'].js > 0, 'No exclusions -> js > 0'); + }); + + it('includes non-root ancestor layout chunks in route size', () => { + // Page has no own chunks; non-root ancestor layout contributes product-layout.js + const pages = { '/[locale]/products/page': [] }; + const layouts = { + '/[locale]/layout': ['root-layout.js'], + '/[locale]/products/layout': ['product-layout.js'], + }; + const rootLayoutChunks = new Set(['root-layout.js']); + + const routes = computeRouteMetrics( + pages, + layouts, + new Set(), + '/[locale]/layout', + rootLayoutChunks, + 0, + testDir, + ); + + assert.ok( + routes['/[locale]/products/page'].js > 0, + 'Non-root ancestor layout chunk should contribute to route js', + ); + }); + + it('does not include root ancestor layout chunks in route size', () => { + // Page has no own chunks; root layout has root-layout.js (should be excluded) + const pages = { '/[locale]/page': [] }; + const layouts = { + '/[locale]/layout': ['root-layout.js'], + }; + const rootLayoutChunks = new Set(['root-layout.js']); + + const routes = computeRouteMetrics( + pages, + layouts, + new Set(), + '/[locale]/layout', + rootLayoutChunks, + 0, + testDir, + ); + + assert.equal( + routes['/[locale]/page'].js, + 0, + 'Root ancestor layout chunks should NOT contribute to route js', + ); + }); + + it('applies round1 to all output values', () => { + const pages = { '/app/page': [] }; + const routes = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(), + 1.25, + testDir, + ); + + // firstLoadJs = round1(1.25 + 0 + 0) = 1.3 + assert.equal(routes['/app/page'].firstLoadJs, 1.3); + assert.equal(routes['/app/page'].js, 0); + assert.equal(routes['/app/page'].css, 0); + }); +}); + +// --------------------------------------------------------------------------- +// compareReport +// The warning sign in the report output is U+26A0 U+FE0F (warning emoji). +// Warning table rows end with "| warning-emoji |" while the footer contains +// the same emoji in a sentence. Use "warning-emoji |" to match only table cells. +// --------------------------------------------------------------------------- + +const WARN_EMOJI = '\u26a0\ufe0f'; // ⚠️ +const WARN_IN_ROW = `${WARN_EMOJI} |`; // appears only in warning table cells + +describe('compareReport', () => { + function makeReport(overrides = {}) { + return { + commitSha: 'abc123', + updatedAt: '2024-01-01', + firstLoadJs: 100, + totalJs: 200, + totalCss: 10, + routes: {}, + ...overrides, + }; + } + + it('shows "No bundle size changes detected." when nothing changed', () => { + const baseline = makeReport(); + const current = makeReport(); + const report = compareReport(baseline, current); + + assert.ok(report.includes('No bundle size changes detected.')); + assert.ok(!report.includes('_No route changes detected._')); + assert.ok(!report.includes('### Per-Route First Load JS')); + }); + + it('shows "No route changes detected." when only global metrics changed', () => { + // Global metric differs (Case 2) but routes are identical → section shown, no threshold + const baseline = makeReport({ firstLoadJs: 100, routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ firstLoadJs: 110, routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('_No route changes detected._')); + assert.ok(!report.includes(`Threshold:`)); + }); + + it('does not show global metrics table when global metrics are unchanged', () => { + const baseline = makeReport(); + const current = makeReport(); + const report = compareReport(baseline, current); + + assert.ok(!report.includes('| Metric |')); + }); + + it('shows global metrics table only when metrics changed', () => { + const baseline = makeReport({ firstLoadJs: 100 }); + const current = makeReport({ firstLoadJs: 115 }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('| Metric |')); + assert.ok(report.includes('First Load JS')); + }); + + it('shows only the changed global metrics', () => { + const baseline = makeReport({ firstLoadJs: 100, totalJs: 200, totalCss: 10 }); + const current = makeReport({ firstLoadJs: 100, totalJs: 210, totalCss: 10 }); + const report = compareReport(baseline, current); + + // Use pipe-delimited patterns to match table rows only (not the section header) + assert.ok(report.includes('| Total JS |')); + assert.ok(!report.includes('| First Load JS |')); + assert.ok(!report.includes('| Total CSS |')); + }); + + it('shows NEW row for added route', () => { + const baseline = makeReport({ routes: {} }); + const current = makeReport({ + routes: { '/app/new/page': { firstLoadJs: 120, js: 60, css: 5 } }, + }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('NEW')); + assert.ok(report.includes('120 kB')); + }); + + it('shows REMOVED row for deleted route', () => { + const baseline = makeReport({ + routes: { '/app/old/page': { firstLoadJs: 120, js: 60, css: 5 } }, + }); + const current = makeReport({ routes: {} }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('REMOVED')); + assert.ok(report.includes('120 kB')); + }); + + it('does not show warning for increase under threshold', () => { + // delta=3kB, pct=3% < 5% threshold: no warning row + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 103, js: 53, css: 5 } } }); + const report = compareReport(baseline, current); + + assert.ok(!report.includes(WARN_IN_ROW), 'Should not have a warning table cell'); + }); + + it('shows warning for increase over threshold (over 1kB AND over threshold percent)', () => { + // delta=10kB, pct=10% > 5% threshold: warning row present + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 110, js: 60, css: 5 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes(WARN_IN_ROW), 'Should have a warning table cell'); + }); + + it('does not warn when delta is over threshold percent but 1kB or less', () => { + // delta=0.5kB = 50% but <=1kB: no warning + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 1, js: 1, css: 0 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 1.5, js: 1.5, css: 0 } } }); + const report = compareReport(baseline, current); + + assert.ok(!report.includes(WARN_IN_ROW)); + }); + + it('does not warn when delta is over 1kB but at or under threshold percent', () => { + // delta=2kB = 1% < 5% threshold: no warning + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 200, js: 200, css: 0 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 202, js: 202, css: 0 } } }); + const report = compareReport(baseline, current); + + assert.ok(!report.includes(WARN_IN_ROW)); + }); + + it('respects custom threshold: no warning when under', () => { + // delta=8kB = 8%, threshold=10: no warning + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 108, js: 58, css: 5 } } }); + const report = compareReport(baseline, current, { threshold: 10 }); + + assert.ok(!report.includes(WARN_IN_ROW)); + }); + + it('respects custom threshold: warning when over', () => { + // delta=8kB = 8%, threshold=3: warning + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 108, js: 58, css: 5 } } }); + const report = compareReport(baseline, current, { threshold: 3 }); + + assert.ok(report.includes(WARN_IN_ROW)); + }); + + it('uses default threshold of 5 percent when not specified', () => { + // delta=6kB = 6% > 5%: warning with default + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 100, css: 0 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 106, js: 106, css: 0 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes(WARN_IN_ROW)); + assert.ok(report.includes('Threshold: 5%')); + }); + + it('shows threshold in footer only when route changes are present', () => { + // Route changed: threshold callout shown + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 110, js: 60, css: 5 } } }); + const report = compareReport(baseline, current, { threshold: 7 }); + + assert.ok(report.includes('Threshold: 7%')); + }); + + it('omits threshold footer when there are no route changes', () => { + // Global metrics differ but routes are identical — no threshold callout + const baseline = makeReport({ firstLoadJs: 100 }); + const current = makeReport({ firstLoadJs: 115 }); + const report = compareReport(baseline, current); + + assert.ok(!report.includes('Threshold:')); + }); + + it('formats positive delta with + sign and percent', () => { + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 110, js: 60, css: 5 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('+10 kB')); + assert.ok(report.includes('+10%')); + }); + + it('formats negative delta with minus sign and percent', () => { + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 90, js: 40, css: 5 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('-10 kB')); + assert.ok(report.includes('-10%')); + }); + + it('sorts routes alphabetically', () => { + const makeRoute = (v: number) => ({ firstLoadJs: v, js: v, css: 0 }); + const baseline = makeReport({ + routes: { + '/z/page': makeRoute(100), + '/a/page': makeRoute(100), + '/m/page': makeRoute(100), + }, + }); + const current = makeReport({ + routes: { + '/z/page': makeRoute(110), + '/a/page': makeRoute(110), + '/m/page': makeRoute(110), + }, + }); + const report = compareReport(baseline, current); + + const aIdx = report.indexOf('/a/page'); + const mIdx = report.indexOf('/m/page'); + const zIdx = report.indexOf('/z/page'); + + assert.ok(aIdx < mIdx, '/a should appear before /m'); + assert.ok(mIdx < zIdx, '/m should appear before /z'); + }); + + it('strips the /[locale] prefix from display names', () => { + const baseline = makeReport({ routes: {} }); + const current = makeReport({ + routes: { '/[locale]/products/page': { firstLoadJs: 120, js: 60, css: 5 } }, + }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('/products/page'), 'Should show /products/page (locale stripped)'); + assert.ok( + !report.includes('/[locale]/products/page'), + 'Should not show /[locale] prefix', + ); + }); + + it('omits near-zero deltas that round to 0.0', () => { + // 0.04kB delta rounds to 0.0: treated as no change + const baseline = makeReport({ + routes: { '/app/page': { firstLoadJs: 100.04, js: 50, css: 5 } }, + }); + const current = makeReport({ + routes: { '/app/page': { firstLoadJs: 100.04, js: 50, css: 5 } }, + }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('No bundle size changes detected.')); + }); + + it('shows Per-Route First Load JS section when there are route changes', () => { + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 0 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 110, js: 60, css: 0 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('### Per-Route First Load JS')); + }); + + it('omits Per-Route First Load JS section when nothing changed', () => { + const baseline = makeReport(); + const current = makeReport(); + const report = compareReport(baseline, current); + + assert.ok(!report.includes('### Per-Route First Load JS')); + }); + + it('shows header with baseline commitSha and updatedAt', () => { + const baseline = makeReport({ commitSha: 'deadbeef', updatedAt: '2024-06-15' }); + const current = makeReport(); + const report = compareReport(baseline, current); + + assert.ok(report.includes('`deadbeef`')); + assert.ok(report.includes('2024-06-15')); + }); + + it('shows "No bundle size changes detected." for empty routes in both reports', () => { + const baseline = makeReport({ routes: {} }); + const current = makeReport({ routes: {} }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('No bundle size changes detected.')); + }); + + it('shows table header when routes have changes', () => { + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 0 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 110, js: 60, css: 0 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('| Route |')); + assert.ok(report.includes('| Baseline |')); + assert.ok(report.includes('| Current |')); + }); +}); + +// --------------------------------------------------------------------------- +// readTurbopackEntries +// --------------------------------------------------------------------------- + +describe('readTurbopackEntries', () => { + // Helper: create a minimal _client-reference-manifest.js fixture + function makeManifestContent( + routes: Record>, + ): string { + const manifest: Record }> = {}; + + for (const [routeKey, modules] of Object.entries(routes)) { + manifest[routeKey] = { clientModules: modules }; + } + + return `globalThis.__RSC_MANIFEST = ${JSON.stringify(manifest)};`; + } + + it('reads chunk paths from a single manifest and normalizes /_next/ prefix', () => { + const dir = join(testDir, `turbopack-basic-${Date.now()}`); + + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'page_client-reference-manifest.js'), + makeManifestContent({ + '/products/page': { + 'mod-a': { chunks: ['/_next/static/chunks/a.js'] }, + 'mod-b': { chunks: ['/_next/static/chunks/b.js'] }, + }, + }), + ); + + const entries = readTurbopackEntries(dir); + + assert.ok(entries['/products/page'], 'should have /products/page entry'); + assert.ok(entries['/products/page'].includes('static/chunks/a.js'), 'should normalize /_next/ prefix'); + assert.ok(entries['/products/page'].includes('static/chunks/b.js')); + assert.ok(!entries['/products/page'].some((c) => c.startsWith('/_next/')), 'no chunk should start with /_next/'); + }); + + it('filters out non-/page routes (layouts, route handlers)', () => { + const dir = join(testDir, `turbopack-filter-${Date.now()}`); + + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'page_client-reference-manifest.js'), + makeManifestContent({ + '/app/layout': { 'mod-a': { chunks: ['/_next/static/chunks/layout.js'] } }, + '/app/route': { 'mod-b': { chunks: ['/_next/static/chunks/route.js'] } }, + '/app/page': { 'mod-c': { chunks: ['/_next/static/chunks/page.js'] } }, + }), + ); + + const entries = readTurbopackEntries(dir); + + assert.ok(entries['/app/page'], 'should include /page route'); + assert.ok(!entries['/app/layout'], 'should exclude /layout route'); + assert.ok(!entries['/app/route'], 'should exclude /route handler'); + }); + + it('deduplicates chunks appearing in multiple modules', () => { + const dir = join(testDir, `turbopack-dedup-${Date.now()}`); + + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'page_client-reference-manifest.js'), + makeManifestContent({ + '/shop/page': { + 'mod-a': { chunks: ['/_next/static/chunks/shared.js', '/_next/static/chunks/a.js'] }, + 'mod-b': { chunks: ['/_next/static/chunks/shared.js', '/_next/static/chunks/b.js'] }, + }, + }), + ); + + const entries = readTurbopackEntries(dir); + const chunks = entries['/shop/page']; + + assert.ok(chunks, 'should have /shop/page entry'); + + const sharedCount = chunks.filter((c) => c === 'static/chunks/shared.js').length; + + assert.equal(sharedCount, 1, 'shared chunk should appear exactly once'); + assert.equal(chunks.length, 3, 'should have 3 unique chunks'); + }); + + it('scans subdirectories recursively', () => { + const dir = join(testDir, `turbopack-recursive-${Date.now()}`); + + mkdirSync(join(dir, 'nested', 'deep'), { recursive: true }); + writeFileSync( + join(dir, 'nested', 'deep', 'page_client-reference-manifest.js'), + makeManifestContent({ + '/nested/deep/page': { 'mod-a': { chunks: ['/_next/static/chunks/deep.js'] } }, + }), + ); + + const entries = readTurbopackEntries(dir); + + assert.ok(entries['/nested/deep/page'], 'should find manifest in nested directory'); + }); + + it('skips malformed manifest files gracefully', () => { + const dir = join(testDir, `turbopack-malformed-${Date.now()}`); + + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'bad_client-reference-manifest.js'), 'this is not valid JS {{{'); + writeFileSync( + join(dir, 'good_client-reference-manifest.js'), + makeManifestContent({ + '/valid/page': { 'mod-a': { chunks: ['/_next/static/chunks/valid.js'] } }, + }), + ); + + // Should not throw, and should still return valid entries + assert.doesNotThrow(() => readTurbopackEntries(dir)); + + const entries = readTurbopackEntries(dir); + + assert.ok(entries['/valid/page'], 'should return valid entries even when another file is malformed'); + }); + + it('returns empty object when no manifest files exist', () => { + const dir = join(testDir, `turbopack-empty-${Date.now()}`); + + mkdirSync(dir, { recursive: true }); + + const entries = readTurbopackEntries(dir); + + assert.deepEqual(entries, {}); + }); + + it('returns empty object when manifests have no __RSC_MANIFEST', () => { + const dir = join(testDir, `turbopack-no-rsc-${Date.now()}`); + + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'page_client-reference-manifest.js'), + 'globalThis.somethingElse = {};', + ); + + const entries = readTurbopackEntries(dir); + + assert.deepEqual(entries, {}); + }); +}); + +// --------------------------------------------------------------------------- +// getGzipSize +// --------------------------------------------------------------------------- + +describe('getGzipSize', () => { + beforeEach(() => clearSizeCache()); + + it('returns 0 when file does not exist', () => { + const result = getGzipSize(join(testDir, 'nonexistent-file-xyz.js')); + + assert.equal(result, 0); + }); + + it('returns a positive number for an existing file', () => { + const result = getGzipSize(join(testDir, 'route.js')); + + assert.ok(result > 0, `Expected positive size, got ${result}`); + }); + + it('caches results and returns same value on second call', () => { + const filePath = join(testDir, `cache-test-${Date.now()}.js`); + + writeFileSync(filePath, makeJs('cached')); + + const firstResult = getGzipSize(filePath); + + assert.ok(firstResult > 0); + + // Delete the file — the cached value should still be returned + unlinkSync(filePath); + + const secondResult = getGzipSize(filePath); + + assert.equal(secondResult, firstResult, 'Should return cached value after file deletion'); + }); + + it('clearSizeCache resets the cache', () => { + const filePath = join(testDir, `clear-test-${Date.now()}.js`); + + writeFileSync(filePath, makeJs('cleared')); + + const sizeBeforeDelete = getGzipSize(filePath); + + assert.ok(sizeBeforeDelete > 0); + + unlinkSync(filePath); + clearSizeCache(); + + // After clearing cache, file is gone so size should be 0 + const sizeAfterClear = getGzipSize(filePath); + + assert.equal(sizeAfterClear, 0, 'Should return 0 after cache cleared and file deleted'); + }); +}); diff --git a/.github/scripts/__tests__/compare-unlighthouse.test.mts b/.github/scripts/__tests__/compare-unlighthouse.test.mts new file mode 100644 index 0000000000..f09cd68db6 --- /dev/null +++ b/.github/scripts/__tests__/compare-unlighthouse.test.mts @@ -0,0 +1,285 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { compareResults } from '../compare-unlighthouse.mts'; +import type { CiResult } from '../compare-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(); + +// --------------------------------------------------------------------------- +// hasChanges +// --------------------------------------------------------------------------- + +describe('hasChanges', () => { + it('is false when all four results are identical', () => { + const { hasChanges } = compareResults(BASE, BASE, BASE, BASE, 1); + + assert.equal(hasChanges, false); + }); + + it('is true when preview desktop summary score differs by exactly 1pp', () => { + const preview = makeCiResult({ score: 0.84 }); // 1pp below + const { hasChanges } = compareResults(BASE, BASE, preview, BASE, 1); + + assert.equal(hasChanges, true); + }); + + it('is false when summary score differs by less than 1pp', () => { + const preview = makeCiResult({ score: 0.855 }); // 0.5pp above + const { hasChanges } = compareResults(BASE, BASE, preview, BASE, 1); + + assert.equal(hasChanges, false); + }); + + it('is true when preview mobile summary score differs by >= 1pp', () => { + const previewMobile = makeCiResult({ score: 0.74 }); + const { hasChanges } = compareResults(BASE, BASE, BASE, previewMobile, 1); + + assert.equal(hasChanges, true); + }); + + it('is true when a category score differs by >= 1pp', () => { + const preview = makeCiResult({ performance: 0.79 }); // 1pp below 0.80 + const { hasChanges } = compareResults(BASE, BASE, preview, BASE, 1); + + assert.equal(hasChanges, true); + }); + + it('is false when category score differs by less than 1pp', () => { + const preview = makeCiResult({ performance: 0.805 }); // 0.5pp above + const { hasChanges } = compareResults(BASE, BASE, preview, BASE, 1); + + assert.equal(hasChanges, false); + }); + + it('respects a custom threshold', () => { + // 2pp delta — true at threshold=1, false at threshold=3 + const preview = makeCiResult({ score: 0.83 }); + + assert.equal(compareResults(BASE, BASE, preview, BASE, 1).hasChanges, true); + assert.equal(compareResults(BASE, BASE, preview, BASE, 3).hasChanges, false); + }); +}); + +// --------------------------------------------------------------------------- +// Report heading +// --------------------------------------------------------------------------- + +describe('report heading', () => { + it('contains the comparison heading', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1); + + assert.ok( + markdown.includes('## Unlighthouse Performance Comparison'), + 'Missing main heading', + ); + }); + + it('appends provider label when provider is given', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1, 'vercel'); + + assert.ok( + markdown.includes('## Unlighthouse Performance Comparison — Vercel'), + 'Missing provider label in heading', + ); + }); + + it('capitalises the provider label', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1, 'cloudflare'); + + assert.ok(markdown.includes('— Cloudflare'), 'Provider should be capitalised'); + }); + + it('omits provider label when none provided', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1); + + assert.ok( + !markdown.includes(' — '), + 'Should not contain a provider label separator', + ); + }); + + it('contains the description text', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1); + + assert.ok( + markdown.includes( + 'Comparing PR preview deployment Unlighthouse scores vs production Unlighthouse scores.', + ), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Summary Score section +// --------------------------------------------------------------------------- + +describe('Summary Score section', () => { + it('contains the Summary Score heading', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1); + + assert.ok(markdown.includes('### Summary Score')); + }); + + it('contains the aggregate score note', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1); + + assert.ok( + markdown.includes( + 'Aggregate score across all categories as reported by Unlighthouse.', + ), + ); + }); + + it('renders scores as integers on a 1-100 scale', () => { + const prod = makeCiResult({ score: 0.85 }); + const prev = makeCiResult({ score: 0.72 }); + const { markdown } = compareResults(prod, prod, prev, prev, 1); + + assert.ok(markdown.includes('| Score | 85 | 85 | 72 | 72 |')); + }); + + it('rounds fractional scores correctly', () => { + const prod = makeCiResult({ score: 0.856 }); // rounds to 86 + const { markdown } = compareResults(prod, BASE, prod, BASE, 1); + + assert.ok(markdown.includes('86'), 'Score 0.856 should round to 86'); + }); + + it('contains the four-column header', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1); + + assert.ok( + markdown.includes('| | Prod Desktop | Prod Mobile | Preview Desktop | Preview Mobile |'), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Category Scores section +// --------------------------------------------------------------------------- + +describe('Category Scores section', () => { + it('contains the Category Scores heading', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1); + + assert.ok(markdown.includes('### Category Scores')); + }); + + it('renders all four categories', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1); + + assert.ok(markdown.includes('Performance')); + assert.ok(markdown.includes('Accessibility')); + assert.ok(markdown.includes('Best Practices')); + assert.ok(markdown.includes('SEO')); + }); + + it('renders category scores as integers on a 1-100 scale', () => { + const prod = makeCiResult({ performance: 0.80 }); + const prev = makeCiResult({ performance: 0.93 }); + const { markdown } = compareResults(prod, prod, prev, prev, 1); + + assert.ok( + markdown.includes('| Performance | 80 | 80 | 93 | 93 |'), + 'Performance row should contain all four scores as integers', + ); + }); + + it('shows all four column values independently', () => { + const prodDesktop = makeCiResult({ seo: 0.88 }); + const prodMobile = makeCiResult({ seo: 0.75 }); + const prevDesktop = makeCiResult({ seo: 0.91 }); + const prevMobile = makeCiResult({ seo: 0.82 }); + const { markdown } = compareResults(prodDesktop, prodMobile, prevDesktop, prevMobile, 1); + + assert.ok(markdown.includes('| SEO | 88 | 75 | 91 | 82 |')); + }); +}); + +// --------------------------------------------------------------------------- +// Core Web Vitals section +// --------------------------------------------------------------------------- + +describe('Core Web Vitals section', () => { + it('contains the Core Web Vitals heading', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1); + + assert.ok(markdown.includes('### Core Web Vitals')); + }); + + it('renders all six metrics', () => { + const { markdown } = compareResults(BASE, BASE, BASE, BASE, 1); + + 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 ci = makeCiResult({ + metrics: { + ...DEFAULT_METRICS, + 'largest-contentful-paint': { displayValue: '4.8 s' }, + }, + }); + const { markdown } = compareResults(ci, ci, ci, ci, 1); + + assert.ok(markdown.includes('4.8 s'), 'displayValue should appear as-is'); + }); + + it('shows — for a metric missing from a result', () => { + const ciMissingMetric = makeCiResult({ metrics: {} }); + const { markdown } = compareResults(BASE, ciMissingMetric, BASE, BASE, 1); + + assert.ok(markdown.includes('—'), 'Missing metric should show —'); + }); + + it('shows four displayValues per metric row', () => { + const prodDesktop = makeCiResult({ metrics: { ...DEFAULT_METRICS, 'total-blocking-time': { displayValue: '80 ms' } } }); + const prodMobile = makeCiResult({ metrics: { ...DEFAULT_METRICS, 'total-blocking-time': { displayValue: '320 ms' } } }); + const prevDesktop = makeCiResult({ metrics: { ...DEFAULT_METRICS, 'total-blocking-time': { displayValue: '75 ms' } } }); + const prevMobile = makeCiResult({ metrics: { ...DEFAULT_METRICS, 'total-blocking-time': { displayValue: '310 ms' } } }); + const { markdown } = compareResults(prodDesktop, prodMobile, prevDesktop, prevMobile, 1); + + assert.ok(markdown.includes('| TBT | 80 ms | 320 ms | 75 ms | 310 ms |')); + }); +}); diff --git a/.github/scripts/__tests__/post-bundle-comment.test.mts b/.github/scripts/__tests__/post-bundle-comment.test.mts new file mode 100644 index 0000000000..8a11c4eb9e --- /dev/null +++ b/.github/scripts/__tests__/post-bundle-comment.test.mts @@ -0,0 +1,189 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { writeFileSync, mkdirSync, rmSync } 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 postBundleComment = require('../post-bundle-comment.js') as (args: { + github: ReturnType['github']; + context: ReturnType; + reportPath?: string; +}) => Promise; + +const marker = ''; + +let tmpDir: string; +let reportPath: string; + +beforeEach(() => { + tmpDir = join(tmpdir(), `post-bundle-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + reportPath = join(tmpDir, 'report.md'); + writeFileSync(reportPath, '## Bundle Size Report\n\nSome content here.'); +}); + +interface Comment { + id: number; + body: string; +} + +interface GithubCalls { + create: object[]; + update: object[]; + list: object[]; +} + +// Helper to create a mock github object and record calls +function makeGithub(existingComments: Comment[] = []) { + const calls: GithubCalls = { create: [], update: [], list: [] }; + const github = { + rest: { + issues: { + listComments: async (args: object) => { + calls.list.push(args); + return { data: existingComments }; + }, + createComment: async (args: object) => { + calls.create.push(args); + }, + updateComment: async (args: object) => { + calls.update.push(args); + }, + }, + }, + }; + return { github, calls }; +} + +function makeContext({ owner = 'test-owner', repo = 'test-repo', number = 42 } = {}) { + return { + repo: { owner, repo }, + issue: { number }, + }; +} + +describe('post-bundle-comment', () => { + it('creates a new comment when no existing comment contains the marker', async () => { + const { github, calls } = makeGithub([]); + await postBundleComment({ github, context: makeContext(), reportPath }); + + assert.equal(calls.create.length, 1, 'Should create exactly one comment'); + assert.equal(calls.update.length, 0, 'Should not update any comment'); + }); + + it('updates existing comment when marker found', async () => { + const existing = { id: 99, body: `${marker}\nOld content` }; + const { github, calls } = makeGithub([existing]); + await postBundleComment({ github, context: makeContext(), reportPath }); + + assert.equal(calls.update.length, 1, 'Should update exactly one comment'); + assert.equal(calls.create.length, 0, 'Should not create a new comment'); + assert.equal((calls.update[0] as { comment_id: number }).comment_id, 99, 'Should update the correct comment by id'); + }); + + it('body always starts with marker and newline', async () => { + const { github, calls } = makeGithub([]); + await postBundleComment({ github, context: makeContext(), reportPath }); + + const body = (calls.create[0] as { body: string }).body; + + assert.ok(body.startsWith(`${marker}\n`), `Body should start with marker, got: ${body.slice(0, 50)}`); + }); + + it('updated comment body also starts with marker and newline', async () => { + const existing = { id: 7, body: `${marker}\nStale content` }; + const { github, calls } = makeGithub([existing]); + await postBundleComment({ github, context: makeContext(), reportPath }); + + const body = (calls.update[0] as { body: string }).body; + + assert.ok(body.startsWith(`${marker}\n`)); + }); + + it('includes report file content in the comment body', async () => { + const { github, calls } = makeGithub([]); + await postBundleComment({ github, context: makeContext(), reportPath }); + + const body = (calls.create[0] as { body: string }).body; + + assert.ok(body.includes('## Bundle Size Report'), 'Should include report heading'); + assert.ok(body.includes('Some content here.'), 'Should include report body content'); + }); + + it('reads report from a custom reportPath', async () => { + const customPath = join(tmpDir, 'custom.md'); + + writeFileSync(customPath, 'Custom report content for testing!'); + + const { github, calls } = makeGithub([]); + await postBundleComment({ github, context: makeContext(), reportPath: customPath }); + + assert.ok((calls.create[0] as { body: string }).body.includes('Custom report content for testing!')); + }); + + it('passes correct owner, repo, issue_number from context to listComments', async () => { + const { github, calls } = makeGithub([]); + await postBundleComment({ + github, + context: makeContext({ owner: 'my-org', repo: 'my-repo', number: 123 }), + reportPath, + }); + + assert.equal((calls.list[0] as { owner: string }).owner, 'my-org'); + assert.equal((calls.list[0] as { repo: string }).repo, 'my-repo'); + assert.equal((calls.list[0] as { issue_number: number }).issue_number, 123); + }); + + it('passes correct owner, repo, issue_number from context to createComment', async () => { + const { github, calls } = makeGithub([]); + await postBundleComment({ + github, + context: makeContext({ owner: 'my-org', repo: 'my-repo', number: 123 }), + reportPath, + }); + + assert.equal((calls.create[0] as { owner: string }).owner, 'my-org'); + assert.equal((calls.create[0] as { repo: string }).repo, 'my-repo'); + assert.equal((calls.create[0] as { issue_number: number }).issue_number, 123); + }); + + it('passes correct owner and repo to updateComment', async () => { + const existing = { id: 55, body: `${marker}\nOld` }; + const { github, calls } = makeGithub([existing]); + await postBundleComment({ + github, + context: makeContext({ owner: 'org2', repo: 'repo2', number: 7 }), + reportPath, + }); + + assert.equal((calls.update[0] as { owner: string }).owner, 'org2'); + assert.equal((calls.update[0] as { repo: string }).repo, 'repo2'); + }); + + it('uses the first comment that contains the marker (not just exact match)', async () => { + const comments = [ + { id: 1, body: 'Just a regular comment' }, + { id: 2, body: `${marker}\nFirst bundle report` }, + { id: 3, body: `${marker}\nSecond bundle report` }, + ]; + const { github, calls } = makeGithub(comments); + await postBundleComment({ github, context: makeContext(), reportPath }); + + assert.equal(calls.update.length, 1); + assert.equal((calls.update[0] as { comment_id: number }).comment_id, 2, 'Should update the first matching comment'); + }); + + it('creates comment when existing comments do not contain the marker', async () => { + const comments = [ + { id: 10, body: 'No marker here' }, + { id: 11, body: 'Also no marker' }, + ]; + const { github, calls } = makeGithub(comments); + await postBundleComment({ github, context: makeContext(), reportPath }); + + assert.equal(calls.create.length, 1); + assert.equal(calls.update.length, 0); + }); +}); diff --git a/.github/scripts/__tests__/post-unlighthouse-comment.test.mts b/.github/scripts/__tests__/post-unlighthouse-comment.test.mts new file mode 100644 index 0000000000..e4b54db698 --- /dev/null +++ b/.github/scripts/__tests__/post-unlighthouse-comment.test.mts @@ -0,0 +1,273 @@ +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 postComment = require('../post-unlighthouse-comment.js') as (args: { + github: ReturnType['github']; + context: ReturnType; + provider?: string; + reportPath?: string; + metaPath?: string; +}) => Promise; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface Comment { + id: number; + body: string; +} + +interface Calls { + listPrs: object[]; + listComments: object[]; + create: object[]; + update: object[]; +} + +function makeGithub(opts: { prs?: { number: number }[]; comments?: Comment[] } = {}) { + const calls: Calls = { listPrs: [], listComments: [], create: [], update: [] }; + const github = { + rest: { + repos: { + listPullRequestsAssociatedWithCommit: async (args: object) => { + calls.listPrs.push(args); + return { data: opts.prs ?? [{ number: 42 }] }; + }, + }, + issues: { + listComments: async (args: object) => { + calls.listComments.push(args); + return { data: opts.comments ?? [] }; + }, + createComment: async (args: object) => { + calls.create.push(args); + }, + updateComment: async (args: object) => { + calls.update.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 tmpDir: string; +let reportPath: string; +let metaPath: string; + +beforeEach(() => { + tmpDir = join(tmpdir(), `post-unlighthouse-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + reportPath = join(tmpDir, 'report.md'); + metaPath = join(tmpDir, 'meta.json'); + writeFileSync(reportPath, '## Unlighthouse Performance Comparison\n\nSome results.'); + writeFileSync(metaPath, JSON.stringify({ hasChanges: true })); +}); + +// --------------------------------------------------------------------------- +// Early exits +// --------------------------------------------------------------------------- + +describe('early exits', () => { + it('does nothing when hasChanges is false', async () => { + writeFileSync(metaPath, JSON.stringify({ hasChanges: false })); + const { github, calls } = makeGithub(); + await postComment({ github, context: makeContext(), reportPath, metaPath }); + + assert.equal(calls.create.length, 0); + assert.equal(calls.update.length, 0); + }); + + it('does nothing when deployment sha is missing', async () => { + const { github, calls } = makeGithub(); + const context = { ...makeContext(), payload: {} }; + await postComment({ github, context, reportPath, metaPath }); + + assert.equal(calls.create.length, 0); + assert.equal(calls.update.length, 0); + }); + + it('does nothing when no PR is associated with the sha', async () => { + const { github, calls } = makeGithub({ prs: [] }); + await postComment({ github, context: makeContext(), reportPath, metaPath }); + + assert.equal(calls.create.length, 0); + assert.equal(calls.update.length, 0); + }); +}); + +// --------------------------------------------------------------------------- +// Comment creation +// --------------------------------------------------------------------------- + +describe('comment creation', () => { + it('creates a comment when no existing comment has the marker', async () => { + const { github, calls } = makeGithub({ comments: [] }); + await postComment({ github, context: makeContext(), reportPath, metaPath }); + + assert.equal(calls.create.length, 1); + assert.equal(calls.update.length, 0); + }); + + it('uses the PR number found from the commit sha', async () => { + const { github, calls } = makeGithub({ prs: [{ number: 77 }] }); + await postComment({ github, context: makeContext(), reportPath, metaPath }); + + assert.equal((calls.create[0] as { issue_number: number }).issue_number, 77); + }); + + it('passes the correct owner and repo', async () => { + const { github, calls } = makeGithub(); + await postComment({ + github, + context: makeContext({ owner: 'my-org', repo: 'my-repo' }), + reportPath, + metaPath, + }); + + assert.equal((calls.create[0] as { owner: string }).owner, 'my-org'); + assert.equal((calls.create[0] as { repo: string }).repo, 'my-repo'); + }); +}); + +// --------------------------------------------------------------------------- +// Comment update +// --------------------------------------------------------------------------- + +describe('comment update', () => { + it('updates an existing comment that contains the marker', async () => { + const marker = ''; + const existing = { id: 55, body: `${marker}\nOld content` }; + const { github, calls } = makeGithub({ comments: [existing] }); + await postComment({ github, context: makeContext(), provider: 'vercel', reportPath, metaPath }); + + assert.equal(calls.update.length, 1); + assert.equal(calls.create.length, 0); + assert.equal((calls.update[0] as { comment_id: number }).comment_id, 55); + }); + + it('creates a new comment when existing comments do not contain the marker', async () => { + const comments = [ + { id: 1, body: 'unrelated comment' }, + { id: 2, body: '\nOther report' }, + ]; + const { github, calls } = makeGithub({ comments }); + await postComment({ github, context: makeContext(), provider: 'vercel', reportPath, metaPath }); + + assert.equal(calls.create.length, 1); + assert.equal(calls.update.length, 0); + }); +}); + +// --------------------------------------------------------------------------- +// Comment body +// --------------------------------------------------------------------------- + +describe('comment body', () => { + it('starts with the provider-specific marker', async () => { + const { github, calls } = makeGithub(); + await postComment({ github, context: makeContext(), provider: 'vercel', reportPath, metaPath }); + + const body = (calls.create[0] as { body: string }).body; + + assert.ok( + body.startsWith('\n'), + `Body should start with vercel marker, got: ${body.slice(0, 60)}`, + ); + }); + + it('uses provider name in the marker', async () => { + const { github, calls } = makeGithub(); + await postComment({ github, context: makeContext(), provider: 'cloudflare', reportPath, metaPath }); + + const body = (calls.create[0] as { body: string }).body; + + assert.ok(body.includes('')); + }); + + it('includes the report file content', async () => { + const { github, calls } = makeGithub(); + await postComment({ github, context: makeContext(), reportPath, metaPath }); + + const body = (calls.create[0] as { body: string }).body; + + assert.ok(body.includes('## Unlighthouse Performance Comparison')); + assert.ok(body.includes('Some results.')); + }); + + it('includes the workflow run link', async () => { + const { github, calls } = makeGithub(); + await postComment({ + github, + context: makeContext({ owner: 'my-org', repo: 'my-repo', runId: 12345 }), + reportPath, + metaPath, + }); + + const body = (calls.create[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 not inside a table (preceded by a blank line)', async () => { + const { github, calls } = makeGithub(); + await postComment({ github, context: makeContext(), reportPath, metaPath }); + + const body = (calls.create[0] as { body: string }).body; + const linkIndex = body.indexOf('[Full Unlighthouse report'); + + assert.ok(linkIndex > 0, 'Run link should be present'); + // The character before the link text should be a newline (blank line separator) + assert.equal(body[linkIndex - 1], '\n', 'Run link should be preceded by a blank line'); + }); +}); + +// --------------------------------------------------------------------------- +// Sha lookup +// --------------------------------------------------------------------------- + +describe('sha lookup', () => { + it('passes the deployment sha to listPullRequestsAssociatedWithCommit', async () => { + const { github, calls } = makeGithub(); + await postComment({ + github, + context: makeContext({ sha: 'deadbeef' }), + reportPath, + metaPath, + }); + + assert.equal( + (calls.listPrs[0] as { commit_sha: string }).commit_sha, + 'deadbeef', + ); + }); +}); diff --git a/.github/scripts/bundle-size.mts b/.github/scripts/bundle-size.mts new file mode 100644 index 0000000000..ee1961b592 --- /dev/null +++ b/.github/scripts/bundle-size.mts @@ -0,0 +1,555 @@ +#!/usr/bin/env node +/* eslint-disable no-console, no-restricted-syntax, no-plusplus, no-continue */ + +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { gzipSync } from "node:zlib"; + +// eslint-disable-next-line no-underscore-dangle +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CORE_DIR = resolve(__dirname, "..", "..", "core"); + +interface ChunkSizes { + js: number; + css: number; +} + +interface RouteMetric { + js: number; + css: number; + firstLoadJs: number; +} + +interface BundleReport { + commitSha: string; + updatedAt: string; + firstLoadJs: number; + totalJs: number; + totalCss: number; + shared?: { js: number; css: number }; + routes?: Record; +} + +interface CompareOptions { + threshold?: number; +} + +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + +const sizeCache = new Map(); + +function clearSizeCache(): void { + sizeCache.clear(); +} + +function getGzipSize(filePath: string): number { + if (sizeCache.has(filePath)) return sizeCache.get(filePath)!; + + if (!existsSync(filePath)) { + sizeCache.set(filePath, 0); + + return 0; + } + + const data = readFileSync(filePath); + const gzipped = gzipSync(data, { level: 6 }); + const sizeKb = gzipped.length / 1024; + + sizeCache.set(filePath, sizeKb); + + return sizeKb; +} + +function sumChunkSizes(chunks: Iterable, dir: string): ChunkSizes { + let js = 0; + let css = 0; + + for (const chunk of chunks) { + const size = getGzipSize(join(dir, chunk)); + + if (chunk.endsWith(".css")) { + css += size; + } else { + js += size; + } + } + + return { js, css }; +} + +function parseManifestEntries(entries: Record): { + layouts: Record; + pages: Record; +} { + const layouts: Record = {}; + const pages: Record = {}; + + for (const [route, chunks] of Object.entries(entries)) { + if (route.endsWith("/layout")) { + layouts[route] = chunks; + } else if (route.endsWith("/page")) { + pages[route] = chunks; + } + } + + return { layouts, pages }; +} + +function computeRootLayout( + layoutPaths: string[], + layouts: Record, + sharedChunks: Set, + nextDir: string, +): { + rootLayoutPath: string | null; + rootLayoutChunks: Set; + rootLayoutJs: number; + rootLayoutCss: number; +} { + const sorted = [...layoutPaths].sort( + (a, b) => a.split("/").length - b.split("/").length, + ); + const rootLayoutPath = sorted[0] ?? null; + const rootLayoutChunks = new Set(); + let rootLayoutJs = 0; + let rootLayoutCss = 0; + + if (rootLayoutPath) { + const uniqueChunks = layouts[rootLayoutPath].filter( + (c) => !sharedChunks.has(c), + ); + const sizes = sumChunkSizes(uniqueChunks, nextDir); + + rootLayoutJs = sizes.js; + rootLayoutCss = sizes.css; + uniqueChunks.forEach((c) => rootLayoutChunks.add(c)); + } + + return { rootLayoutPath, rootLayoutChunks, rootLayoutJs, rootLayoutCss }; +} + +function computeRouteMetrics( + pages: Record, + layouts: Record, + sharedChunks: Set, + rootLayoutPath: string | null, + rootLayoutChunks: Set, + firstLoadJs: number, + nextDir: string, +): Record { + const routes: Record = {}; + + for (const [route, chunks] of Object.entries(pages)) { + const segments = route.split("/"); + + segments.pop(); // remove 'page' + + const ancestorLayouts: string[] = []; + + for (let i = segments.length; i >= 1; i--) { + const parentPath = `${segments.slice(0, i).join("/")}/layout`; + + if (layouts[parentPath]) { + ancestorLayouts.push(parentPath); + } + } + + const routeChunks = new Set(); + + for (const chunk of chunks.filter((c) => !sharedChunks.has(c))) { + if (!rootLayoutChunks.has(chunk)) { + routeChunks.add(chunk); + } + } + + for (const layoutPath of ancestorLayouts) { + if (layoutPath === rootLayoutPath) continue; + + for (const chunk of layouts[layoutPath].filter( + (c) => !sharedChunks.has(c), + )) { + if (!rootLayoutChunks.has(chunk)) { + routeChunks.add(chunk); + } + } + } + + const sizes = sumChunkSizes(routeChunks, nextDir); + + routes[route] = { + js: round1(sizes.js), + css: round1(sizes.css), + firstLoadJs: round1(firstLoadJs + sizes.js + sizes.css), + }; + } + + return routes; +} + +function readTurbopackEntries(serverAppDir: string): Record { + const entries: Record = {}; + + function scanDir(dir: string): void { + const items = readdirSync(dir, { withFileTypes: true }); + + for (const item of items) { + const fullPath = join(dir, item.name); + + if (item.isDirectory()) { + scanDir(fullPath); + } else if (item.name.endsWith("_client-reference-manifest.js")) { + try { + const content = readFileSync(fullPath, "utf-8"); + const g: Record = {}; + // eslint-disable-next-line no-new-func + const fn = new Function("globalThis", "self", `${content}\nreturn globalThis;`); + const result = fn(g, g) as { + __RSC_MANIFEST?: Record< + string, + { clientModules?: Record } + >; + }; + const manifest = result.__RSC_MANIFEST; + + if (!manifest) continue; + + for (const [routeKey, entry] of Object.entries(manifest)) { + if (!routeKey.endsWith("/page")) continue; + + const chunks = new Set(); + + for (const mod of Object.values(entry.clientModules ?? {})) { + for (const chunk of mod.chunks ?? []) { + // Normalize: "/_next/static/chunks/xxx.js" → "static/chunks/xxx.js" + chunks.add(chunk.replace(/^\/_next\//, "")); + } + } + + entries[routeKey] = [...chunks]; + } + } catch { + // Skip malformed manifest files + } + } + } + } + + scanDir(serverAppDir); + + return entries; +} + +function compareReport( + baseline: BundleReport, + current: BundleReport, + { threshold = 5 }: CompareOptions = {}, +): string { + function hasChanged(base: number, curr: number): boolean { + if (round1(curr - base) === 0) return false; + const pct = base > 0 ? ((curr - base) / base) * 100 : null; + if (pct !== null && round1(pct) === 0) return false; + return true; + } + + function formatDelta(base: number, curr: number): string { + const delta = curr - base; + const rounded = round1(delta); + const sign = delta >= 0 ? "+" : ""; + const pct = base > 0 ? (delta / base) * 100 : 0; + const pctStr = base > 0 ? ` (${sign}${round1(pct)}%)` : ""; + return `${sign}${rounded} kB${pctStr}`; + } + + function isWarning(base: number, curr: number): boolean { + const delta = curr - base; + const pct = base > 0 ? (delta / base) * 100 : 0; + + return delta > 1 && pct > threshold; + } + + function displayRoute(route: string): string { + return route.replace(/^\/\[locale\]/, ""); + } + + const lines: string[] = []; + + lines.push("## Bundle Size Report"); + lines.push(""); + lines.push( + `Comparing against baseline from \`${baseline.commitSha}\` (${baseline.updatedAt}).`, + ); + lines.push(""); + + const changedMetrics = [ + { + name: "First Load JS", + base: baseline.firstLoadJs, + curr: current.firstLoadJs, + }, + { name: "Total JS", base: baseline.totalJs, curr: current.totalJs }, + { name: "Total CSS", base: baseline.totalCss, curr: current.totalCss }, + ].filter((m) => hasChanged(m.base, m.curr)); + + const allRoutes = new Set([ + ...Object.keys(baseline.routes ?? {}), + ...Object.keys(current.routes ?? {}), + ]); + + const sortedRoutes = [...allRoutes].sort(); + const routeLines: string[] = []; + + for (const route of sortedRoutes) { + const display = displayRoute(route); + const base = baseline.routes?.[route]; + const curr = current.routes?.[route]; + + if (!base && curr) { + routeLines.push( + `| ${display} | -- | ${round1(curr.firstLoadJs)} kB | ✨ NEW | |`, + ); + } else if (base && !curr) { + routeLines.push( + `| ${display} | ${round1(base.firstLoadJs)} kB | -- | REMOVED | |`, + ); + } else if (base && curr && hasChanged(base.firstLoadJs, curr.firstLoadJs)) { + const d = formatDelta(base.firstLoadJs, curr.firstLoadJs); + const warn = isWarning(base.firstLoadJs, curr.firstLoadJs) ? " ⚠️" : ""; + + routeLines.push( + `| ${display} | ${round1(base.firstLoadJs)} kB | ${round1(curr.firstLoadJs)} kB | ${d} |${warn} |`, + ); + } + } + + if (changedMetrics.length === 0 && routeLines.length === 0) { + lines.push("No bundle size changes detected."); + lines.push(""); + return lines.join("\n"); + } + + if (changedMetrics.length > 0) { + lines.push("| Metric | Baseline | Current | Delta | |"); + lines.push("|:-------|:---------|:--------|:------|:-|"); + + for (const m of changedMetrics) { + const d = formatDelta(m.base, m.curr); + const warn = isWarning(m.base, m.curr) ? " ⚠️" : ""; + + lines.push( + `| ${m.name} | ${round1(m.base)} kB | ${round1(m.curr)} kB | ${d} |${warn} |`, + ); + } + + lines.push(""); + } + + lines.push("### Per-Route First Load JS"); + lines.push(""); + + if (routeLines.length > 0) { + lines.push("| Route | Baseline | Current | Delta | |"); + lines.push("|:------|:---------|:--------|:------|:-|"); + lines.push(...routeLines); + lines.push(""); + lines.push( + `> Threshold: ${threshold}% increase. Routes with ⚠️ exceed the threshold.`, + ); + } else { + lines.push("_No route changes detected._"); + } + + lines.push(""); + + return lines.join("\n"); +} + +function generate( + nextDir: string, + values: Record, +): void { + const appManifestPath = join(nextDir, "app-build-manifest.json"); + const buildManifestPath = join(nextDir, "build-manifest.json"); + const serverAppDir = join(nextDir, "server", "app"); + + const isWebpack = existsSync(appManifestPath); + const isTurbopack = !isWebpack && existsSync(serverAppDir); + + if (!isWebpack && !isTurbopack) { + console.error( + "Error: No build output found (.next/app-build-manifest.json or .next/server/app/). Run `next build` first.", + ); + process.exit(1); + } + + const buildManifest = JSON.parse( + readFileSync(buildManifestPath, "utf-8"), + ) as { + rootMainFiles?: string[]; + polyfillFiles?: string[]; + }; + + const rootMainFiles = new Set(buildManifest.rootMainFiles ?? []); + const polyfillFiles = new Set(buildManifest.polyfillFiles ?? []); + const sharedChunks = new Set([...rootMainFiles, ...polyfillFiles]); + + let entries: Record; + + if (isWebpack) { + const appManifest = JSON.parse(readFileSync(appManifestPath, "utf-8")) as { + pages?: Record; + }; + + entries = appManifest.pages ?? {}; + } else { + entries = readTurbopackEntries(serverAppDir); + } + const { layouts, pages } = parseManifestEntries(entries); + + // Shared JS = sum of rootMainFiles gzipped sizes + const sharedSizes = sumChunkSizes(rootMainFiles, nextDir); + const sharedJs = round1(sharedSizes.js); + + // Root layout + const { rootLayoutPath, rootLayoutChunks, rootLayoutJs, rootLayoutCss } = + computeRootLayout(Object.keys(layouts), layouts, sharedChunks, nextDir); + + const sharedCss = round1(rootLayoutCss); + const firstLoadJs = round1(sharedJs + rootLayoutJs + rootLayoutCss); + + // Total JS and CSS across all unique chunks + const allChunksSet = new Set(); + + for (const chunks of Object.values(entries)) { + for (const chunk of chunks) { + allChunksSet.add(chunk); + } + } + + const totals = sumChunkSizes(allChunksSet, nextDir); + const totalJs = round1(totals.js); + const totalCss = round1(totals.css); + + // Per-route metrics + const routes = computeRouteMetrics( + pages, + layouts, + sharedChunks, + rootLayoutPath, + rootLayoutChunks, + firstLoadJs, + nextDir, + ); + + const result: BundleReport = { + commitSha: values.sha ?? "unknown", + updatedAt: new Date().toISOString().split("T")[0], + firstLoadJs, + shared: { js: sharedJs, css: sharedCss }, + routes, + totalJs, + totalCss, + }; + + const output = values.output ?? null; + const json = `${JSON.stringify(result, null, 2)}\n`; + + if (output) { + writeFileSync(resolve(output), json); + console.error(`Bundle size report written to ${output}`); + } else { + process.stdout.write(json); + } +} + +function compare( + nextDir: string, + values: Record, +): void { + const baselinePath = resolve( + values.baseline ?? join(CORE_DIR, "bundle-baseline.json"), + ); + const currentPath = resolve(values.current ?? ""); + const threshold = Number(values.threshold ?? "5"); + + if (!currentPath || !existsSync(currentPath)) { + console.error("Error: --current is required and must exist"); + process.exit(1); + } + + if (!existsSync(baselinePath)) { + console.error(`Error: baseline not found at ${baselinePath}`); + process.exit(1); + } + + const baseline = JSON.parse( + readFileSync(baselinePath, "utf-8"), + ) as BundleReport; + const current = JSON.parse( + readFileSync(currentPath, "utf-8"), + ) as BundleReport; + + process.stdout.write(compareReport(baseline, current, { threshold })); +} + +export { + round1, + getGzipSize, + sumChunkSizes, + parseManifestEntries, + computeRootLayout, + computeRouteMetrics, + compareReport, + clearSizeCache, + readTurbopackEntries, +}; + +export type { BundleReport, RouteMetric, ChunkSizes, CompareOptions }; + +const isMain = process.argv[1] === fileURLToPath(import.meta.url); + +if (isMain) { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + output: { type: "string" }, + baseline: { type: "string" }, + current: { type: "string" }, + threshold: { type: "string" }, + sha: { type: "string" }, + dir: { type: "string" }, + }, + }); + + const NEXT_DIR = values.dir ? resolve(values.dir) : join(CORE_DIR, ".next"); + const command = positionals.at(0); + + if (command === "generate") { + generate(NEXT_DIR, values); + } else if (command === "compare") { + compare(NEXT_DIR, values); + } else { + console.error("Usage: bundle-size.mts [options]"); + console.error(""); + console.error("Commands:"); + console.error( + " generate Analyze .next/ build output and produce bundle size JSON", + ); + console.error(" --output Write JSON to file instead of stdout"); + console.error(""); + console.error(" compare Compare current bundle against a baseline"); + console.error( + " --baseline Path to baseline JSON (default: ./bundle-baseline.json)", + ); + console.error( + " --current Path to current bundle JSON (required)", + ); + console.error( + " --threshold Warning threshold percentage (default: 5)", + ); + process.exit(1); + } +} diff --git a/.github/scripts/compare-unlighthouse.mts b/.github/scripts/compare-unlighthouse.mts new file mode 100644 index 0000000000..0b84ae2ec5 --- /dev/null +++ b/.github/scripts/compare-unlighthouse.mts @@ -0,0 +1,257 @@ +#!/usr/bin/env node +/* eslint-disable no-console, no-restricted-syntax, no-plusplus, no-continue */ + +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, + prodDesktop: string, + prodMobile: string, + prevDesktop: string, + prevMobile: string, +): string { + return `| ${label} | ${prodDesktop} | ${prodMobile} | ${prevDesktop} | ${prevMobile} |`; +} + +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", +}; + +const COL_HEADER = + "| | Prod Desktop | Prod Mobile | Preview Desktop | Preview Mobile |"; +const COL_SEP = + "|:-|:------------|:------------|:----------------|:---------------|"; + +function compareResults( + productionDesktop: CiResult, + productionMobile: CiResult, + previewDesktop: CiResult, + previewMobile: CiResult, + threshold: number, + provider?: string, +): { markdown: string; hasChanges: boolean } { + const thresholdDecimal = threshold / 100; + + // hasChanges: any summary or category score pair differs by >= threshold + let hasChanges = + Math.abs(previewDesktop.summary.score - productionDesktop.summary.score) >= + thresholdDecimal || + Math.abs(previewMobile.summary.score - productionMobile.summary.score) >= + thresholdDecimal; + + if (!hasChanges) { + for (const id of CATEGORY_ORDER) { + const deltaDesktop = Math.abs( + (previewDesktop.summary.categories[id]?.score ?? 0) - + (productionDesktop.summary.categories[id]?.score ?? 0), + ); + const deltaMobile = Math.abs( + (previewMobile.summary.categories[id]?.score ?? 0) - + (productionMobile.summary.categories[id]?.score ?? 0), + ); + + if (deltaDesktop >= thresholdDecimal || deltaMobile >= thresholdDecimal) { + hasChanges = true; + break; + } + } + } + + const lines: string[] = []; + + const providerLabel = provider + ? ` — ${provider.charAt(0).toUpperCase()}${provider.slice(1)}` + : ""; + + lines.push(`## Unlighthouse Performance Comparison${providerLabel}`); + lines.push( + "Comparing PR preview deployment Unlighthouse scores vs production Unlighthouse scores.", + ); + lines.push(""); + + lines.push("### Summary Score"); + lines.push( + "_Aggregate score across all categories as reported by Unlighthouse._", + ); + lines.push(""); + lines.push(COL_HEADER); + lines.push(COL_SEP); + lines.push( + row( + "Score", + score(productionDesktop.summary.score), + score(productionMobile.summary.score), + score(previewDesktop.summary.score), + score(previewMobile.summary.score), + ), + ); + lines.push(""); + + lines.push("### Category Scores"); + lines.push(""); + lines.push( + "| Category | Prod Desktop | Prod Mobile | Preview Desktop | Preview Mobile |", + ); + lines.push( + "|:---------|:------------|:------------|:----------------|:---------------|", + ); + + for (const id of CATEGORY_ORDER) { + lines.push( + row( + CATEGORY_LABELS[id] ?? id, + score(productionDesktop.summary.categories[id]?.score ?? 0), + score(productionMobile.summary.categories[id]?.score ?? 0), + score(previewDesktop.summary.categories[id]?.score ?? 0), + score(previewMobile.summary.categories[id]?.score ?? 0), + ), + ); + } + + lines.push(""); + + lines.push("### Core Web Vitals"); + lines.push(""); + lines.push( + "| Metric | Prod Desktop | Prod Mobile | Preview Desktop | Preview Mobile |", + ); + lines.push( + "|:-------|:------------|:------------|:----------------|:---------------|", + ); + + for (const id of METRIC_ORDER) { + lines.push( + row( + METRIC_LABELS[id] ?? id, + productionDesktop.summary.metrics[id]?.displayValue ?? "—", + productionMobile.summary.metrics[id]?.displayValue ?? "—", + previewDesktop.summary.metrics[id]?.displayValue ?? "—", + previewMobile.summary.metrics[id]?.displayValue ?? "—", + ), + ); + } + + lines.push(""); + + return { markdown: lines.join("\n"), hasChanges }; +} + +export { compareResults }; +export type { CiResult }; + +const isMain = process.argv[1] === fileURLToPath(import.meta.url); + +if (isMain) { + const { values } = parseArgs({ + options: { + "preview-desktop": { type: "string" }, + "preview-mobile": { type: "string" }, + "production-desktop": { type: "string" }, + "production-mobile": { type: "string" }, + output: { type: "string" }, + "meta-output": { type: "string" }, + threshold: { type: "string" }, + provider: { type: "string" }, + }, + }); + + const previewDesktopPath = values["preview-desktop"] ?? ""; + const previewMobilePath = values["preview-mobile"] ?? ""; + const productionDesktopPath = values["production-desktop"] ?? ""; + const productionMobilePath = values["production-mobile"] ?? ""; + + if ( + !previewDesktopPath || + !previewMobilePath || + !productionDesktopPath || + !productionMobilePath + ) { + console.error( + "Usage: compare-unlighthouse.mts --preview-desktop --preview-mobile --production-desktop --production-mobile [--output ] [--meta-output ] [--threshold ] [--provider ]", + ); + process.exit(1); + } + + const threshold = Number(values.threshold ?? "1"); + + const previewDesktop = loadCiResult(resolve(previewDesktopPath)); + const previewMobile = loadCiResult(resolve(previewMobilePath)); + const productionDesktop = loadCiResult(resolve(productionDesktopPath)); + const productionMobile = loadCiResult(resolve(productionMobilePath)); + + const { markdown, hasChanges } = compareResults( + productionDesktop, + productionMobile, + previewDesktop, + previewMobile, + threshold, + values.provider, + ); + + const outputPath = values.output ? resolve(values.output) : null; + const metaOutputPath = values["meta-output"] + ? resolve(values["meta-output"]) + : null; + + if (outputPath) { + writeFileSync(outputPath, markdown); + console.error(`Unlighthouse comparison report written to ${outputPath}`); + } else { + process.stdout.write(markdown); + } + + if (metaOutputPath) { + writeFileSync( + metaOutputPath, + `${JSON.stringify({ hasChanges }, null, 2)}\n`, + ); + console.error(`Meta output written to ${metaOutputPath}`); + } +} diff --git a/.github/scripts/post-bundle-comment.js b/.github/scripts/post-bundle-comment.js new file mode 100644 index 0000000000..43832a5a60 --- /dev/null +++ b/.github/scripts/post-bundle-comment.js @@ -0,0 +1,30 @@ +const fs = require('fs'); + +module.exports = async ({ github, context, reportPath = '/tmp/bundle-report.md' }) => { + const marker = ''; + const body = marker + '\n' + fs.readFileSync(reportPath, 'utf-8'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } +}; diff --git a/.github/scripts/post-unlighthouse-comment.js b/.github/scripts/post-unlighthouse-comment.js new file mode 100644 index 0000000000..a71cc59437 --- /dev/null +++ b/.github/scripts/post-unlighthouse-comment.js @@ -0,0 +1,52 @@ +const fs = require('fs'); + +module.exports = async ({ github, context, provider = 'unknown', reportPath = '/tmp/unlighthouse-report.md', metaPath = '/tmp/unlighthouse-meta.json' }) => { + // Exit early if no changes + const { hasChanges } = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); + + if (!hasChanges) return; + + // Find PR from commit SHA (context.issue.number is 0 in deployment_status events; + // deployment.ref is also the SHA in Vercel deployments, not the branch name) + const sha = context.payload.deployment?.sha; + + if (!sha) return; + + const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: sha, + }); + + const prNumber = prs[0]?.number; + + if (!prNumber) 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`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + + const existing = comments.find(c => c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } +}; diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index 4a9c901c81..1bd4d8228b 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -12,9 +12,19 @@ env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} - BIGCOMMERCE_STORE_HASH: ${{ secrets.BIGCOMMERCE_STORE_HASH }} + BIGCOMMERCE_STORE_HASH: ${{ vars.BIGCOMMERCE_STORE_HASH }} + BIGCOMMERCE_CHANNEL_ID: ${{ vars.BIGCOMMERCE_CHANNEL_ID }} + BIGCOMMERCE_CLIENT_ID: ${{ secrets.BIGCOMMERCE_CLIENT_ID }} + BIGCOMMERCE_CLIENT_SECRET: ${{ secrets.BIGCOMMERCE_CLIENT_SECRET }} BIGCOMMERCE_STOREFRONT_TOKEN: ${{ secrets.BIGCOMMERCE_STOREFRONT_TOKEN }} - BIGCOMMERCE_CHANNEL_ID: ${{ secrets.BIGCOMMERCE_CHANNEL_ID }} + BIGCOMMERCE_ACCESS_TOKEN: ${{ secrets.BIGCOMMERCE_ACCESS_TOKEN }} + TEST_CUSTOMER_ID: ${{ vars.TEST_CUSTOMER_ID }} + TEST_CUSTOMER_EMAIL: ${{ vars.TEST_CUSTOMER_EMAIL }} + TEST_CUSTOMER_PASSWORD: ${{ secrets.TEST_CUSTOMER_PASSWORD }} + TESTS_FALLBACK_LOCALE: ${{ vars.TESTS_FALLBACK_LOCALE }} + TESTS_READ_ONLY: ${{ vars.TESTS_READ_ONLY }} + DEFAULT_PRODUCT_ID: ${{ vars.DEFAULT_PRODUCT_ID }} + DEFAULT_COMPLEX_PRODUCT_ID: ${{ vars.DEFAULT_COMPLEX_PRODUCT_ID }} jobs: lint-typecheck: diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 0000000000..9744ec6a74 --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,142 @@ +name: Bundle Size +# Reports the bundle size impact of a PR by comparing the current build against +# a live build of the base branch (canary or integrations/makeswift). +# +# build-pr and build-baseline run in parallel, each uploading a JSON artifact. +# compare downloads both artifacts, runs the comparison, and posts the PR comment. + +on: + pull_request: + types: [opened, synchronize] + +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + BIGCOMMERCE_STORE_HASH: ${{ vars.BIGCOMMERCE_STORE_HASH }} + BIGCOMMERCE_CHANNEL_ID: ${{ vars.BIGCOMMERCE_CHANNEL_ID }} + BIGCOMMERCE_CLIENT_ID: ${{ secrets.BIGCOMMERCE_CLIENT_ID }} + BIGCOMMERCE_CLIENT_SECRET: ${{ secrets.BIGCOMMERCE_CLIENT_SECRET }} + BIGCOMMERCE_STOREFRONT_TOKEN: ${{ secrets.BIGCOMMERCE_STOREFRONT_TOKEN }} + BIGCOMMERCE_ACCESS_TOKEN: ${{ secrets.BIGCOMMERCE_ACCESS_TOKEN }} + MAKESWIFT_SITE_API_KEY: ${{ secrets.MAKESWIFT_SITE_API_KEY }} + +jobs: + build-pr: + name: Build & Measure PR Bundle + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + path: pr + + - uses: pnpm/action-setup@v4 + with: + package_json_file: pr/package.json + + - uses: actions/setup-node@v4 + with: + node-version-file: pr/.nvmrc + cache: pnpm + cache-dependency-path: pr/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + working-directory: pr + + - run: pnpm build + working-directory: pr + + - run: node .github/scripts/bundle-size.mts generate --output /tmp/bundle-current.json --sha ${{ github.sha }} + working-directory: pr + + - uses: actions/upload-artifact@v4 + with: + name: bundle-current + path: /tmp/bundle-current.json + + build-baseline: + name: Build & Measure Baseline Bundle + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + path: pr + + - name: Detect baseline branch + id: baseline + run: | + PKG_NAME=$(node -p "require('./pr/core/package.json').name") + if [ "$PKG_NAME" = "@bigcommerce/catalyst-makeswift" ]; then + echo "branch=integrations/makeswift" >> $GITHUB_OUTPUT + else + echo "branch=canary" >> $GITHUB_OUTPUT + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.baseline.outputs.branch }} + path: baseline + + - uses: pnpm/action-setup@v4 + with: + package_json_file: pr/package.json + + - uses: actions/setup-node@v4 + with: + node-version-file: pr/.nvmrc + cache: pnpm + cache-dependency-path: baseline/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + working-directory: baseline + + - run: pnpm build + working-directory: baseline + + - name: Generate baseline bundle size + run: | + SHA=$(git -C $GITHUB_WORKSPACE/baseline rev-parse --short HEAD) + node .github/scripts/bundle-size.mts generate --dir $GITHUB_WORKSPACE/baseline/core/.next --output /tmp/bundle-baseline.json --sha $SHA + working-directory: pr + + - uses: actions/upload-artifact@v4 + with: + name: bundle-baseline + path: /tmp/bundle-baseline.json + + compare: + name: Compare Bundles & Post Report + needs: [build-pr, build-baseline] + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + path: pr + + - uses: actions/setup-node@v4 + with: + node-version-file: pr/.nvmrc + + - uses: actions/download-artifact@v4 + with: + pattern: bundle-* + path: /tmp + merge-multiple: true + + - run: node .github/scripts/bundle-size.mts compare --baseline /tmp/bundle-baseline.json --current /tmp/bundle-current.json > /tmp/bundle-report.md + working-directory: pr + + - run: cat /tmp/bundle-report.md >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/github-script@v7 + with: + script: | + const postComment = require('./pr/.github/scripts/post-bundle-comment.js') + await postComment({ github, context }) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 36899bfe75..2e8edf081e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,7 +1,8 @@ name: Production Tag Deployment env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + # secrets is for dependabot compatibility; prefer vars when available + VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID != '' && vars.VERCEL_ORG_ID || secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID != '' && vars.VERCEL_PROJECT_ID || secrets.VERCEL_PROJECT_ID }} on: push: tags: @@ -10,7 +11,7 @@ on: - "@bigcommerce/catalyst-b2b-makeswift@latest" jobs: deploy-tag: - name: Deploy `{{ github.ref_name }}` tag + name: Deploy `${{ github.ref_name }}` tag runs-on: ubuntu-latest concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} @@ -20,25 +21,61 @@ jobs: - name: Install Vercel CLI run: npm install --global vercel@latest - - name: Parse Tag and Build Domain - id: parse-tag + - name: Configure catalyst-core deployment + if: contains(github.ref_name, 'catalyst-core@') run: | - TAG="${{ github.ref_name }}" - PACKAGE_NAME=$(echo "$TAG" | sed -E 's/^@bigcommerce\/([^@]+)@.*$/\1/') - case "$PACKAGE_NAME" in - "catalyst-core") DOMAIN="catalyst-demo.site" ;; - "catalyst-makeswift") DOMAIN="makeswift.catalyst-demo.site" ;; - "catalyst-b2b-makeswift") DOMAIN="b2b-makeswift.catalyst-demo.site" ;; - *) echo "Error: Unknown package name: $PACKAGE_NAME"; exit 1 ;; - esac - echo "domain=$DOMAIN" >> $GITHUB_OUTPUT + echo "DOMAIN=catalyst-demo.site" >> $GITHUB_ENV + echo "CHANNEL_ID=${{ vars.CORE_BIGCOMMERCE_CHANNEL_ID }}" >> $GITHUB_ENV + echo "STOREFRONT_TOKEN=${{ secrets.CORE_BIGCOMMERCE_STOREFRONT_TOKEN }}" >> $GITHUB_ENV + + - name: Configure catalyst-makeswift deployment + if: contains(github.ref_name, 'catalyst-makeswift@') + run: | + echo "DOMAIN=makeswift.catalyst-demo.site" >> $GITHUB_ENV + echo "CHANNEL_ID=${{ vars.MAKESWIFT_BIGCOMMERCE_CHANNEL_ID }}" >> $GITHUB_ENV + echo "STOREFRONT_TOKEN=${{ secrets.MAKESWIFT_BIGCOMMERCE_STOREFRONT_TOKEN }}" >> $GITHUB_ENV + echo "MAKESWIFT_KEY=${{ secrets.MAKESWIFT_SITE_API_KEY }}" >> $GITHUB_ENV + + - name: Configure catalyst-b2b-makeswift deployment + if: contains(github.ref_name, 'catalyst-b2b-makeswift@') + run: | + echo "DOMAIN=b2b-makeswift.catalyst-demo.site" >> $GITHUB_ENV + echo "CHANNEL_ID=${{ vars.B2B_MAKESWIFT_BIGCOMMERCE_CHANNEL_ID }}" >> $GITHUB_ENV + echo "STOREFRONT_TOKEN=${{ secrets.B2B_MAKESWIFT_BIGCOMMERCE_STOREFRONT_TOKEN }}" >> $GITHUB_ENV + echo "MAKESWIFT_KEY=${{ secrets.B2B_MAKESWIFT_SITE_API_KEY }}" >> $GITHUB_ENV + echo "B2B_API_HOST=${{ vars.B2B_API_HOST }}" >> $GITHUB_ENV + echo "BIGCOMMERCE_ACCESS_TOKEN=${{ secrets.B2B_BIGCOMMERCE_ACCESS_TOKEN }}" >> $GITHUB_ENV - name: Deploy to Vercel id: deploy + timeout-minutes: 15 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | - DEPLOYMENT_URL=$(vercel deploy --token=${{ secrets.VERCEL_TOKEN }}) + DEPLOY_ARGS=( + --token="$VERCEL_TOKEN" + --env BIGCOMMERCE_CHANNEL_ID="$CHANNEL_ID" + --env BIGCOMMERCE_STOREFRONT_TOKEN="$STOREFRONT_TOKEN" + ) + + if [[ -n "$MAKESWIFT_KEY" ]]; then + DEPLOY_ARGS+=(--env MAKESWIFT_SITE_API_KEY="$MAKESWIFT_KEY") + fi + + if [[ -n "$B2B_API_HOST" ]]; then + DEPLOY_ARGS+=(--env B2B_API_HOST="$B2B_API_HOST") + fi + + if [[ -n "$BIGCOMMERCE_ACCESS_TOKEN" ]]; then + DEPLOY_ARGS+=(--env BIGCOMMERCE_ACCESS_TOKEN="$BIGCOMMERCE_ACCESS_TOKEN") + fi + + DEPLOYMENT_URL=$(vercel deploy --scope="${{ vars.VERCEL_TEAM_SLUG }}" "${DEPLOY_ARGS[@]}") echo "deployment_url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT - name: Set Vercel Domain Alias + timeout-minutes: 5 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | - vercel alias ${{ steps.deploy.outputs.deployment_url }} ${{ steps.parse-tag.outputs.domain }} --token=${{ secrets.VERCEL_TOKEN }} + vercel alias ${{ steps.deploy.outputs.deployment_url }} $DOMAIN --scope="${{ vars.VERCEL_TEAM_SLUG }}" --token="$VERCEL_TOKEN" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000000..51fe612489 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,106 @@ +name: E2E Tests + +on: + pull_request: + types: [opened, synchronize] + branches: [canary, integrations/makeswift, integrations/b2b-makeswift] + +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + BIGCOMMERCE_STORE_HASH: ${{ vars.BIGCOMMERCE_STORE_HASH }} + BIGCOMMERCE_CHANNEL_ID: ${{ vars.BIGCOMMERCE_CHANNEL_ID }} + BIGCOMMERCE_CLIENT_ID: ${{ secrets.BIGCOMMERCE_CLIENT_ID }} + BIGCOMMERCE_CLIENT_SECRET: ${{ secrets.BIGCOMMERCE_CLIENT_SECRET }} + BIGCOMMERCE_STOREFRONT_TOKEN: ${{ secrets.BIGCOMMERCE_STOREFRONT_TOKEN }} + BIGCOMMERCE_ACCESS_TOKEN: ${{ secrets.BIGCOMMERCE_ACCESS_TOKEN }} + TEST_CUSTOMER_ID: ${{ vars.TEST_CUSTOMER_ID }} + TEST_CUSTOMER_EMAIL: ${{ vars.TEST_CUSTOMER_EMAIL }} + TEST_CUSTOMER_PASSWORD: ${{ secrets.TEST_CUSTOMER_PASSWORD }} + TESTS_FALLBACK_LOCALE: ${{ vars.TESTS_FALLBACK_LOCALE }} + TESTS_READ_ONLY: ${{ vars.TESTS_READ_ONLY }} + DEFAULT_PRODUCT_ID: ${{ vars.DEFAULT_PRODUCT_ID }} + DEFAULT_COMPLEX_PRODUCT_ID: ${{ vars.DEFAULT_COMPLEX_PRODUCT_ID }} + MAKESWIFT_SITE_API_KEY: ${{ secrets.MAKESWIFT_SITE_API_KEY }} + +jobs: + e2e-tests: + name: E2E Functional Tests (${{ matrix.name }}) + + runs-on: ubuntu-latest + + strategy: + matrix: + include: + - name: default + browsers: chromium webkit + test-filter: tests/ui/e2e + trailing-slash: true + locale-var: TESTS_LOCALE + artifact-name: playwright-report + - name: TRAILING_SLASH=false + browsers: chromium + test-filter: tests/ui/e2e --grep @no-trailing-slash + trailing-slash: false + locale-var: TESTS_LOCALE + artifact-name: playwright-report-no-trailing + - name: alternate locale + browsers: chromium + test-filter: tests/ui/e2e --grep @alternate-locale + trailing-slash: true + locale-var: TESTS_ALTERNATE_LOCALE + artifact-name: playwright-report-alternate-locale + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - 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 Playwright browsers + run: pnpm exec playwright install --with-deps ${{ matrix.browsers }} + working-directory: ./core + + - name: Build catalyst + run: pnpm build + + - name: Start server + run: | + mkdir -p ./.tests/reports/ + pnpm start > ./.tests/reports/nextjs.app.log 2>&1 & + npx wait-on http://localhost:3000 --timeout 60000 + working-directory: ./core + env: + PORT: 3000 + 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 + run: pnpm exec playwright test ${{ matrix.test-filter }} + working-directory: ./core + env: + PLAYWRIGHT_TEST_BASE_URL: http://localhost:3000 + + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact-name }} + path: ./core/.tests/reports/ + retention-days: 3 diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/regression-tests.yml index afcbc46c2f..327d7d5b49 100644 --- a/.github/workflows/regression-tests.yml +++ b/.github/workflows/regression-tests.yml @@ -2,7 +2,7 @@ name: Regression Tests on: deployment_status: - states: ['success'] + states: ["success"] env: VERCEL_PROTECTION_BYPASS: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} @@ -12,33 +12,175 @@ concurrency: cancel-in-progress: true jobs: - unlighthouse-audit: - if: ${{ contains(fromJson('["Production – catalyst-canary", "Preview – catalyst-canary"]'), github.event.deployment_status.environment) }} - name: Unlighthouse Audit - ${{ matrix.device }} + 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 }} + steps: + - uses: actions/checkout@v4 + + - name: Detect provider and production URL + id: detect + run: | + CREATOR="${{ github.event.deployment_status.creator.login }}" + ENVIRONMENT="${{ github.event.deployment.environment }}" + + if [[ "$CREATOR" == "vercel[bot]" ]]; then + echo "provider=vercel" >> $GITHUB_OUTPUT + 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 ;; + "@bigcommerce/catalyst-makeswift") + echo "production-url=https://canary.makeswift.catalyst-demo.site" >> $GITHUB_OUTPUT ;; + *) + echo "::warning::No production URL configured for package: $PKG_NAME. Skipping comparison." + echo "production-url=" >> $GITHUB_OUTPUT ;; + esac + else + echo "is-preview=false" >> $GITHUB_OUTPUT + echo "production-url=" >> $GITHUB_OUTPUT + 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 + + 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 + fi + + unlighthouse-audit-preview: + name: Unlighthouse Audit Preview (${{ needs.detect-provider.outputs.provider }}) - ${{ matrix.device }} + needs: [detect-provider] + if: needs.detect-provider.outputs.is-preview == 'true' runs-on: ubuntu-latest strategy: matrix: device: [desktop, mobile] - concurrency: + 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: actions/checkout@v4 with: fetch-depth: 0 - name: Install Dependencies run: npm install @unlighthouse/cli puppeteer -g - - 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: Unlighthouse audit on ${{ matrix.device }} (preview) + env: + PROVIDER: ${{ needs.detect-provider.outputs.provider }} + PREVIEW_URL: ${{ github.event.deployment_status.target_url }} + run: | + if [[ "$PROVIDER" == "vercel" ]]; then + unlighthouse-ci --site "$PREVIEW_URL" --${{ matrix.device }} --disable-robots-txt \ + --extra-headers "x-vercel-protection-bypass=$VERCEL_PROTECTION_BYPASS,x-vercel-set-bypass-cookie=true" + else + unlighthouse-ci --site "$PREVIEW_URL" --${{ matrix.device }} --disable-robots-txt + fi + + - name: Upload ${{ matrix.device }} preview audit + if: failure() || success() + uses: actions/upload-artifact@v4 + with: + name: unlighthouse-preview-${{ matrix.device }}-report + path: "./.unlighthouse/" + include-hidden-files: "true" + + 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 != '' + runs-on: ubuntu-latest + strategy: + matrix: + device: [desktop, mobile] + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-production-${{ matrix.device }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@v4 + + - name: Install Dependencies + run: npm install @unlighthouse/cli puppeteer -g + + - name: Unlighthouse audit on ${{ matrix.device }} (production) + env: + PRODUCTION_URL: ${{ needs.detect-provider.outputs.production-url }} + run: unlighthouse-ci --site "$PRODUCTION_URL" --${{ matrix.device }} --disable-robots-txt - - name: Upload ${{ matrix.device }} audit + - name: Upload ${{ matrix.device }} production audit if: failure() || success() uses: actions/upload-artifact@v4 with: - name: unlighthouse-${{ matrix.device }}-report - path: './.unlighthouse/' - include-hidden-files: 'true' + name: unlighthouse-production-${{ matrix.device }}-report + path: "./.unlighthouse/" + include-hidden-files: "true" + + unlighthouse-compare: + name: Unlighthouse Compare & Comment (${{ needs.detect-provider.outputs.provider }}) + needs: [detect-provider, unlighthouse-audit-preview, unlighthouse-audit-production] + if: needs.detect-provider.outputs.is-preview == 'true' && needs.detect-provider.outputs.production-url != '' + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + - name: Download all Unlighthouse artifacts + uses: actions/download-artifact@v4 + with: + pattern: unlighthouse-*-report + path: /tmp/unlighthouse-artifacts + merge-multiple: false + + - name: Compare audits + env: + PROVIDER: ${{ needs.detect-provider.outputs.provider }} + run: | + node .github/scripts/compare-unlighthouse.mts \ + --preview-desktop /tmp/unlighthouse-artifacts/unlighthouse-preview-desktop-report/ci-result.json \ + --preview-mobile /tmp/unlighthouse-artifacts/unlighthouse-preview-mobile-report/ci-result.json \ + --production-desktop /tmp/unlighthouse-artifacts/unlighthouse-production-desktop-report/ci-result.json \ + --production-mobile /tmp/unlighthouse-artifacts/unlighthouse-production-mobile-report/ci-result.json \ + --output /tmp/unlighthouse-report.md \ + --meta-output /tmp/unlighthouse-meta.json \ + --provider "$PROVIDER" + cat /tmp/unlighthouse-report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Post PR comment + uses: actions/github-script@v7 + with: + script: | + const postComment = require('./.github/scripts/post-unlighthouse-comment.js') + await postComment({ + github, + context, + provider: '${{ needs.detect-provider.outputs.provider }}', + reportPath: '/tmp/unlighthouse-report.md', + metaPath: '/tmp/unlighthouse-meta.json', + }) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de49f01607..556297f799 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,12 @@ The default branch for this repository is called `canary`. This is the primary d To contribute to the `canary` branch, you can create a new branch off of `canary` and submit a PR against that branch. +## API Scope + +Catalyst is intended to work with the [BigCommerce Storefront GraphQL API](https://developer.bigcommerce.com/docs/storefront/graphql) and not directly integrate out of the box with the [REST Management API](https://developer.bigcommerce.com/docs/rest-management). + +You're welcome to integrate the REST Management API in your own fork, but we will not accept pull requests that incorporate or depend on the REST Management API. If your contribution requires Management API functionality, it is out of scope for this project. + ## 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). @@ -60,6 +66,7 @@ In order to complete the following steps, you will need to have met the followin > > - 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 +> - The latest release in `core/CHANGELOG.md` 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. @@ -172,14 +179,35 @@ This ensures `integrations/makeswift` remains a faithful mirror of `canary` whil - 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`) + - Example changeset: + + ``` + --- + "@bigcommerce/catalyst-makeswift": patch + --- + + Pulls in changes from the `@bigcommerce/catalyst-core@1.4.1` patch. + ``` + - 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 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. +5. **Tags and Releases:** Confirm tags exist for both `@bigcommerce/catalyst-core` and `@bigcommerce/catalyst-makeswift`. If needed, update `latest` tags in GitHub manually. + +- Push manually: + ``` + git checkout canary + # Make sure you have the latest code + git fetch origin + git pull + git tag @bigcommerce/catalyst-core@latest -f + git push origin @bigcommerce/catalyst-core@latest -f + ``` + ### Additional Notes -- **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 diff --git a/core/AGENTS.md b/core/AGENTS.md index 1d037c59bc..9c419ba898 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -10,13 +10,13 @@ This document provides guidance for Large Language Models (LLMs) working with th The main Next.js application is located in the `/core` directory, which contains the complete e-commerce storefront implementation. Other packages exist outside of `/core` but are not the primary focus for most development work. -## Middleware Architecture +## Proxy Architecture -The application uses a composed middleware stack that significantly alters the default Next.js routing behavior. The middleware composition includes authentication, internationalization, analytics, channel handling, and most importantly, custom routing. +The application uses the Next.js 16 proxy pattern (`proxy.ts`) with a composed proxy stack that significantly alters the default Next.js routing behavior. The proxy composition (in the `proxies/` directory) includes authentication, internationalization, analytics, channel handling, and most importantly, custom routing. -### Custom Routing with `with-routes` Middleware +### Custom Routing with `with-routes` -The `with-routes` middleware is the most critical component that overrides Next.js's default path-based routing. Instead of relying on file-based routing, this middleware: +The `with-routes` proxy is the most critical component that overrides Next.js's default path-based routing. Instead of relying on file-based routing, this proxy: 1. **Queries the BigCommerce GraphQL API** to resolve incoming URL paths to specific entity types (products, categories, brands, blog posts, pages). @@ -271,10 +271,10 @@ export default async function ProductPage({ params, searchParams }: Props) { 3. **Progressive Enhancement**: Static content loads immediately with dynamic content streaming via PPR and Streamable 4. **Vibes Separation**: Complete separation between data fetching (`page.tsx`) and presentation (`vibes/`) concerns 5. **Centralized API Access**: All BigCommerce API interactions go through the configured GraphQL client -6. **Middleware-First**: Critical functionality like routing, auth, and internationalization handled at the middleware layer +6. **Proxy-First**: Critical functionality like routing, auth, and internationalization handled at the proxy layer ## Notes -This codebase differs significantly from typical Next.js applications due to the custom routing middleware and e-commerce-specific patterns. The `with-routes` middleware essentially turns Next.js into a headless CMS router, where content structure is determined by the BigCommerce backend rather than the filesystem. Understanding this fundamental difference is crucial for working effectively with the codebase. +This codebase differs significantly from typical Next.js applications due to the custom routing proxy and e-commerce-specific patterns. The `with-routes` proxy (composed within `proxy.ts`) essentially turns Next.js into a headless CMS router, where content structure is determined by the BigCommerce backend rather than the filesystem. Understanding this fundamental difference is crucial for working effectively with the codebase. The Streamable pattern and PPR integration provide excellent user experience through progressive loading, but require understanding of React's newer concurrent features like the `use()` hook and Suspense boundaries. 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 128b2e3b8e..12bbaf3883 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 @@ -4,8 +4,8 @@ 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/reset-password-section/schema'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; @@ -25,6 +25,10 @@ const ChangePasswordMutation = graphql(` } `); +const schema = z.object({ + password: z.string(), +}); + export async function changePassword( { token, customerEntityId }: { token: string; customerEntityId: string }, _prevState: { lastResult: SubmissionResult | null; successMessage?: string }, diff --git a/core/app/[locale]/(default)/(auth)/change-password/page-data.ts b/core/app/[locale]/(default)/(auth)/change-password/page-data.ts new file mode 100644 index 0000000000..43a72f2d3a --- /dev/null +++ b/core/app/[locale]/(default)/(auth)/change-password/page-data.ts @@ -0,0 +1,39 @@ +import { cache } from 'react'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; + +const ChangePasswordQuery = graphql(` + query ChangePasswordQuery { + site { + settings { + customers { + passwordComplexitySettings { + minimumNumbers + minimumPasswordLength + minimumSpecialCharacters + requireLowerCase + requireNumbers + requireSpecialCharacters + requireUpperCase + } + } + } + } + } +`); + +export const getChangePasswordQuery = cache(async () => { + const response = await client.fetch({ + document: ChangePasswordQuery, + fetchOptions: { next: { revalidate } }, + }); + + const passwordComplexitySettings = + response.data.site.settings?.customers?.passwordComplexitySettings; + + return { + passwordComplexitySettings, + }; +}); diff --git a/core/app/[locale]/(default)/(auth)/change-password/page.tsx b/core/app/[locale]/(default)/(auth)/change-password/page.tsx index 944f091c6c..1e7e251a6a 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/change-password/page.tsx @@ -3,6 +3,7 @@ import { Metadata } from 'next'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { ResetPasswordSection } from '@/vibes/soul/sections/reset-password-section'; +import { getChangePasswordQuery } from '~/app/[locale]/(default)/(auth)/change-password/page-data'; import { redirect } from '~/i18n/routing'; import { changePassword } from './_actions/change-password'; @@ -37,11 +38,14 @@ export default async function ChangePassword({ params, searchParams }: Props) { return redirect({ href: '/login', locale }); } + const { passwordComplexitySettings } = await getChangePasswordQuery(); + return ( ); diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts index 918affdedf..9bb605d215 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts @@ -9,6 +9,7 @@ const BrandPageQuery = graphql(` site { brand(entityId: $entityId) { name + path seo { pageTitle metaDescription diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx index 407892f55a..34fe8b1027 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -13,6 +13,8 @@ import { facetsTransformer } from '~/data-transformers/facets-transformer'; import { pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { productCardTransformer } from '~/data-transformers/product-card-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { MAX_COMPARE_LIMIT } from '../../../compare/page-data'; import { getCompareProducts as getCompareProductsData } from '../../fetch-compare-products'; @@ -67,7 +69,7 @@ interface Props { } export async function generateMetadata(props: Props): Promise { - const { slug } = await props.params; + const { slug, locale } = await props.params; const customerAccessToken = await getSessionCustomerAccessToken(); const brandId = Number(slug); @@ -78,12 +80,17 @@ export async function generateMetadata(props: Props): Promise { return notFound(); } + const makeswiftMetadata = await getMakeswiftPageMetadata({ path: brand.path, locale }); + const { pageTitle, metaDescription, metaKeywords } = brand.seo; return { - title: pageTitle || brand.name, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + title: makeswiftMetadata?.title || pageTitle || brand.name, + ...((makeswiftMetadata?.description || metaDescription) && { + description: makeswiftMetadata?.description || metaDescription, + }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), + ...(brand.path && { alternates: await getMetadataAlternates({ path: brand.path, locale }) }), }; } diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts index 6c2c4633fe..3567a50247 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts @@ -12,6 +12,7 @@ const CategoryPageQuery = graphql( category(entityId: $entityId) { entityId name + path ...BreadcrumbsFragment seo { pageTitle diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index 05e607fe21..3742f4e96d 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -14,7 +14,9 @@ import { facetsTransformer } from '~/data-transformers/facets-transformer'; import { pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { productCardTransformer } from '~/data-transformers/product-card-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; import { Slot } from '~/lib/makeswift/slot'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { MAX_COMPARE_LIMIT } from '../../../compare/page-data'; import { getCompareProducts } from '../../fetch-compare-products'; @@ -70,7 +72,7 @@ interface Props { } export async function generateMetadata(props: Props): Promise { - const { slug } = await props.params; + const { slug, locale } = await props.params; const customerAccessToken = await getSessionCustomerAccessToken(); const categoryId = Number(slug); @@ -81,12 +83,22 @@ export async function generateMetadata(props: Props): Promise { return notFound(); } + const makeswiftMetadata = await getMakeswiftPageMetadata({ path: category.path, locale }); + const { pageTitle, metaDescription, metaKeywords } = category.seo; + const breadcrumbs = removeEdgesAndNodes(category.breadcrumbs); + const categoryPath = breadcrumbs[breadcrumbs.length - 1]?.path; + return { - title: pageTitle || category.name, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + title: makeswiftMetadata?.title || pageTitle || category.name, + ...((makeswiftMetadata?.description || metaDescription) && { + description: makeswiftMetadata?.description || metaDescription, + }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), + ...(categoryPath && { + alternates: await getMetadataAlternates({ path: categoryPath, locale }), + }), }; } diff --git a/core/app/[locale]/(default)/(faceted)/search/page.tsx b/core/app/[locale]/(default)/(faceted)/search/page.tsx index bc86471c32..c13ef0a71c 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/search/page.tsx @@ -12,6 +12,7 @@ import { facetsTransformer } from '~/data-transformers/facets-transformer'; import { pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { productCardTransformer } from '~/data-transformers/product-card-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; import { MAX_COMPARE_LIMIT } from '../../compare/page-data'; import { getCompareProducts as getCompareProductsData } from '../fetch-compare-products'; @@ -63,9 +64,11 @@ export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'Faceted.Search' }); + const makeswiftMetadata = await getMakeswiftPageMetadata({ path: '/search', locale }); return { - title: t('title'), + title: makeswiftMetadata?.title || t('title'), + description: makeswiftMetadata?.description || undefined, }; } diff --git a/core/app/[locale]/(default)/[...rest]/page.tsx b/core/app/[locale]/(default)/[...rest]/page.tsx index 695fc72a42..0382b82ac0 100644 --- a/core/app/[locale]/(default)/[...rest]/page.tsx +++ b/core/app/[locale]/(default)/[...rest]/page.tsx @@ -1,11 +1,26 @@ +import { Metadata } from 'next'; + import { defaultLocale, locales } from '~/i18n/locales'; -import { client, Page } from '~/lib/makeswift'; +import { client, getMakeswiftPageMetadata, Page } from '~/lib/makeswift'; interface PageParams { locale: string; rest: string[]; } +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { rest, locale } = await params; + const path = `/${rest.join('/')}`; + + const metadata = await getMakeswiftPageMetadata({ path, locale }); + + return metadata ?? {}; +} + export async function generateStaticParams(): Promise { const pages = await client.getPages().toArray(); diff --git a/core/app/[locale]/(default)/account/addresses/_actions/address-action.ts b/core/app/[locale]/(default)/account/addresses/_actions/address-action.ts index d94594b41d..19e0ce8823 100644 --- a/core/app/[locale]/(default)/account/addresses/_actions/address-action.ts +++ b/core/app/[locale]/(default)/account/addresses/_actions/address-action.ts @@ -11,21 +11,24 @@ export interface State { addresses: Address[]; lastResult: SubmissionResult | null; defaultAddress?: DefaultAddressConfiguration; - fields: Array>; } -export async function addressAction(prevState: Awaited, formData: FormData): Promise { +export async function addressAction( + fields: Array>, + prevState: Awaited, + formData: FormData, +): Promise { 'use server'; const intent = formData.get('intent'); switch (intent) { case 'create': { - return await createAddress(prevState, formData); + return await createAddress(fields, prevState, formData); } case 'update': { - return await updateAddress(prevState, formData); + return await updateAddress(fields, prevState, formData); } case 'delete': { diff --git a/core/app/[locale]/(default)/account/addresses/_actions/create-address.ts b/core/app/[locale]/(default)/account/addresses/_actions/create-address.ts index 94c7e3dc1e..6d7df51ab1 100644 --- a/core/app/[locale]/(default)/account/addresses/_actions/create-address.ts +++ b/core/app/[locale]/(default)/account/addresses/_actions/create-address.ts @@ -1,6 +1,6 @@ import { BigCommerceAPIError, BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { parseWithZod } from '@conform-to/zod'; -import { unstable_expireTag as expireTag } from 'next/cache'; +import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; @@ -196,7 +196,11 @@ function parseAddAddressInput( return inputSchema.parse(mappedInput); } -export async function createAddress(prevState: Awaited, formData: FormData): Promise { +export async function createAddress( + fields: Array>, + prevState: Awaited, + formData: FormData, +): Promise { const t = await getTranslations('Account.Addresses'); const customerAccessToken = await getSessionCustomerAccessToken(); @@ -210,7 +214,7 @@ export async function createAddress(prevState: Awaited, formData: FormDat } try { - const input = parseAddAddressInput(submission.value, prevState.fields); + const input = parseAddAddressInput(submission.value, fields); const response = await client.fetch({ document: AddCustomerAddressMutation, @@ -230,7 +234,7 @@ export async function createAddress(prevState: Awaited, formData: FormDat }; } - expireTag(TAGS.customer); + revalidateTag(TAGS.customer, { expire: 0 }); return { addresses: [ @@ -242,7 +246,6 @@ export async function createAddress(prevState: Awaited, formData: FormDat ], lastResult: submission.reply({ resetForm: true }), defaultAddress: prevState.defaultAddress, - fields: prevState.fields, }; } catch (error) { // eslint-disable-next-line no-console diff --git a/core/app/[locale]/(default)/account/addresses/_actions/delete-address.ts b/core/app/[locale]/(default)/account/addresses/_actions/delete-address.ts index 6dd1f82cfe..f1b1a1314e 100644 --- a/core/app/[locale]/(default)/account/addresses/_actions/delete-address.ts +++ b/core/app/[locale]/(default)/account/addresses/_actions/delete-address.ts @@ -1,6 +1,6 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { parseWithZod } from '@conform-to/zod'; -import { unstable_expireTag as expireTag } from 'next/cache'; +import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; @@ -78,7 +78,7 @@ export async function deleteAddress(prevState: Awaited, formData: FormDat }; } - expireTag(TAGS.customer); + revalidateTag(TAGS.customer, { expire: 0 }); return { addresses: prevState.addresses.filter( @@ -86,7 +86,6 @@ export async function deleteAddress(prevState: Awaited, formData: FormDat ), lastResult: submission.reply({ resetForm: true }), defaultAddress: prevState.defaultAddress, - fields: prevState.fields, }; } catch (error) { // eslint-disable-next-line no-console diff --git a/core/app/[locale]/(default)/account/addresses/_actions/update-address.ts b/core/app/[locale]/(default)/account/addresses/_actions/update-address.ts index 54548e9f93..0435290bae 100644 --- a/core/app/[locale]/(default)/account/addresses/_actions/update-address.ts +++ b/core/app/[locale]/(default)/account/addresses/_actions/update-address.ts @@ -1,6 +1,6 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { parseWithZod } from '@conform-to/zod'; -import { unstable_expireTag as expireTag } from 'next/cache'; +import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; @@ -209,7 +209,11 @@ function parseUpdateAddressInput( return inputSchema.parse(mappedInput); } -export async function updateAddress(prevState: Awaited, formData: FormData): Promise { +export async function updateAddress( + fields: Array>, + prevState: Awaited, + formData: FormData, +): Promise { const t = await getTranslations('Account.Addresses'); const customerAccessToken = await getSessionCustomerAccessToken(); @@ -223,7 +227,7 @@ export async function updateAddress(prevState: Awaited, formData: FormDat } try { - const input = parseUpdateAddressInput(submission.value, prevState.fields); + const input = parseUpdateAddressInput(submission.value, fields); const response = await client.fetch({ document: UpdateCustomerAddressMutation, @@ -243,7 +247,7 @@ export async function updateAddress(prevState: Awaited, formData: FormDat }; } - expireTag(TAGS.customer); + revalidateTag(TAGS.customer, { expire: 0 }); return { addresses: prevState.addresses.map((address) => @@ -251,7 +255,6 @@ export async function updateAddress(prevState: Awaited, formData: FormDat ), lastResult: submission.reply({ resetForm: true }), defaultAddress: prevState.defaultAddress, - fields: prevState.fields, }; } catch (error) { // eslint-disable-next-line no-console diff --git a/core/app/[locale]/(default)/account/settings/_actions/change-password.ts b/core/app/[locale]/(default)/account/settings/_actions/change-password.ts index 3a5df942d9..e781cc140b 100644 --- a/core/app/[locale]/(default)/account/settings/_actions/change-password.ts +++ b/core/app/[locale]/(default)/account/settings/_actions/change-password.ts @@ -3,9 +3,9 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { parseWithZod } from '@conform-to/zod'; import { getTranslations } from 'next-intl/server'; +import { z } from 'zod'; import { ChangePasswordAction } from '@/vibes/soul/sections/account-settings/change-password-form'; -import { changePasswordSchema } from '@/vibes/soul/sections/account-settings/schema'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; @@ -34,11 +34,15 @@ const CustomerChangePasswordMutation = graphql(` } `); +const schema = z.object({ + currentPassword: z.string().trim(), + password: z.string(), +}); + export const changePassword: ChangePasswordAction = async (prevState, formData) => { const t = await getTranslations('Account.Settings'); const customerAccessToken = await getSessionCustomerAccessToken(); - - const submission = parseWithZod(formData, { schema: changePasswordSchema }); + const submission = parseWithZod(formData, { schema }); if (submission.status !== 'success') { return { lastResult: submission.reply() }; diff --git a/core/app/[locale]/(default)/account/settings/_actions/update-customer.ts b/core/app/[locale]/(default)/account/settings/_actions/update-customer.ts index 8d678d562c..90f673bd55 100644 --- a/core/app/[locale]/(default)/account/settings/_actions/update-customer.ts +++ b/core/app/[locale]/(default)/account/settings/_actions/update-customer.ts @@ -2,7 +2,7 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { parseWithZod } from '@conform-to/zod'; -import { unstable_expireTag } from 'next/cache'; +import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; import { updateAccountSchema } from '@/vibes/soul/sections/account-settings/schema'; @@ -75,7 +75,7 @@ export const updateCustomer: UpdateAccountAction = async (prevState, formData) = }; } - unstable_expireTag(TAGS.customer); + revalidateTag(TAGS.customer, { expire: 0 }); return { account: submission.value, diff --git a/core/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts b/core/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts index b984b53601..2ecf52d63c 100644 --- a/core/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts +++ b/core/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts @@ -3,7 +3,7 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { SubmissionResult } from '@conform-to/react'; import { parseWithZod } from '@conform-to/zod'; -import { unstable_expireTag } from 'next/cache'; +import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; @@ -120,7 +120,7 @@ export const updateNewsletterSubscription = async ( }; } - unstable_expireTag(TAGS.customer); + revalidateTag(TAGS.customer, { expire: 0 }); return { lastResult: submission.reply(), diff --git a/core/app/[locale]/(default)/account/settings/page-data.tsx b/core/app/[locale]/(default)/account/settings/page-data.tsx index f0db7b0f6a..136ef5c991 100644 --- a/core/app/[locale]/(default)/account/settings/page-data.tsx +++ b/core/app/[locale]/(default)/account/settings/page-data.tsx @@ -35,6 +35,17 @@ const AccountSettingsQuery = graphql( newsletter { showNewsletterSignup } + customers { + passwordComplexitySettings { + minimumNumbers + minimumPasswordLength + minimumSpecialCharacters + requireLowerCase + requireNumbers + requireSpecialCharacters + requireUpperCase + } + } } } } @@ -75,6 +86,8 @@ export const getAccountSettingsQuery = cache(async ({ address, customer }: Props const customerFields = response.data.site.settings?.formFields.customer; const customerInfo = response.data.customer; const newsletterSettings = response.data.site.settings?.newsletter; + const passwordComplexitySettings = + response.data.site.settings?.customers?.passwordComplexitySettings; if (!addressFields || !customerFields || !customerInfo) { return null; @@ -85,5 +98,6 @@ export const getAccountSettingsQuery = cache(async ({ address, customer }: Props customerFields, customerInfo, newsletterSettings, + passwordComplexitySettings, }; }); diff --git a/core/app/[locale]/(default)/account/settings/page.tsx b/core/app/[locale]/(default)/account/settings/page.tsx index 6d074e9431..cad145dc6f 100644 --- a/core/app/[locale]/(default)/account/settings/page.tsx +++ b/core/app/[locale]/(default)/account/settings/page.tsx @@ -61,6 +61,7 @@ export default async function Settings({ params }: Props) { newsletterSubscriptionEnabled={newsletterSubscriptionEnabled} newsletterSubscriptionLabel={t('NewsletterSubscription.label')} newsletterSubscriptionTitle={t('NewsletterSubscription.title')} + passwordComplexitySettings={accountSettings.passwordComplexitySettings} title={t('title')} updateAccountAction={updateCustomer} updateAccountSubmitLabel={t('cta')} 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 index 46ff104696..3f8f8547da 100644 --- a/core/app/[locale]/(default)/account/wishlists/_actions/change-wishlist-visibility.ts +++ b/core/app/[locale]/(default)/account/wishlists/_actions/change-wishlist-visibility.ts @@ -63,7 +63,7 @@ export async function toggleWishlistVisibility( }; } - revalidateTag(TAGS.customer); + revalidateTag(TAGS.customer, { expire: 0 }); return { lastResult: submission.reply(), diff --git a/core/app/[locale]/(default)/account/wishlists/_actions/delete-wishlist.ts b/core/app/[locale]/(default)/account/wishlists/_actions/delete-wishlist.ts index b63ec01a89..8a5498671e 100644 --- a/core/app/[locale]/(default)/account/wishlists/_actions/delete-wishlist.ts +++ b/core/app/[locale]/(default)/account/wishlists/_actions/delete-wishlist.ts @@ -60,7 +60,7 @@ export async function deleteWishlist( }; } - revalidateTag(TAGS.customer); + revalidateTag(TAGS.customer, { expire: 0 }); // 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. diff --git a/core/app/[locale]/(default)/account/wishlists/_actions/new-wishlist.ts b/core/app/[locale]/(default)/account/wishlists/_actions/new-wishlist.ts index 1df54efdc6..dbf86eb612 100644 --- a/core/app/[locale]/(default)/account/wishlists/_actions/new-wishlist.ts +++ b/core/app/[locale]/(default)/account/wishlists/_actions/new-wishlist.ts @@ -58,7 +58,7 @@ export async function newWishlist(prevState: Awaited, formData: FormData) }; } - revalidateTag(TAGS.customer); + revalidateTag(TAGS.customer, { expire: 0 }); return { lastResult: submission.reply(), 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 index 5bee617081..39fcad84da 100644 --- a/core/app/[locale]/(default)/account/wishlists/_actions/remove-wishlist-item.ts +++ b/core/app/[locale]/(default)/account/wishlists/_actions/remove-wishlist-item.ts @@ -63,7 +63,7 @@ export async function removeWishlistItem( }; } - revalidateTag(TAGS.customer); + revalidateTag(TAGS.customer, { expire: 0 }); // 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. diff --git a/core/app/[locale]/(default)/account/wishlists/_actions/rename-wishlist.ts b/core/app/[locale]/(default)/account/wishlists/_actions/rename-wishlist.ts index 0446c4e6b0..33d7c45ecd 100644 --- a/core/app/[locale]/(default)/account/wishlists/_actions/rename-wishlist.ts +++ b/core/app/[locale]/(default)/account/wishlists/_actions/rename-wishlist.ts @@ -60,7 +60,7 @@ export async function renameWishlist( }; } - revalidateTag(TAGS.customer); + revalidateTag(TAGS.customer, { expire: 0 }); return { lastResult: submission.reply(), diff --git a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts index ec480d77b0..472c44059a 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts +++ b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts @@ -15,6 +15,7 @@ const BlogPageQuery = graphql(` author htmlBody name + path publishedDate { utc } diff --git a/core/app/[locale]/(default)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index e6bda68e8e..df2df54e73 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page.tsx +++ b/core/app/[locale]/(default)/blog/[blogId]/page.tsx @@ -5,6 +5,8 @@ import { cache } from 'react'; import { BlogPostContent, BlogPostContentBlogPost } from '@/vibes/soul/sections/blog-post-content'; import { Breadcrumb } from '@/vibes/soul/sections/breadcrumbs'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getBlogPageData } from './page-data'; @@ -18,7 +20,7 @@ interface Props { } export async function generateMetadata({ params }: Props): Promise { - const { blogId } = await params; + const { blogId, locale } = await params; const variables = cachedBlogPageDataVariables(blogId); @@ -29,12 +31,18 @@ export async function generateMetadata({ params }: Props): Promise { return {}; } + const makeswiftMetadata = await getMakeswiftPageMetadata({ path: blogPost.path, locale }); const { pageTitle, metaDescription, metaKeywords } = blogPost.seo; return { - title: pageTitle || blogPost.name, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + title: makeswiftMetadata?.title || pageTitle || blogPost.name, + ...((makeswiftMetadata?.description || metaDescription) && { + description: makeswiftMetadata?.description || metaDescription, + }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), + ...(blogPost.path && { + alternates: await getMetadataAlternates({ path: blogPost.path, locale }), + }), }; } diff --git a/core/app/[locale]/(default)/blog/page.tsx b/core/app/[locale]/(default)/blog/page.tsx index b0756183f4..8b2fe4af18 100644 --- a/core/app/[locale]/(default)/blog/page.tsx +++ b/core/app/[locale]/(default)/blog/page.tsx @@ -7,6 +7,8 @@ import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/ser 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 { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getBlog, getBlogPosts } from './page-data'; @@ -29,13 +31,18 @@ export async function generateMetadata({ params }: Props): Promise { const t = await getTranslations({ locale, namespace: 'Blog' }); const blog = await getBlog(); + const makeswiftMetadata = await getMakeswiftPageMetadata({ path: '/blog', locale }); + + const description = + makeswiftMetadata?.description || + (blog?.description && blog.description.length > 150 + ? `${blog.description.substring(0, 150)}...` + : blog?.description); return { - title: blog?.name ?? t('title'), - description: - blog?.description && blog.description.length > 150 - ? `${blog.description.substring(0, 150)}...` - : blog?.description, + title: makeswiftMetadata?.title || blog?.name || t('title'), + ...(description && { description }), + ...(blog?.path && { alternates: await getMetadataAlternates({ path: blog.path, locale }) }), }; } diff --git a/core/app/[locale]/(default)/cart/_actions/add-shipping-cost.ts b/core/app/[locale]/(default)/cart/_actions/add-shipping-cost.ts index cd6566f838..5550033fb9 100644 --- a/core/app/[locale]/(default)/cart/_actions/add-shipping-cost.ts +++ b/core/app/[locale]/(default)/cart/_actions/add-shipping-cost.ts @@ -49,7 +49,7 @@ export const addShippingCost = async ({ const result = response.data.checkout.selectCheckoutShippingOption?.checkout; - revalidateTag(TAGS.checkout); + revalidateTag(TAGS.checkout, { expire: 0 }); 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 index 87200afc1f..fd29033f17 100644 --- a/core/app/[locale]/(default)/cart/_actions/add-shipping-info.ts +++ b/core/app/[locale]/(default)/cart/_actions/add-shipping-info.ts @@ -68,7 +68,7 @@ export const addCheckoutShippingConsignments = async ({ fetchOptions: { cache: 'no-store' }, }); - revalidateTag(TAGS.checkout); + revalidateTag(TAGS.checkout, { expire: 0 }); return response.data.checkout.addCheckoutShippingConsignments?.checkout; }; @@ -135,7 +135,7 @@ export const updateCheckoutShippingConsignment = async ({ fetchOptions: { cache: 'no-store' }, }); - revalidateTag(TAGS.checkout); + revalidateTag(TAGS.checkout, { expire: 0 }); 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 index 604e9e3d5c..ed0347eb3c 100644 --- a/core/app/[locale]/(default)/cart/_actions/apply-coupon-code.ts +++ b/core/app/[locale]/(default)/cart/_actions/apply-coupon-code.ts @@ -45,7 +45,7 @@ export const applyCouponCode = async ({ checkoutEntityId, couponCode }: Props) = const checkout = response.data.checkout.applyCheckoutCoupon?.checkout; - revalidateTag(TAGS.checkout); + revalidateTag(TAGS.checkout, { expire: 0 }); return checkout; }; diff --git a/core/app/[locale]/(default)/cart/_actions/apply-gift-certificate.ts b/core/app/[locale]/(default)/cart/_actions/apply-gift-certificate.ts index b2133c65c6..23113eef8b 100644 --- a/core/app/[locale]/(default)/cart/_actions/apply-gift-certificate.ts +++ b/core/app/[locale]/(default)/cart/_actions/apply-gift-certificate.ts @@ -47,7 +47,7 @@ export const applyGiftCertificate = async ({ checkoutEntityId, giftCertificateCo const checkout = response.data.checkout.applyCheckoutGiftCertificate?.checkout; - revalidateTag(TAGS.checkout); + revalidateTag(TAGS.checkout, { expire: 0 }); return checkout; }; diff --git a/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts b/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts index 9d0ef45efa..b27a956fdd 100644 --- a/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts +++ b/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts @@ -45,7 +45,7 @@ export const removeCouponCode = async ({ checkoutEntityId, couponCode }: Props) const checkout = response.data.checkout.unapplyCheckoutCoupon?.checkout; - revalidateTag(TAGS.checkout); + revalidateTag(TAGS.checkout, { expire: 0 }); return checkout; }; diff --git a/core/app/[locale]/(default)/cart/_actions/remove-gift-certificate.ts b/core/app/[locale]/(default)/cart/_actions/remove-gift-certificate.ts index 1d358ae1a9..040d97a1fc 100644 --- a/core/app/[locale]/(default)/cart/_actions/remove-gift-certificate.ts +++ b/core/app/[locale]/(default)/cart/_actions/remove-gift-certificate.ts @@ -47,7 +47,7 @@ export const removeGiftCertificate = async ({ checkoutEntityId, giftCertificateC const checkout = response.data.checkout.unapplyCheckoutGiftCertificate?.checkout; - revalidateTag(TAGS.checkout); + revalidateTag(TAGS.checkout, { expire: 0 }); 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 f02a24dd66..fd4ee07fe8 100644 --- a/core/app/[locale]/(default)/cart/_actions/remove-item.ts +++ b/core/app/[locale]/(default)/cart/_actions/remove-item.ts @@ -1,6 +1,6 @@ 'use server'; -import { unstable_expireTag } from 'next/cache'; +import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; import { getSessionCustomerAccessToken } from '~/auth'; @@ -62,7 +62,7 @@ export async function removeItem({ await clearCartId(); } - unstable_expireTag(TAGS.cart); + revalidateTag(TAGS.cart, { expire: 0 }); return cart; } diff --git a/core/app/[locale]/(default)/cart/_actions/update-quantity.ts b/core/app/[locale]/(default)/cart/_actions/update-quantity.ts index 6a36c88c60..ebae4492ed 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-quantity.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-quantity.ts @@ -1,6 +1,6 @@ 'use server'; -import { unstable_expirePath } from 'next/cache'; +import { revalidatePath } from 'next/cache'; import { getTranslations } from 'next-intl/server'; import { getSessionCustomerAccessToken } from '~/auth'; @@ -87,7 +87,7 @@ export const updateQuantity = async ({ throw new Error(t('failedToUpdateQuantity')); } - unstable_expirePath('/cart'); + revalidatePath('/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 index bb1c1d34a0..662ba0f7a6 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts @@ -23,7 +23,7 @@ export const updateShippingInfo = async ( const t = await getTranslations('Cart.CheckoutSummary.Shipping'); const submission = parseWithZod(formData, { - schema: shippingActionFormDataSchema, + schema: shippingActionFormDataSchema({ required_error: t('countryRequired') }), }); const cartId = await getCartId(); diff --git a/core/app/[locale]/(default)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts index 83ff9ac449..c6e47636dc 100644 --- a/core/app/[locale]/(default)/cart/page-data.ts +++ b/core/app/[locale]/(default)/cart/page-data.ts @@ -19,6 +19,7 @@ export const PhysicalItemFragment = graphql(` quantity productEntityId variantEntityId + parentEntityId listPrice { currencyCode value @@ -55,6 +56,12 @@ export const PhysicalItemFragment = graphql(` } } url + stockPosition { + backorderMessage + quantityOnHand + quantityBackordered + quantityOutOfStock + } } `); @@ -71,6 +78,7 @@ export const DigitalItemFragment = graphql(` quantity productEntityId variantEntityId + parentEntityId listPrice { currencyCode value @@ -204,6 +212,13 @@ const CartPageQuery = graphql( query CartPageQuery($cartId: String, $currencyCode: currencyCode) { site { settings { + inventory { + defaultOutOfStockMessage + showOutOfStockMessage + showBackorderMessage + showQuantityOnBackorder + showQuantityOnHand + } url { checkoutUrl } diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index a3272a8be2..d997b4f43e 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -6,6 +6,7 @@ import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/car import { CartAnalyticsProvider } from '~/app/[locale]/(default)/cart/_components/cart-analytics-provider'; import { getCartId } from '~/lib/cart'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; import { Slot } from '~/lib/makeswift/slot'; import { exists } from '~/lib/utils'; @@ -27,9 +28,11 @@ export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'Cart' }); + const makeswiftMetadata = await getMakeswiftPageMetadata({ path: '/cart', locale }); return { - title: t('title'), + title: makeswiftMetadata?.title || t('title'), + description: makeswiftMetadata?.description || undefined, }; } @@ -42,7 +45,9 @@ const getAnalyticsData = async (cartId: string) => { return []; } - const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems]; + const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems].filter( + (item) => !item.parentEntityId, // Only include top-level items + ); return lineItems.map((item) => { return { @@ -65,6 +70,7 @@ export default async function Cart({ params }: Props) { setRequestLocale(locale); const t = await getTranslations('Cart'); + const tGiftCertificates = await getTranslations('GiftCertificates'); const format = await getFormatter(); const cartId = await getCartId(); @@ -99,7 +105,7 @@ export default async function Cart({ params }: Props) { ...cart.lineItems.giftCertificates, ...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems, - ]; + ].filter((item) => !('parentEntityId' in item) || !item.parentEntityId); const formattedLineItems = lineItems.map((item) => { if (item.__typename === 'CartGiftCertificate') { @@ -123,6 +129,47 @@ export default async function Cart({ params }: Props) { }; } + let inventoryMessages; + + if (item.__typename === 'CartPhysicalItem') { + if (item.stockPosition?.quantityOutOfStock === item.quantity) { + inventoryMessages = { + outOfStockMessage: data.site.settings?.inventory?.showOutOfStockMessage + ? data.site.settings.inventory.defaultOutOfStockMessage + : undefined, + }; + } else { + inventoryMessages = { + quantityReadyToShipMessage: + data.site.settings?.inventory?.showQuantityOnHand && + !!item.stockPosition?.quantityOnHand + ? t('quantityReadyToShip', { + quantity: Number(item.stockPosition.quantityOnHand), + }) + : undefined, + quantityBackorderedMessage: + data.site.settings?.inventory?.showQuantityOnBackorder && + !!item.stockPosition?.quantityBackordered + ? t('quantityOnBackorder', { + quantity: Number(item.stockPosition.quantityBackordered), + }) + : undefined, + quantityOutOfStockMessage: + data.site.settings?.inventory?.showOutOfStockMessage && + !!item.stockPosition?.quantityOutOfStock + ? t('partiallyAvailable', { + quantity: item.quantity - Number(item.stockPosition.quantityOutOfStock), + }) + : undefined, + backorderMessage: + data.site.settings?.inventory?.showBackorderMessage && + !!item.stockPosition?.quantityBackordered + ? (item.stockPosition.backorderMessage ?? undefined) + : undefined, + }; + } + } + return { typename: item.__typename, id: item.entityId, @@ -163,6 +210,7 @@ export default async function Cart({ params }: Props) { selectedOptions: item.selectedOptions, productEntityId: item.productEntityId, variantEntityId: item.variantEntityId, + inventoryMessages, }; }); @@ -190,12 +238,23 @@ export default async function Cart({ params }: Props) { label: country.name, })); + // These US states share the same abbreviation (AE), which causes issues: + // 1. The shipping API uses abbreviations, so it can't distinguish between them + // 2. React select dropdowns require unique keys, causing duplicate key warnings + const blacklistedUSStates = new Set([ + 'Armed Forces Africa', + 'Armed Forces Canada', + 'Armed Forces Middle East', + ]); + const statesOrProvinces = shippingCountries.map((country) => ({ country: country.code, - states: country.statesOrProvinces.map((state) => ({ - value: state.entityId.toString(), - label: state.name, - })), + states: country.statesOrProvinces + .filter((state) => country.code !== 'US' || !blacklistedUSStates.has(state.name)) + .map((state) => ({ + value: state.abbreviation, + label: state.name, + })), })); const showShippingForm = @@ -282,6 +341,7 @@ export default async function Cart({ params }: Props) { giftCertificateCodes: checkout?.giftCertificates.map((gc) => gc.code) ?? [], ctaLabel: t('GiftCertificate.apply'), label: t('GiftCertificate.giftCertificateCode'), + placeholder: tGiftCertificates('CheckBalance.inputPlaceholder'), removeLabel: t('GiftCertificate.removeGiftCertificate'), } : undefined diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index 79ed057c0a..33adca4aed 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -8,6 +8,8 @@ import { CompareSection } from '@/vibes/soul/sections/compare-section'; import { getSessionCustomerAccessToken } from '~/auth'; import { pricesTransformer } from '~/data-transformers/prices-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { addToCart } from './_actions/add-to-cart'; import { CompareAnalyticsProvider } from './_components/compare-analytics-provider'; @@ -41,9 +43,12 @@ export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'Compare' }); + const makeswiftMetadata = await getMakeswiftPageMetadata({ path: '/compare', locale }); return { - title: t('title'), + title: makeswiftMetadata?.title || t('title'), + ...(makeswiftMetadata?.description && { description: makeswiftMetadata.description }), + alternates: await getMetadataAlternates({ path: '/compare', locale }), }; } diff --git a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx index d56106a6ee..88c4c4fe21 100644 --- a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx @@ -4,6 +4,8 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import { GiftCertificateCheckBalanceSection } from '@/vibes/soul/sections/gift-certificate-balance-section'; import { redirect } from '~/i18n/routing'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getGiftCertificatesData } from '../page-data'; @@ -17,9 +19,15 @@ export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); + const makeswiftMetadata = await getMakeswiftPageMetadata({ + path: '/gift-certificates/balance', + locale, + }); return { - title: t('title') || 'Gift certificates - Check balance', + title: makeswiftMetadata?.title || t('title') || 'Gift certificates - Check balance', + ...(makeswiftMetadata?.description && { description: makeswiftMetadata.description }), + alternates: await getMetadataAlternates({ path: '/gift-certificates/balance', locale }), }; } diff --git a/core/app/[locale]/(default)/gift-certificates/page.tsx b/core/app/[locale]/(default)/gift-certificates/page.tsx index e0117c53f7..9f95690e45 100644 --- a/core/app/[locale]/(default)/gift-certificates/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/page.tsx @@ -4,6 +4,8 @@ import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/serve import { GiftCertificatesSection } from '@/vibes/soul/sections/gift-certificates-section'; import { redirect } from '~/i18n/routing'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getGiftCertificatesData } from './page-data'; @@ -15,9 +17,12 @@ export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); + const makeswiftMetadata = await getMakeswiftPageMetadata({ path: '/gift-certificates', locale }); return { - title: t('title') || 'Gift certificates', + title: makeswiftMetadata?.title || t('title') || 'Gift certificates', + ...(makeswiftMetadata?.description && { description: makeswiftMetadata.description }), + alternates: await getMetadataAlternates({ path: '/gift-certificates', locale }), }; } diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx b/core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx index a3106caaa9..49047ba143 100644 --- a/core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx +++ b/core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx @@ -7,7 +7,8 @@ import { getFormatter, getTranslations } from 'next-intl/server'; import { ReactNode } from 'react'; import { z } from 'zod'; -import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema'; +import { DynamicFormActionArgs } from '@/vibes/soul/form/dynamic-form'; +import { Field } from '@/vibes/soul/form/dynamic-form/schema'; import { client } from '~/client'; import { graphql, ResultOf } from '~/client/graphql'; import { ExistingResultType } from '~/client/util'; @@ -19,7 +20,6 @@ import { getPreferredCurrencyCode } from '~/lib/currency'; import { GiftCertificateSettingsFragment } from '../fragment'; interface State { - fields: Array>; lastResult: SubmissionResult | null; successMessage?: ReactNode; } @@ -41,7 +41,7 @@ const GiftCertificateSettingsQuery = graphql( const schema = ( giftCertificateSettings: ResultOf | undefined, - t: ExistingResultType, + t: ExistingResultType>, ) => { return z .object({ @@ -100,8 +100,9 @@ const schema = ( }); }; -export async function addGiftCertificateToCart( - prevState: State, +export async function addGiftCertificateToCart( + _args: DynamicFormActionArgs, + _prevState: State, formData: FormData, ): Promise { const t = await getTranslations('GiftCertificates.Purchase'); @@ -117,7 +118,7 @@ export async function addGiftCertificateToCart( }); if (submission.status !== 'success') { - return { lastResult: submission.reply(), fields: prevState.fields }; + return { lastResult: submission.reply() }; } const amountFormatted = format.number(submission.value.amount, { @@ -148,7 +149,6 @@ export async function addGiftCertificateToCart( return { lastResult: submission.reply(), - fields: prevState.fields, successMessage: t.rich('successMessage', { cartLink: (chunks) => ( @@ -168,27 +168,23 @@ export async function addGiftCertificateToCart( return message; }), }), - fields: prevState.fields, }; } if (error instanceof MissingCartError) { return { lastResult: submission.reply({ formErrors: [t('missingCart')] }), - fields: prevState.fields, }; } if (error instanceof Error) { return { lastResult: submission.reply({ formErrors: [error.message] }), - fields: prevState.fields, }; } return { lastResult: submission.reply({ formErrors: [t('unknownError')] }), - fields: prevState.fields, }; } } diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx index 7b78bed80e..29e4158c75 100644 --- a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx @@ -1,4 +1,5 @@ import { ResultOf } from 'gql.tada'; +import { Metadata } from 'next'; import { getFormatter, getTranslations } from 'next-intl/server'; import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema'; @@ -7,6 +8,7 @@ import { GiftCertificateSettingsFragment } from '~/app/[locale]/(default)/gift-c import { ExistingResultType } from '~/client/util'; import { redirect } from '~/i18n/routing'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { addGiftCertificateToCart } from './_actions/add-to-cart'; import { getGiftCertificatePurchaseData } from './page-data'; @@ -15,25 +17,34 @@ interface Props { params: Promise<{ locale: string }>; } +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + + const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); + + return { + title: t('Purchase.title'), + alternates: await getMetadataAlternates({ path: '/gift-certificates/purchase', locale }), + }; +} + function getFields( giftCertificateSettings: ResultOf, expiresAt: string | undefined, - t: ExistingResultType, + t: ExistingResultType>, ): Array> { const baseFields: Array> = [ [ { type: 'text', name: 'senderName', - label: `${t('Purchase.Form.senderNameLabel')} *`, - placeholder: t('Purchase.Form.namePlaceholder'), + label: t('Purchase.Form.senderNameLabel'), required: true, }, { type: 'email', name: 'senderEmail', - label: `${t('Purchase.Form.senderEmailLabel')} *`, - placeholder: t('Purchase.Form.emailPlaceholder'), + label: t('Purchase.Form.senderEmailLabel'), required: true, }, ], @@ -41,15 +52,13 @@ function getFields( { type: 'text', name: 'recipientName', - label: `${t('Purchase.Form.recipientNameLabel')} *`, - placeholder: t('Purchase.Form.namePlaceholder'), + label: t('Purchase.Form.recipientNameLabel'), required: true, }, { type: 'email', name: 'recipientEmail', - label: `${t('Purchase.Form.recipientEmailLabel')} *`, - placeholder: t('Purchase.Form.emailPlaceholder'), + label: t('Purchase.Form.recipientEmailLabel'), required: true, }, ], @@ -57,7 +66,6 @@ function getFields( type: 'textarea', name: 'message', label: t('Purchase.Form.messageLabel'), - placeholder: t('Purchase.Form.messagePlaceholder'), required: false, }, { @@ -83,11 +91,10 @@ function getFields( { type: 'text', name: 'amount', - label: `${t('Purchase.Form.customAmountLabel', { + label: t('Purchase.Form.customAmountLabel', { minAmount: String(giftCertificateSettings.minimumAmount.value), maxAmount: String(giftCertificateSettings.maximumAmount.value), - })} *`, - placeholder: t('Purchase.Form.customAmountPlaceholder'), + }), pattern: '^[0-9]*\\.?[0-9]+$', required: true, }, @@ -96,7 +103,7 @@ function getFields( { type: 'select', name: 'amount', - label: `${t('Purchase.Form.amountLabel')} *`, + label: t('Purchase.Form.amountLabel'), defaultValue: '0', options: [ { diff --git a/core/app/[locale]/(default)/page.tsx b/core/app/[locale]/(default)/page.tsx index 4612a77c9a..2459e2cf26 100644 --- a/core/app/[locale]/(default)/page.tsx +++ b/core/app/[locale]/(default)/page.tsx @@ -1,18 +1,32 @@ +import { Metadata } from 'next'; + import { locales } from '~/i18n/locales'; -import { Page as MakeswiftPage } from '~/lib/makeswift'; +import { getMakeswiftPageMetadata, Page as MakeswiftPage } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; interface Params { locale: string; } -export function generateStaticParams(): Params[] { - return locales.map((locale) => ({ locale })); -} - interface Props { params: Promise; } +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + const metadata = await getMakeswiftPageMetadata({ path: '/', locale }); + + return { + ...(metadata?.title != null && { title: metadata.title }), + ...(metadata?.description != null && { description: metadata.description }), + alternates: await getMetadataAlternates({ path: '/', locale }), + }; +} + +export function generateStaticParams(): Params[] { + return locales.map((locale) => ({ locale })); +} + export default async function Home({ params }: Props) { const { locale } = await params; diff --git a/core/app/[locale]/(default)/product/[slug]/_actions/add-to-cart.tsx b/core/app/[locale]/(default)/product/[slug]/_actions/add-to-cart.tsx index a3f790bf8a..57b429b99b 100644 --- a/core/app/[locale]/(default)/product/[slug]/_actions/add-to-cart.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_actions/add-to-cart.tsx @@ -77,10 +77,8 @@ export const addToCart = async ( optionEntityId: Number(field.name), optionValueEntityId: optionValueEntityId === 'true' - ? // @ts-expect-error Types from custom fields are not yet available, pending fix - Number(field.checkedValue) - : // @ts-expect-error Types from custom fields are not yet available, pending fix - Number(field.uncheckedValue), + ? Number(field.checkedValue) + : Number(field.uncheckedValue), }; if (accum.checkboxes) { diff --git a/core/app/[locale]/(default)/product/[slug]/_actions/get-more-images.ts b/core/app/[locale]/(default)/product/[slug]/_actions/get-more-images.ts new file mode 100644 index 0000000000..4d52edc9bd --- /dev/null +++ b/core/app/[locale]/(default)/product/[slug]/_actions/get-more-images.ts @@ -0,0 +1,54 @@ +'use server'; + +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; + +const MoreProductImagesQuery = graphql(` + query MoreProductImagesQuery($entityId: Int!, $first: Int!, $after: String!) { + site { + product(entityId: $entityId) { + images(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + altText + url: urlTemplate(lossy: true) + } + } + } + } + } + } +`); + +export async function getMoreProductImages( + productId: number, + cursor: string, + limit = 12, +): Promise<{ + images: Array<{ src: string; alt: string }>; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +}> { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const { data } = await client.fetch({ + document: MoreProductImagesQuery, + variables: { entityId: productId, first: limit, after: cursor }, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + }); + + const images = removeEdgesAndNodes(data.site.product?.images ?? { edges: [] }); + + return { + images: images.map((img) => ({ src: img.url, alt: img.altText })), + pageInfo: data.site.product?.images.pageInfo ?? { hasNextPage: false, endCursor: null }, + }; +} diff --git a/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts b/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts index faa4368e3d..385ab2d74f 100644 --- a/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts +++ b/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts @@ -235,7 +235,7 @@ export async function wishlistAction(payload: FormData): Promise { } } - revalidateTag(TAGS.customer); + revalidateTag(TAGS.customer, { expire: 0 }); } catch (error) { // eslint-disable-next-line no-console console.error(error); 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 15f1cd00c4..21917a35e8 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 @@ -1,4 +1,7 @@ -import DOMPurify from 'isomorphic-dompurify'; +'use client'; + +// eslint-disable-next-line import/no-named-as-default +import DOMPurify from 'dompurify'; import { useFormatter } from 'next-intl'; import { Product as ProductSchemaType, WithContext } from 'schema-dts'; diff --git a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx index 5fae6d44b7..6429de74be 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx @@ -35,6 +35,7 @@ const ReviewsQuery = graphql( product(entityId: $entityId) { reviewSummary { averageRating + numberOfReviews } reviews(first: $first, after: $after, before: $before, last: $last) { pageInfo { @@ -76,7 +77,10 @@ const getReviews = cache(async (productId: number, paginationArgs: object) => { interface Props { productId: number; searchParams: Promise; - streamableImages: Streamable>; + streamableImages: Streamable<{ + images: Array<{ src: string; alt: string }>; + pageInfo?: { hasNextPage: boolean; endCursor: string | null }; + }>; streamableProduct: Streamable>>; } @@ -161,6 +165,12 @@ export const Reviews = async ({ return { email: session?.user?.email ?? '', name: obfuscatedName }; }); + const streamableTotalCount = Streamable.from(async () => { + const product = await streamableReviewsData; + + return product?.reviewSummary.numberOfReviews ?? 0; + }); + return ( <> {(product) => diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 1959e8589f..c61334e8bc 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -112,7 +112,6 @@ export const ProductOptionsFragment = graphql( entityId displayName isRequired - isVariantOption ...MultipleChoiceFieldFragment ...CheckboxFieldFragment ...NumberFieldFragment @@ -139,6 +138,7 @@ const ProductPageMetadataQuery = graphql(` site { product(entityId: $entityId) { name + path defaultImage { altText url: urlTemplate(lossy: true) @@ -148,6 +148,7 @@ const ProductPageMetadataQuery = graphql(` metaDescription metaKeywords } + path plainTextDescription(characterLimit: 1200) } } @@ -211,7 +212,7 @@ export const getProduct = cache(async (entityId: number, customerAccessToken?: s return data.site; }); -const StreamableProductVariantBySkuQuery = graphql(` +const StreamableProductVariantInventoryBySkuQuery = graphql(` query ProductVariantBySkuQuery($productId: Int!, $sku: String!) { site { product(entityId: $productId) { @@ -247,15 +248,15 @@ const StreamableProductVariantBySkuQuery = graphql(` } `); -type VariantVariables = VariablesOf; +type VariantInventoryVariables = VariablesOf; -export const getStreamableProductVariant = cache( - async (variables: VariantVariables, customerAccessToken?: string) => { +export const getStreamableProductVariantInventory = cache( + async (variables: VariantInventoryVariables, customerAccessToken?: string) => { const { data } = await client.fetch({ - document: StreamableProductVariantBySkuQuery, + document: StreamableProductVariantInventoryBySkuQuery, variables, customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 60 } }, }); return data.site.product?.variants; @@ -275,7 +276,12 @@ const StreamableProductQuery = graphql( optionValueIds: $optionValueIds useDefaultOptionSelections: $useDefaultOptionSelections ) { - images { + entityId + images(first: 12) { + pageInfo { + hasNextPage + endCursor + } edges { node { altText @@ -306,6 +312,36 @@ const StreamableProductQuery = graphql( minPurchaseQuantity maxPurchaseQuantity warranty + ...ProductViewedFragment + ...ProductSchemaFragment + } + } + } + `, + [ProductViewedFragment, ProductSchemaFragment], +); + +type Variables = VariablesOf; + +export const getStreamableProduct = cache( + async (variables: Variables, customerAccessToken?: string) => { + const { data } = await client.fetch({ + document: StreamableProductQuery, + variables, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + }); + + return data.site.product; + }, +); + +const StreamableProductInventoryQuery = graphql( + ` + query StreamableProductInventoryQuery($entityId: Int!) { + site { + product(entityId: $entityId) { + sku inventory { hasVariantInventory isInStock @@ -320,25 +356,23 @@ const StreamableProductQuery = graphql( availabilityV2 { status } - ...ProductViewedFragment ...ProductVariantsInventoryFragment - ...ProductSchemaFragment } } } `, - [ProductViewedFragment, ProductSchemaFragment, ProductVariantsInventoryFragment], + [ProductVariantsInventoryFragment], ); -type Variables = VariablesOf; +type ProductInventoryVariables = VariablesOf; -export const getStreamableProduct = cache( - async (variables: Variables, customerAccessToken?: string) => { +export const getStreamableProductInventory = cache( + async (variables: ProductInventoryVariables, customerAccessToken?: string) => { const { data } = await client.fetch({ - document: StreamableProductQuery, + document: StreamableProductInventoryQuery, variables, customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 60 } }, }); return data.site.product; diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 8a9e65a2d4..dd0268116d 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -11,9 +11,12 @@ import { pricesTransformer } from '~/data-transformers/prices-transformer'; import { productCardTransformer } from '~/data-transformers/product-card-transformer'; import { productOptionsTransformer } from '~/data-transformers/product-options-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; import { ProductDetail } from '~/lib/makeswift/components/product-detail'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { addToCart } from './_actions/add-to-cart'; +import { getMoreProductImages } from './_actions/get-more-images'; import { submitReview } from './_actions/submit-review'; import { ProductAnalyticsProvider } from './_components/product-analytics-provider'; import { ProductSchema } from './_components/product-schema'; @@ -27,7 +30,8 @@ import { getProductPricingAndRelatedProducts, getStreamableInventorySettingsQuery, getStreamableProduct, - getStreamableProductVariant, + getStreamableProductInventory, + getStreamableProductVariantInventory, } from './page-data'; interface Props { @@ -36,7 +40,7 @@ interface Props { } export async function generateMetadata({ params }: Props): Promise { - const { slug } = await params; + const { slug, locale } = await params; const customerAccessToken = await getSessionCustomerAccessToken(); const productId = Number(slug); @@ -47,23 +51,20 @@ export async function generateMetadata({ params }: Props): Promise { return notFound(); } + const makeswiftMetadata = await getMakeswiftPageMetadata({ path: product.path, locale }); + const { pageTitle, metaDescription, metaKeywords } = product.seo; const { url, altText: alt } = product.defaultImage || {}; return { - title: pageTitle || product.name, - description: metaDescription || `${product.plainTextDescription.slice(0, 150)}...`, - keywords: metaKeywords ? metaKeywords.split(',') : null, - openGraph: url - ? { - images: [ - { - url, - alt, - }, - ], - } - : null, + title: makeswiftMetadata?.title || pageTitle || product.name, + description: + makeswiftMetadata?.description || + metaDescription || + `${product.plainTextDescription.replaceAll(/\s+/g, ' ').trim().slice(0, 150)}...`, + ...(metaKeywords && { keywords: metaKeywords.split(',') }), + alternates: await getMetadataAlternates({ path: product.path, locale }), + ...(url && { openGraph: { images: [{ url, alt }] } }), }; } @@ -117,8 +118,22 @@ export default async function Product({ params, searchParams }: Props) { const streamableProductSku = Streamable.from(async () => (await streamableProduct).sku); - const streamableProductVariant = Streamable.from(async () => { - const product = await streamableProduct; + const streamableProductInventory = Streamable.from(async () => { + const variables = { + entityId: Number(productId), + }; + + const product = await getStreamableProductInventory(variables, customerAccessToken); + + if (!product) { + return notFound(); + } + + return product; + }); + + const streamableProductVariantInventory = Streamable.from(async () => { + const product = await streamableProductInventory; if (!product.inventory.hasVariantInventory) { return undefined; @@ -129,7 +144,7 @@ export default async function Product({ params, searchParams }: Props) { sku: product.sku, }; - const variants = await getStreamableProductVariant(variables, customerAccessToken); + const variants = await getStreamableProductVariantInventory(variables, customerAccessToken); if (!variants) { return undefined; @@ -182,13 +197,16 @@ export default async function Product({ params, searchParams }: Props) { alt: image.altText, })); - return product.defaultImage - ? [{ src: product.defaultImage.url, alt: product.defaultImage.altText }, ...images] - : images; + return { + images: product.defaultImage + ? [{ src: product.defaultImage.url, alt: product.defaultImage.altText }, ...images] + : images, + pageInfo: product.images.pageInfo, + }; }); const streameableCtaLabel = Streamable.from(async () => { - const product = await streamableProduct; + const product = await streamableProductInventory; if (product.availabilityV2.status === 'Unavailable') { return t('ProductDetails.Submit.unavailable'); @@ -206,7 +224,7 @@ export default async function Product({ params, searchParams }: Props) { }); const streameableCtaDisabled = Streamable.from(async () => { - const product = await streamableProduct; + const product = await streamableProductInventory; if (product.availabilityV2.status === 'Unavailable') { return true; @@ -253,8 +271,8 @@ export default async function Product({ params, searchParams }: Props) { const streamableStockDisplayData = Streamable.from(async () => { const [product, variant, inventorySetting] = await Streamable.all([ - streamableProduct, - streamableProductVariant, + streamableProductInventory, + streamableProductVariantInventory, streamableInventorySettings, ]); @@ -343,8 +361,8 @@ export default async function Product({ params, searchParams }: Props) { const streamableBackorderDisplayData = Streamable.from(async () => { const [product, variant, inventorySetting] = await Streamable.all([ - streamableProduct, - streamableProductVariant, + streamableProductInventory, + streamableProductVariantInventory, streamableInventorySettings, ]); @@ -546,6 +564,7 @@ export default async function Product({ params, searchParams }: Props) { emptySelectPlaceholder={t('ProductDetails.emptySelectPlaceholder')} fields={productOptionsTransformer(baseProduct.productOptions)} incrementLabel={t('ProductDetails.increaseQuantity')} + loadMoreImagesAction={getMoreProductImages} prefetch={true} product={{ id: baseProduct.entityId.toString(), diff --git a/core/app/[locale]/(default)/webpages/[id]/_components/web-page.tsx b/core/app/[locale]/(default)/webpages/[id]/_components/web-page.tsx index cbf571880f..899000651b 100644 --- a/core/app/[locale]/(default)/webpages/[id]/_components/web-page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/_components/web-page.tsx @@ -5,6 +5,7 @@ export interface WebPage { title: string; content: string; breadcrumbs: Breadcrumb[]; + path: string; seo: { pageTitle: string; metaDescription: string; diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts b/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts index 346562bb88..fb3bb80777 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts +++ b/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts @@ -6,7 +6,8 @@ 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 { DynamicFormActionArgs } from '@/vibes/soul/form/dynamic-form'; +import { Field, schema } from '@/vibes/soul/form/dynamic-form/schema'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { redirect } from '~/i18n/routing'; @@ -58,18 +59,18 @@ function parseContactFormInput( } export async function submitContactForm( - prevState: { lastResult: SubmissionResult | null; fields: Array> }, + { fields }: DynamicFormActionArgs, + _prevState: { lastResult: SubmissionResult | null }, formData: FormData, ) { const t = await getTranslations('WebPages.ContactUs.Form'); const locale = await getLocale(); - const submission = parseWithZod(formData, { schema: schema(prevState.fields) }); + const submission = parseWithZod(formData, { schema: schema(fields) }); if (submission.status !== 'success') { return { lastResult: submission.reply(), - fields: prevState.fields, }; } @@ -88,7 +89,6 @@ export async function submitContactForm( if (result.errors.length > 0) { return { lastResult: submission.reply({ formErrors: result.errors.map((error) => error.message) }), - fields: prevState.fields, }; } } catch (error) { @@ -100,20 +100,17 @@ export async function submitContactForm( lastResult: submission.reply({ formErrors: error.errors.map(({ message }) => message), }), - fields: prevState.fields, }; } if (error instanceof Error) { return { lastResult: submission.reply({ formErrors: [error.message] }), - fields: prevState.fields, }; } return { lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }), - fields: prevState.fields, }; } diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx index 144381f12a..04468930b0 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx @@ -12,6 +12,8 @@ import { breadcrumbsTransformer, truncateBreadcrumbs, } from '~/data-transformers/breadcrumbs-transformer'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { WebPage, WebPageContent } from '../_components/web-page'; @@ -25,7 +27,6 @@ interface Props { interface ContactPage extends WebPage { entityId: number; - path: string; contactFields: string[]; } @@ -153,14 +154,20 @@ async function getContactFields(id: string) { } export async function generateMetadata({ params }: Props): Promise { - const { id } = await params; + const { id, locale } = await params; const webpage = await getWebPage(id); + const makeswiftMetadata = await getMakeswiftPageMetadata({ path: webpage.path, locale }); const { pageTitle, metaDescription, metaKeywords } = webpage.seo; return { - title: pageTitle || webpage.title, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + title: makeswiftMetadata?.title || pageTitle || webpage.title, + ...((makeswiftMetadata?.description || metaDescription) && { + description: makeswiftMetadata?.description || metaDescription, + }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), + ...(webpage.path && { + alternates: await getMetadataAlternates({ path: webpage.path, locale }), + }), }; } diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts b/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts index eb87a7884d..2a7ace5701 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts @@ -12,6 +12,7 @@ const NormalPageQuery = graphql( ... on NormalPage { __typename name + path ...BreadcrumbsFragment htmlBody entityId diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx index a923030446..36c0aeaf7c 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx @@ -9,6 +9,8 @@ import { breadcrumbsTransformer, truncateBreadcrumbs, } from '~/data-transformers/breadcrumbs-transformer'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { WebPageContent, WebPage as WebPageData } from '../_components/web-page'; @@ -30,6 +32,7 @@ const getWebPage = cache(async (id: string): Promise => { return { title: webpage.name, + path: webpage.path, breadcrumbs, content: webpage.htmlBody, seo: webpage.seo, @@ -57,14 +60,21 @@ async function getWebPageBreadcrumbs(id: string): Promise { } export async function generateMetadata({ params }: Props): Promise { - const { id } = await params; + const { id, locale } = await params; const webpage = await getWebPage(id); + const makeswiftMetadata = await getMakeswiftPageMetadata({ path: webpage.path, locale }); const { pageTitle, metaDescription, metaKeywords } = webpage.seo; + // Get the path from the last breadcrumb + const pagePath = webpage.breadcrumbs[webpage.breadcrumbs.length - 1]?.href; + return { - title: pageTitle || webpage.title, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + title: makeswiftMetadata?.title || pageTitle || webpage.title, + ...((makeswiftMetadata?.description || metaDescription) && { + description: makeswiftMetadata?.description || metaDescription, + }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), + ...(pagePath && { alternates: await getMetadataAlternates({ path: pagePath, locale }) }), }; } diff --git a/core/app/[locale]/(default)/wishlist/[token]/page.tsx b/core/app/[locale]/(default)/wishlist/[token]/page.tsx index 0ee9fc40bc..e6fe52378f 100644 --- a/core/app/[locale]/(default)/wishlist/[token]/page.tsx +++ b/core/app/[locale]/(default)/wishlist/[token]/page.tsx @@ -19,6 +19,7 @@ import { } from '~/components/wishlist/share-button'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { publicWishlistDetailsTransformer } from '~/data-transformers/wishlists-transformer'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { isMobileUser } from '~/lib/user-agent'; import { getPublicWishlist } from './page-data'; @@ -73,6 +74,7 @@ export async function generateMetadata({ params, searchParams }: Props): Promise return { title: wishlist?.name ?? t('title'), + alternates: await getMetadataAlternates({ path: `/wishlist/${token}`, locale }), }; } diff --git a/core/app/[locale]/layout.tsx b/core/app/[locale]/layout.tsx index 527527d619..8abcd61d25 100644 --- a/core/app/[locale]/layout.tsx +++ b/core/app/[locale]/layout.tsx @@ -36,6 +36,9 @@ const RootLayoutMetadataQuery = graphql( query RootLayoutMetadataQuery { site { settings { + url { + vanityUrl + } privacy { cookieConsentEnabled privacyPolicyUrl @@ -74,7 +77,21 @@ export async function generateMetadata(): Promise { const { pageTitle, metaDescription, metaKeywords } = data.site.settings?.seo || {}; + const vanityUrl = data.site.settings?.url.vanityUrl; + + // Use preview deployment URL so metadataBase (canonical, og:url) points at the preview, not production. + let baseUrl: URL | undefined; + const previewUrl = + process.env.VERCEL_ENV === 'preview' ? `https://${process.env.VERCEL_URL}` : undefined; + + if (previewUrl && URL.canParse(previewUrl)) { + baseUrl = new URL(previewUrl); + } else if (vanityUrl && URL.canParse(vanityUrl)) { + baseUrl = new URL(vanityUrl); + } + return { + metadataBase: baseUrl, title: { template: `%s - ${storeName}`, default: pageTitle || storeName, diff --git a/core/app/api/makeswift/[...makeswift]/route.ts b/core/app/api/makeswift/[...makeswift]/route.ts index 3ef7bc1f62..375dc590ef 100644 --- a/core/app/api/makeswift/[...makeswift]/route.ts +++ b/core/app/api/makeswift/[...makeswift]/route.ts @@ -47,4 +47,4 @@ const handler = MakeswiftApiHandler(process.env.MAKESWIFT_SITE_API_KEY, { }, }); -export { handler as GET, handler as POST }; +export { handler as GET, handler as POST, handler as OPTIONS }; diff --git a/core/client/index.ts b/core/client/index.ts index d0b059b994..d14bcb463b 100644 --- a/core/client/index.ts +++ b/core/client/index.ts @@ -1,23 +1,31 @@ import { BigCommerceAuthError, createClient } from '@bigcommerce/catalyst-client'; -import { headers } from 'next/headers'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { redirect } from 'next/navigation'; -import { getLocale as getServerLocale } from 'next-intl/server'; import { getChannelIdFromLocale } from '../channels.config'; import { backendUserAgent } from '../user-agent'; +// next/headers, next/navigation, and next-intl/server are imported dynamically +// (via `import()`) rather than statically. Static imports cause these modules to +// be evaluated during module graph resolution when next.config.ts imports this +// file, which poisons the process-wide AsyncLocalStorage context (pnpm symlinks +// create two separate singleton instances of next/headers). Dynamic imports +// defer module loading to call time, after Next.js has fully initialized. +// +// During config resolution, the dynamic import of next-intl/server succeeds but +// getLocale() throws ("not supported in Client Components") — the try/catch +// below absorbs this gracefully, and getChannelId falls back to defaultChannelId. + const getLocale = async () => { try { - const locale = await getServerLocale(); + const { getLocale: getServerLocale } = await import('next-intl/server'); - return locale; + return await getServerLocale(); } catch { /** - * Next-intl `getLocale` only works on the server, and when middleware has run. + * Next-intl `getLocale` only works on the server, and when the proxy has run. * * Instances when `getLocale` will not work: - * - Requests in middlewares + * - Requests during next.config.ts resolution + * - Requests in proxies * - Requests in `generateStaticParams` * - Request in api routes * - Requests in static sites without `setRequestLocale` @@ -45,6 +53,7 @@ export const client = createClient({ const locale = await getLocale(); if (fetchOptions?.cache && ['no-store', 'no-cache'].includes(fetchOptions.cache)) { + const { headers } = await import('next/headers'); const ipAddress = (await headers()).get('X-Forwarded-For'); if (ipAddress) { @@ -61,8 +70,10 @@ export const client = createClient({ headers: requestHeaders, }; }, - onError: (error, queryType) => { + onError: async (error, queryType) => { if (error instanceof BigCommerceAuthError && queryType === 'query') { + const { redirect } = await import('next/navigation'); + redirect('/api/auth/signout'); } }, diff --git a/core/components/header/_actions/switch-currency.ts b/core/components/header/_actions/switch-currency.ts index 93cbec4f80..b15b87d72f 100644 --- a/core/components/header/_actions/switch-currency.ts +++ b/core/components/header/_actions/switch-currency.ts @@ -64,7 +64,7 @@ export const switchCurrency = async (_prevState: SubmissionResult | null, payloa if (cartId) { await updateCartCurrency(cartId, submission.value.id) .then(() => { - revalidateTag(TAGS.cart); + revalidateTag(TAGS.cart, { expire: 0 }); }) .catch((error: unknown) => { // eslint-disable-next-line no-console diff --git a/core/components/subscribe/_actions/subscribe.ts b/core/components/subscribe/_actions/subscribe.ts index 62a82ec774..f4470c138d 100644 --- a/core/components/subscribe/_actions/subscribe.ts +++ b/core/components/subscribe/_actions/subscribe.ts @@ -35,8 +35,11 @@ export const subscribe = async ( formData: FormData, ) => { const t = await getTranslations('Components.Subscribe'); - - const submission = parseWithZod(formData, { schema }); + const subscribeSchema = schema({ + requiredMessage: t('Errors.emailRequired'), + invalidMessage: t('Errors.invalidEmail'), + }); + const submission = parseWithZod(formData, { schema: subscribeSchema }); if (submission.status !== 'success') { return { lastResult: submission.reply() }; diff --git a/core/components/wishlist/fragment.ts b/core/components/wishlist/fragment.ts index b468c91f7e..c76e458238 100644 --- a/core/components/wishlist/fragment.ts +++ b/core/components/wishlist/fragment.ts @@ -1,11 +1,25 @@ import { PaginationFragment } from '~/client/fragments/pagination'; +import { PricingFragment } from '~/client/fragments/pricing'; import { graphql } from '~/client/graphql'; -import { ProductCardFragment } from '~/components/product-card/fragment'; export const WishlistItemProductFragment = graphql( ` fragment WishlistItemProductFragment on Product { - ...ProductCardFragment + entityId + name + defaultImage { + altText + url: urlTemplate(lossy: true) + } + path + brand { + name + path + } + reviewSummary { + numberOfReviews + averageRating + } sku showCartAction inventory { @@ -14,9 +28,10 @@ export const WishlistItemProductFragment = graphql( availabilityV2 { status } + ...PricingFragment } `, - [ProductCardFragment], + [PricingFragment], ); export const WishlistItemFragment = graphql( @@ -30,7 +45,7 @@ export const WishlistItemFragment = graphql( } } `, - [WishlistItemProductFragment, ProductCardFragment], + [WishlistItemProductFragment], ); export const WishlistFragment = graphql( @@ -52,7 +67,7 @@ export const WishlistFragment = graphql( } } `, - [WishlistItemFragment, ProductCardFragment], + [WishlistItemFragment], ); export const WishlistsFragment = graphql( @@ -68,7 +83,7 @@ export const WishlistsFragment = graphql( } } `, - [WishlistFragment, ProductCardFragment, PaginationFragment], + [WishlistFragment, PaginationFragment], ); export const WishlistPaginatedItemsFragment = graphql( @@ -93,7 +108,7 @@ export const WishlistPaginatedItemsFragment = graphql( } } `, - [WishlistItemFragment, ProductCardFragment, PaginationFragment], + [WishlistItemFragment, PaginationFragment], ); export const PublicWishlistFragment = graphql( @@ -117,5 +132,5 @@ export const PublicWishlistFragment = graphql( } } `, - [WishlistItemFragment, ProductCardFragment, PaginationFragment], + [WishlistItemFragment, PaginationFragment], ); diff --git a/core/components/wishlist/modals/new.tsx b/core/components/wishlist/modals/new.tsx index a509e38d49..e5fcae488c 100644 --- a/core/components/wishlist/modals/new.tsx +++ b/core/components/wishlist/modals/new.tsx @@ -32,6 +32,7 @@ export const NewWishlistModal = ({ onChange={(e) => { defaultValue.current = e.target.value; }} + required /> {state.lastResult?.status === 'error' && (
diff --git a/core/components/wishlist/modals/rename.tsx b/core/components/wishlist/modals/rename.tsx index 4ff11fb6d6..f5c41a9df7 100644 --- a/core/components/wishlist/modals/rename.tsx +++ b/core/components/wishlist/modals/rename.tsx @@ -33,6 +33,7 @@ export const RenameWishlistModal = ({ onChange={(e) => { defaultValue.current = e.target.value; }} + required /> {state.lastResult?.status === 'error' && (
diff --git a/core/data-transformers/facets-transformer.ts b/core/data-transformers/facets-transformer.ts index 0d182a9254..fc3b1bc2b3 100644 --- a/core/data-transformers/facets-transformer.ts +++ b/core/data-transformers/facets-transformer.ts @@ -24,9 +24,13 @@ export const facetsTransformer = async ({ return allFacets.map((facet) => { const refinedFacet = refinedFacets.find((f) => f.displayName === facet.displayName); + if (refinedFacet == null) { + return null; + } + if (facet.__typename === 'CategorySearchFilter') { const refinedCategorySearchFilter = - refinedFacet?.__typename === 'CategorySearchFilter' ? refinedFacet : null; + refinedFacet.__typename === 'CategorySearchFilter' ? refinedFacet : null; return { type: 'toggle-group' as const, @@ -38,13 +42,16 @@ export const facetsTransformer = async ({ (c) => c.entityId === category.entityId, ); const isSelected = filters.categoryEntityIds?.includes(category.entityId) === true; + const disabled = refinedCategory == null && !isSelected; + const productCountLabel = disabled ? '' : ` (${category.productCount})`; + const label = facet.displayProductCount + ? `${category.name}${productCountLabel}` + : category.name; return { - label: facet.displayProductCount - ? `${category.name} (${category.productCount})` - : category.name, + label, value: category.entityId.toString(), - disabled: refinedCategory == null && !isSelected, + disabled, }; }), }; @@ -52,7 +59,7 @@ export const facetsTransformer = async ({ if (facet.__typename === 'BrandSearchFilter') { const refinedBrandSearchFilter = - refinedFacet?.__typename === 'BrandSearchFilter' ? refinedFacet : null; + refinedFacet.__typename === 'BrandSearchFilter' ? refinedFacet : null; return { type: 'toggle-group' as const, @@ -64,11 +71,16 @@ export const facetsTransformer = async ({ (b) => b.entityId === brand.entityId, ); const isSelected = filters.brandEntityIds?.includes(brand.entityId) === true; + const disabled = refinedBrand == null && !isSelected; + const productCountLabel = disabled ? '' : ` (${brand.productCount})`; + const label = facet.displayProductCount + ? `${brand.name}${productCountLabel}` + : brand.name; return { - label: facet.displayProductCount ? `${brand.name} (${brand.productCount})` : brand.name, + label, value: brand.entityId.toString(), - disabled: refinedBrand == null && !isSelected, + disabled, }; }), }; @@ -76,7 +88,7 @@ export const facetsTransformer = async ({ if (facet.__typename === 'ProductAttributeSearchFilter') { const refinedProductAttributeSearchFilter = - refinedFacet?.__typename === 'ProductAttributeSearchFilter' ? refinedFacet : null; + refinedFacet.__typename === 'ProductAttributeSearchFilter' ? refinedFacet : null; return { type: 'toggle-group' as const, @@ -92,12 +104,16 @@ export const facetsTransformer = async ({ filters.productAttributes?.some((attr) => attr.values.includes(attribute.value)) === true; + const disabled = refinedAttribute == null && !isSelected; + const productCountLabel = disabled ? '' : ` (${attribute.productCount})`; + const label = facet.displayProductCount + ? `${attribute.value}${productCountLabel}` + : attribute.value; + return { - label: facet.displayProductCount - ? `${attribute.value} (${attribute.productCount})` - : attribute.value, + label, value: attribute.value, - disabled: refinedAttribute == null && !isSelected, + disabled, }; }), }; @@ -105,7 +121,7 @@ export const facetsTransformer = async ({ if (facet.__typename === 'RatingSearchFilter') { const refinedRatingSearchFilter = - refinedFacet?.__typename === 'RatingSearchFilter' ? refinedFacet : null; + refinedFacet.__typename === 'RatingSearchFilter' ? refinedFacet : null; const isSelected = filters.rating?.minRating != null; return { @@ -119,7 +135,7 @@ export const facetsTransformer = async ({ if (facet.__typename === 'PriceSearchFilter') { const refinedPriceSearchFilter = - refinedFacet?.__typename === 'PriceSearchFilter' ? refinedFacet : null; + refinedFacet.__typename === 'PriceSearchFilter' ? refinedFacet : null; const isSelected = filters.price?.minPrice != null || filters.price?.maxPrice != null; return { @@ -136,7 +152,7 @@ export const facetsTransformer = async ({ if (facet.freeShipping) { const refinedFreeShippingSearchFilter = - refinedFacet?.__typename === 'OtherSearchFilter' && refinedFacet.freeShipping + refinedFacet.__typename === 'OtherSearchFilter' && refinedFacet.freeShipping ? refinedFacet : null; const isSelected = filters.isFreeShipping === true; @@ -158,7 +174,7 @@ export const facetsTransformer = async ({ if (facet.isFeatured) { const refinedIsFeaturedSearchFilter = - refinedFacet?.__typename === 'OtherSearchFilter' && refinedFacet.isFeatured + refinedFacet.__typename === 'OtherSearchFilter' && refinedFacet.isFeatured ? refinedFacet : null; const isSelected = filters.isFeatured === true; @@ -180,7 +196,7 @@ export const facetsTransformer = async ({ if (facet.isInStock) { const refinedIsInStockSearchFilter = - refinedFacet?.__typename === 'OtherSearchFilter' && refinedFacet.isInStock + refinedFacet.__typename === 'OtherSearchFilter' && refinedFacet.isInStock ? refinedFacet : null; const isSelected = filters.hideOutOfStock === true; diff --git a/core/data-transformers/product-card-transformer.ts b/core/data-transformers/product-card-transformer.ts index a0a6f54484..719cec9c04 100644 --- a/core/data-transformers/product-card-transformer.ts +++ b/core/data-transformers/product-card-transformer.ts @@ -5,6 +5,7 @@ import { getFormatter } from 'next-intl/server'; import { Product } from '@/vibes/soul/primitives/product-card'; import { ExistingResultType } from '~/client/util'; import { ProductCardFragment } from '~/components/product-card/fragment'; +import { WishlistItemProductFragment } from '~/components/wishlist/fragment'; import { pricesTransformer } from './prices-transformer'; @@ -46,7 +47,7 @@ const getInventoryMessage = ( }; export const singleProductCardTransformer = ( - product: ResultOf, + product: ResultOf, format: ExistingResultType, outOfStockMessage?: string, showBackorderMessage?: boolean, @@ -62,12 +63,15 @@ export const singleProductCardTransformer = ( subtitle: product.brand?.name ?? undefined, rating: product.reviewSummary.averageRating, numberOfReviews: product.reviewSummary.numberOfReviews, - inventoryMessage: getInventoryMessage(product, outOfStockMessage, showBackorderMessage), + inventoryMessage: + 'variants' in product + ? getInventoryMessage(product, outOfStockMessage, showBackorderMessage) + : undefined, }; }; export const productCardTransformer = ( - products: Array>, + products: Array>, format: ExistingResultType, outOfStockMessage?: string, showBackorderMessage?: boolean, diff --git a/core/data-transformers/product-options-transformer.ts b/core/data-transformers/product-options-transformer.ts index 3456f61005..4809735c65 100644 --- a/core/data-transformers/product-options-transformer.ts +++ b/core/data-transformers/product-options-transformer.ts @@ -18,8 +18,7 @@ export const productOptionsTransformer = async ( switch (option.displayStyle) { case 'Swatch': { return { - id: option.entityId, - persist: option.isVariantOption, + persist: true, type: 'swatch-radio-group', label: option.displayName, required: option.isRequired, @@ -33,7 +32,6 @@ export const productOptionsTransformer = async ( if (value.imageUrl) { return { type: 'image', - id: value.entityId, label: value.label, value: value.entityId.toString(), image: { src: value.imageUrl, alt: value.label }, @@ -42,7 +40,6 @@ export const productOptionsTransformer = async ( return { type: 'color', - id: value.entityId, label: value.label, value: value.entityId.toString(), color: value.hexColors[0] ?? '', @@ -53,15 +50,13 @@ export const productOptionsTransformer = async ( case 'RectangleBoxes': { return { - id: option.entityId, - persist: option.isVariantOption, + persist: true, type: 'button-radio-group', label: option.displayName, required: option.isRequired, name: option.entityId.toString(), defaultValue: values.find((value) => value.isDefault)?.entityId.toString(), options: values.map((value) => ({ - id: value.entityId, label: value.label, value: value.entityId.toString(), })), @@ -70,15 +65,13 @@ export const productOptionsTransformer = async ( case 'RadioButtons': { return { - id: option.entityId, - persist: option.isVariantOption, + persist: true, type: 'radio-group', label: option.displayName, required: option.isRequired, name: option.entityId.toString(), defaultValue: values.find((value) => value.isDefault)?.entityId.toString(), options: values.map((value) => ({ - id: value.entityId, label: value.label, value: value.entityId.toString(), })), @@ -87,15 +80,13 @@ export const productOptionsTransformer = async ( case 'DropdownList': { return { - id: option.entityId, - persist: option.isVariantOption, + persist: true, type: 'select', label: option.displayName, required: option.isRequired, name: option.entityId.toString(), defaultValue: values.find((value) => value.isDefault)?.entityId.toString(), options: values.map((value) => ({ - id: value.entityId, label: value.label, value: value.entityId.toString(), })), @@ -104,7 +95,7 @@ export const productOptionsTransformer = async ( case 'ProductPickList': { return { - persist: option.isVariantOption, + persist: true, type: 'card-radio-group', label: option.displayName, required: option.isRequired, @@ -124,8 +115,7 @@ export const productOptionsTransformer = async ( case 'ProductPickListWithImages': { return { - id: option.entityId, - persist: option.isVariantOption, + persist: true, type: 'card-radio-group', label: option.displayName, required: option.isRequired, @@ -137,7 +127,6 @@ export const productOptionsTransformer = async ( '__typename' in value && value.__typename === 'ProductPickListOptionValue', ) .map((value) => ({ - id: value.entityId, label: value.label, value: value.entityId.toString(), image: { @@ -155,8 +144,7 @@ export const productOptionsTransformer = async ( if (option.__typename === 'CheckboxOption') { return { - id: option.entityId, - persist: option.isVariantOption, + persist: true, type: 'checkbox', label: option.displayName, required: option.isRequired, @@ -169,8 +157,7 @@ export const productOptionsTransformer = async ( if (option.__typename === 'NumberFieldOption') { return { - id: option.entityId, - persist: option.isVariantOption, + persist: false, type: 'number', label: option.displayName, required: option.isRequired, @@ -187,8 +174,7 @@ export const productOptionsTransformer = async ( if (option.__typename === 'MultiLineTextFieldOption') { return { - id: option.entityId, - persist: option.isVariantOption, + persist: false, type: 'textarea', label: option.displayName, required: option.isRequired, @@ -201,8 +187,7 @@ export const productOptionsTransformer = async ( if (option.__typename === 'TextFieldOption') { return { - id: option.entityId, - persist: option.isVariantOption, + persist: false, type: 'text', label: option.displayName, required: option.isRequired, @@ -213,8 +198,7 @@ export const productOptionsTransformer = async ( if (option.__typename === 'DateFieldOption') { return { - id: option.entityId, - persist: option.isVariantOption, + persist: false, type: 'date', label: option.displayName, required: option.isRequired, diff --git a/core/data-transformers/scripts-transformer.ts b/core/data-transformers/scripts-transformer.ts index 3af032a3ee..a7d7ec32ec 100644 --- a/core/data-transformers/scripts-transformer.ts +++ b/core/data-transformers/scripts-transformer.ts @@ -19,17 +19,54 @@ const BC_TO_C15T_CONSENT_CATEGORY_MAP = { type ScriptInfo = { textContent: string } | { src: string } | null; +/** + * Extracts HTML attributes from a script tag's opening element. + * Handles both boolean attributes (async, defer) and key-value attributes (data-*, crossorigin). + * @param {string} scriptTag - The full script tag HTML string + * @returns {Record} Record of attribute name-value pairs (boolean attrs get empty string value) + */ +function extractAttributes(scriptTag: string): Record { + // Extract the opening tag content (everything between