diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 49087cd7db..a2c300d73d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @bigcommerce/team-catalyst \ No newline at end of file +* @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/scripts/prevent-invalid-changesets.js b/.github/scripts/prevent-invalid-changesets.js index c707b86651..cd451e0dbc 100644 --- a/.github/scripts/prevent-invalid-changesets.js +++ b/.github/scripts/prevent-invalid-changesets.js @@ -16,7 +16,7 @@ module.exports = async ({ core, exec }) => { const allFilenames = stdout.split("\n").filter((line) => line.trim()); const changesetFilenames = allFilenames.filter( - (file) => file.startsWith(".changeset/") && file.endsWith(".md") + (file) => file.startsWith(".changeset/") && file.endsWith(".md"), ); if (changesetFilenames.length === 0) { @@ -45,8 +45,10 @@ module.exports = async ({ core, exec }) => { } if (!fs.existsSync(filename)) { - core.setFailed(`File not found: ${filename}`); - return; + core.warning( + `File not found: ${filename}. This is likely a version PR where the changeset was already consumed. Skipping validation for this file.`, + ); + continue; } // check file size (limit to 100KB) @@ -83,18 +85,18 @@ module.exports = async ({ core, exec }) => { if (packageMatches) { const invalidPackages = packageMatches.filter( - (pkg) => pkg !== '"@bigcommerce/catalyst-makeswift"' + (pkg) => pkg !== '"@bigcommerce/catalyst-makeswift"', ); if (invalidPackages.length > 0) { core.error( `Invalid package found in changeset file. Only @bigcommerce/catalyst-makeswift is allowed.`, - { file: filename } + { file: filename }, ); core.setFailed( `File ${filename} contains invalid packages: ${invalidPackages.join( - ", " - )}` + ", ", + )}`, ); return; } diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index c56b656b50..1bd4d8228b 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -2,7 +2,7 @@ name: Basic on: push: - branches: [canary] + branches: [canary, integrations/makeswift, integrations/b2b-makeswift] pull_request: types: [opened, synchronize] merge_group: @@ -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/changesets-release.yml b/.github/workflows/changesets-release.yml index 251741f70a..38d63d889c 100644 --- a/.github/workflows/changesets-release.yml +++ b/.github/workflows/changesets-release.yml @@ -8,6 +8,12 @@ on: concurrency: ${{ github.workflow }}-${{ github.ref }} +permissions: + id-token: write + contents: write + packages: write + pull-requests: write + jobs: changesets-release: name: Changesets Release @@ -42,4 +48,3 @@ jobs: commit: "Version Packages (`${{ github.ref_name }}`)" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000..2e8edf081e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,81 @@ +name: Production Tag Deployment +env: + # 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: + - "@bigcommerce/catalyst-core@latest" + - "@bigcommerce/catalyst-makeswift@latest" + - "@bigcommerce/catalyst-b2b-makeswift@latest" +jobs: + deploy-tag: + name: Deploy `${{ github.ref_name }}` tag + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + steps: + - uses: actions/checkout@v4 + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + - name: Configure catalyst-core deployment + if: contains(github.ref_name, 'catalyst-core@') + run: | + 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: | + 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 }} $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/.gitignore b/.gitignore index 5af2d3e5cb..a0caaaaac9 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ bigcommerce-graphql.d.ts coverage/ .history .unlighthouse +.bigcommerce diff --git a/.nvmrc b/.nvmrc index 2bd5a0a98a..a45fd52cc5 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07fa2f4032..29c9e04c6f 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). @@ -26,25 +32,21 @@ Except for the additional code required to integrate with Makeswift, the `integr In order to complete the following steps, you will need to have met the following prerequisites: -- You have a remote named `origin` pointing to the [`bigcommerce/catalyst` repository on GitHub](https://github.com/bigcommerce/catalyst). If you do not, you can add it with `git remote add origin ssh://git@github.com/bigcommerce/catalyst.git`, or if you are not using SSH, you can use `git remote add origin https://github.com/bigcommerce/catalyst.git`. +- You have a remote named `origin` pointing to the [`bigcommerce/catalyst` repository on GitHub](https://github.com/bigcommerce/catalyst). - You have rights to push to the `integrations/makeswift` branch on GitHub. #### Steps -To pull the latest code from `canary` into `integrations/makeswift`, follow the steps below: - -1. Ensure your local `canary` branch is synchronized with the remote `canary` branch: +1. Fetch latest from `origin` ```bash git fetch origin - git checkout canary - git reset --hard origin/canary ``` -2. Fetch the latest code from `integrations/makeswift`: +2. Create a branch to perform a merge from `canary` ```bash - git checkout -B integrations/makeswift origin/integrations/makeswift + git checkout -B sync-integrations-makeswift origin/integrations/makeswift ``` > [!TIP] @@ -53,72 +55,92 @@ To pull the latest code from `canary` into `integrations/makeswift`, follow the > - If the local branch doesn't exist, it creates it from `origin/integrations/makeswift` > - If the local branch exists, it resets it to match `origin/integrations/makeswift` -3. Checkout a new branch from `integrations/makeswift`: - - ```bash - git checkout -b {new-branch-name} - ``` - -4. Merge `canary` into `{new-branch-name}`, and resolve merge conflicts, if necessary: +3. Merge `canary` and resolve merge conflicts, if necessary: ```bash git merge canary ``` > [!WARNING] -> There are a number of "gotchas" that you need to be aware of when merging `canary` into `integrations/makeswift`: +> **Gotchas when merging canary into integrations/makeswift:** > > - The `name` field in `core/package.json` should remain `@bigcommerce/catalyst-makeswift` > - The `version` field in `core/package.json` should remain whatever the latest published `@bigcommerce/catalyst-makeswift` version was -> - The `.changeset/` directory should not include any files that reference the `"@bigcommerce/catalyst-core"` package. If these files are merged into `integrations/makeswift`, they will cause the `Changesets Release` GitHub Action in `.github/workflows/changesets-release.yml` to fail with the error: `Error: Found changeset for package @bigcommerce/catalyst-core which is not in the workspace` -> -> _Note: A [GitHub Action is in place](.github/workflows/prevent-invalid-changesets.yml) to help prevent invalid changesets from being merged into `integrations/makeswift`. Do not merge your PR if this GitHub Action fails._ +> - The latest release in `core/CHANGELOG.md` should remain whatever the latest published `@bigcommerce/catalyst-makeswift` version was -5. After resolving any merge conflicts, open a new PR in GitHub to merge your `{new-branch-name}` into `integrations/makeswift`. This PR should be code reviewed and approved before the next steps. +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. -6. Once your PR is approved, the next step is to incorporate the merge commit from `{new-branch-name}` into `integrations/makeswift`. Do not use the merge button in the GitHub UI to merge your PR. Instead, you'll want to run the following command locally: +5. Rebase `integrations/makeswift` to establish new merge base ```bash - git checkout integrations/makeswift - git rebase {new-branch-name} + git checkout -B integrations/makeswift origin/integrations/makeswift + git rebase sync-integrations-makeswift ``` -> [!IMPORTANT] -> We have added a GitHub Ruleset to protect against this, but it's worth explicitly documenting here for posterity: It is very important that we do not "Squash and merge" or "Rebase and merge" our changes onto `integrations/makeswift`. Instead, we should either merge the PR with a traditional merge commit (the button in the GitHub PR UI should say "Merge pull request"), or locally rebase the `integrations/makeswift` branch onto the `{new-branch-name}` branch (as illustrated in the step above). Either of these options will correctly preserve the merge commit from step 4 in the history of the `integrations/makeswift` branch, which will then set the new merge base for future merges from `canary` into `integrations/makeswift`. -> -> If you are unsure whether or not you've done this correctly, you can run `git merge canary` from `integrations/makeswift` after rebasing in the step above; if you see "Already up to date.", you followed the steps correctly (with one caveat: in the case that new commits have been pushed to `canary` since the last time you merged, then you may see a new merge commit/potential conflicts for only those new commits). - -7. Push the changes up to GitHub, which will automatically close the open PR from step 5. +6. Push the changes up to GitHub: ```bash git push origin integrations/makeswift ``` -## Cutting new releases +This should close the PR in GitHub automatically. -This repository uses [Changesets](https://github.com/changesets/changesets) to manage version bumps, changelogs, and publishing to the NPM registry. Whenever you create a pull request, you should think about whether the changes you are making warrant a version bump or a changelog entry. +> [!IMPORTANT] +> Do not squash or rebase-and-merge PRs into `integrations/makeswift`. Always use a true merge commit or rebase locally (as shown below). This is to preserve the merge commit and establish a new merge base between `canary` and `integrations/makeswift`. -If you are not sure, you can ask in the PR. Here are some examples: +## Cutting New Releases -- If your pull request introduces changes to the root `README.md`: _Likely does not warrant a version bump or changelog entry, therefore your PR does not need to include a Changeset._ -- If your pull request introduces changes to `core/`, e.g., `core/app/`, or any of the packages in `packages/`: _Likely warrants a version bump and changelog entry, therefore your PR should include a Changeset._ +Catalyst uses [Changesets](https://github.com/changesets/changesets) to manage version bumps, changelogs, and publishing. Releases happen in **two stages**: -You can run the following command to create a new version bump and changelog entry: +1. Cut a release from `canary` +2. Sync that release into `integrations/makeswift` and cut again -```bash -pnpm changeset -``` +This ensures `integrations/makeswift` remains a faithful mirror of `canary` while including its additional integration code. -An interactive prompt will take you through the process of [adding your changeset](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). +#### Stage 1: Cut a release from `canary` -Once you've completed the interactive prompt, you'll see a new file in the `.changeset/` directory. This file contains the version bump and changelog entry for your changes. You should commit this file to the branch associated with your PR. +1. Begin the release process by merging the **Version Packages (`canary`)** PR. When `.changeset/` files exist on `canary`, a GitHub Action opens a **Version Packages (`canary`)** PR. This PR consolidates pending changesets, bumps versions, and updates changelogs. Merging this PR should publish new tags to GitHub, and optionally publish new package versions to NPM. -Once your PR is merged, our [GitHub Action](.github/workflows/changesets-release.yml) will handle the process of versioning and updating the changelog, (and in the case of `packages/`, publishing your changes to NPM). No further action is needed from you. +#### Stage 2: Sync and Release `integrations/makeswift` -> [!WARNING] -> It is very important that `.changeset/*.md` files targeting packages in `packages/` are not merged into the `integrations/makeswift` branch. While it is technically feasible to release packages from `integrations/makeswift`, we never want to do this. If we did this, we would need to sync the branches in the opposite direction, which was never intended to happen. -> -> _Note: A [GitHub Action is in place](.github/workflows/prevent-invalid-changesets.yml) to help prevent invalid changesets from being merged into `integrations/makeswift`. Do not merge your PR if this GitHub Action fails._ +2. Follow steps 1-6 under "[Keeping `integrations/makeswift` in sync with `canary`](#keeping-integrationsmakeswift-in-sync-with-canary)" + +3. **IMPORTANT**: After step 6, you'll need to open another PR into `integrations/makeswift` + - Ensure a local `integrations/makeswift` branch exists and is up to date (`git checkout -B integrations/makeswift origin/integrations/makeswift`) + - Run `git fetch origin` and create a new branch from `integrations/makeswift` (`git checkout -B bump-version origin/integrations/makeswift`) + - From this new `bump-version` branch, run `pnpm changeset` + - Select `@bigcommerce/catalyst-makeswift` + - For choosing between a `patch/minor/major` bump, you should copy the bump from Stage 1. (e.g., if `@bigcommerce/catalyst-core` went from `1.1.0` to `1.2.0`, choose `minor`) + - 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 + +- **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/.env.example b/core/.env.example index 3647a7c116..ffb5a872a2 100644 --- a/core/.env.example +++ b/core/.env.example @@ -40,3 +40,16 @@ TURBO_REMOTE_CACHE_SIGNATURE_KEY= # https://nextjs.org/docs/app/building-your-application/caching#data-cache # This sets a sensible revalidation target for cached requests DEFAULT_REVALIDATE_TARGET=3600 + +# OpenTelemetry Configuration (Optional) +# See OPENTELEMETRY.md for detailed setup and usage instructions +# See https://nextjs.org/docs/app/guides/open-telemetry for Next.js guide + +# Set a custom service name for your traces (defaults to 'next-app') +# OTEL_SERVICE_NAME=catalyst-storefront + +# Enable verbose tracing to see all spans (useful for debugging, increases data volume) +# NEXT_OTEL_VERBOSE=1 + +# Disable automatic fetch instrumentation (not recommended unless using custom instrumentation) +# NEXT_OTEL_FETCH_DISABLED=1 diff --git a/core/.eslintignore b/core/.eslintignore new file mode 100644 index 0000000000..9defb83ba8 --- /dev/null +++ b/core/.eslintignore @@ -0,0 +1,24 @@ +# Dependencies +node_modules/ + +# Build outputs +.next/ +.wrangler/ +.open-next/ +out/ +dist/ +build/ + +# Generated files +.turbo/ +messages/*.d.json.ts +next-env.d.ts +*-graphql.d.ts + +# Test outputs +playwright-report/ +test-results/ +.tests/ + +# Cache +.eslintcache diff --git a/core/.eslintrc.cjs b/core/.eslintrc.cjs index ee3336a73f..7e4b4b2098 100644 --- a/core/.eslintrc.cjs +++ b/core/.eslintrc.cjs @@ -82,13 +82,6 @@ const config = { }, }, ], - ignorePatterns: [ - 'client/generated/**/*.ts', - 'playwright-report/**', - 'test-results/**', - '.tests/**', - '**/google_analytics4.js', - ], }; module.exports = config; diff --git a/core/.gitignore b/core/.gitignore index 255bc859a8..f4086e9252 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -50,3 +50,7 @@ messages/*.d.json.ts # Build config build-config.json + +# OpenNext +.open-next +.wrangler diff --git a/core/AGENTS.md b/core/AGENTS.md new file mode 100644 index 0000000000..9c419ba898 --- /dev/null +++ b/core/AGENTS.md @@ -0,0 +1,280 @@ +# AGENTS.md + +## BigCommerce Catalyst Codebase Overview + +This document provides guidance for Large Language Models (LLMs) working with the BigCommerce Catalyst codebase, focusing on the **Next.js App Router application** architecture, data fetching patterns, and key design principles. + +**Catalyst is built as a Next.js App Router application** with React Server Components, enabling server-side data fetching, automatic code splitting, and optimal performance for e-commerce workloads. + +## Repository Structure + +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. + +## Proxy Architecture + +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` + +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). + +2. **Rewrites requests** to internal Next.js routes based on the resolved entity type. + +3. **Handles redirects** automatically based on BigCommerce's redirect configuration. + +This means that URLs like `/my-product-name` can resolve to `/en/product/123` internally, providing flexible URL structure while maintaining SEO-friendly paths. + +## Data Fetching and Partial Prerendering (PPR) + +### PPR Configuration + +The application uses Next.js Partial Prerendering with incremental adoption. This allows static parts of pages to be prerendered while dynamic content streams in. + +### Streamable Pattern + +The `Streamable` pattern is a core architectural concept that enables efficient data streaming and React Server Component compatibility. + +#### What is Streamable? + +```typescript +export type Streamable = T | Promise; +``` + +A `Streamable` represents data that can be either: +- **Immediate**: Already resolved data of type `T` +- **Deferred**: A Promise that will resolve to type `T` + +#### Core Streamable API + +Located in `core/vibes/soul/lib/streamable.tsx`, the Streamable system provides: + +**`Streamable.from()`** - Creates a streamable from a lazy promise factory: +```typescript +const streamableProducts = Streamable.from(async () => { + const customerToken = await getSessionCustomerAccessToken(); + const currencyCode = await getPreferredCurrencyCode(); + return getProducts(customerToken, currencyCode); +}); +``` + +**`Streamable.all()`** - Combines multiple streamables with automatic caching: +```typescript +const combined = Streamable.all([ + streamableProducts, + streamableCategories, + streamableUser +]); +``` + +**`useStreamable()`** - Hook for consuming streamables in components: +```typescript +function MyComponent({ data }: { data: Streamable }) { + const products = useStreamable(data); + return
{products.map(...)}
; +} +``` + +**`` Component** - Provides Suspense boundary for streamable data: +```tsx +}> + {(products) => } + +``` + +#### Streamable Benefits + +- **Performance**: Enables concurrent data fetching and streaming +- **Caching**: Automatic promise deduplication and stability +- **Flexibility**: Works with both sync and async data +- **Suspense Integration**: Built-in React Suspense support +- **Composition**: Easy chaining and combination of data sources + +### Data Fetching Best Practices + +1. **Use React's `cache()` function** for server-side data fetching to memoize function results and prevent repeated fetches or computations **per request** (React will invalidate the cache for all memoized functions for each server request). + +2. **Implement proper cache strategies** based on whether user authentication is present. + +3. **Leverage Streamable for progressive enhancement** where static content loads immediately and dynamic content streams in. + +## GraphQL API Client + +### Centralized Client Configuration + +All interactions with the BigCommerce Storefront GraphQL API should use the centralized GraphQL client. This client provides: + +- Automatic channel ID resolution based on locale +- Proper authentication token handling +- Request/response logging in development +- Error handling with automatic auth redirects +- IP address forwarding for personalization + +### Usage Pattern + +Always import and use the configured client rather than making direct API calls. The client handles all the necessary headers, authentication, and channel context automatically. + +## UI Design System (Vibes) + +### Architecture Overview + +The `vibes/` directory contains the **highly customizable and styleable UI layer** that is completely separate from data fetching and business logic. This separation enables: + +- **Complete visual customization** without touching data logic +- **Theme-based styling** through CSS variables +- **Reusable components** across different page contexts +- **Clear separation of concerns** between data and presentation + +### Vibes vs Pages Architecture + +**`vibes/` folder**: Contains presentation components that are meant to be highly customizable and styleable to change the UI: +- Accept `Streamable` data as props +- Handle rendering, styling, and user interactions +- Support theming through CSS variables +- No direct data fetching or business logic + +**`page.tsx` files**: Where data fetching patterns should live: +- Handle authentication and authorization +- Create `Streamable` data sources +- Transform API responses for vibes components +- Manage routing and server-side logic + +### Component Hierarchy + +``` +vibes/soul/ +├── lib/ +│ └── streamable.tsx # Streamable utilities +├── primitives/ # Basic UI components +│ ├── button/ +│ ├── product-card/ +│ └── navigation/ +└── sections/ # Complex UI sections + ├── product-list/ + ├── featured-product-carousel/ + └── footer/ +``` + +1. **Primitives** (`vibes/soul/primitives/`) - Basic reusable UI components like buttons, cards, forms. + +2. **Sections** (`vibes/soul/sections/`) - Page-level components that compose primitives into complete page sections. + +3. **Library** (`vibes/soul/lib/`) - Utility functions and patterns like the Streamable implementation. + +### Data Flow Pattern + +``` +page.tsx → Streamable data → Vibes components → User interaction +``` + +**Example Pattern:** +```typescript +// app/[locale]/(default)/page.tsx - Data fetching +export default async function HomePage({ params }: Props) { + const streamableProducts = Streamable.from(async () => { + const customerToken = await getSessionCustomerAccessToken(); + return getProducts(customerToken); + }); + + return ( + + ); +} + +// vibes/soul/sections/featured-product-list/index.tsx - Presentation +export function FeaturedProductList({ + products, + title +}: { + products: Streamable; // Accept streamable + title: string; +}) { + return ( +
+

{title}

+ }> + {(productList) => ( +
+ {productList.map(product => )} +
+ )} +
+
+ ); +} +``` + +### Import Patterns + +Components should be imported from the vibes design system using the `@/vibes/soul/` alias, maintaining clear separation between business logic in `/components` and design system components in `/vibes`. + +## App Router Data Fetching Patterns + +### Server Components by Default + +All pages are React Server Components, enabling: +- Server-side data fetching with zero client JavaScript +- Automatic code splitting and optimization +- SEO-friendly content rendering +- Direct database/API access + +### File-based Routing Structure + +``` +app/[locale]/(default)/ +├── page.tsx # Homepage with data fetching +├── layout.tsx # Shared layout components +├── product/[slug]/ +│ ├── page.tsx # Product detail page +│ └── page-data.ts # Product data fetching logic +├── (faceted)/category/[slug]/ +│ └── page.tsx # Category page +└── cart/ + └── page.tsx # Cart page +``` + +### Data Fetching Example + +```typescript +// page.tsx - Server Component with async data fetching +export default async function ProductPage({ params, searchParams }: Props) { + const { slug } = await params; + const customerAccessToken = await getSessionCustomerAccessToken(); + + // Create streamables for concurrent data loading + const streamableProduct = Streamable.from(async () => { + return getProduct(slug, customerAccessToken); + }); + + const streamableReviews = Streamable.from(async () => { + const product = await streamableProduct; // Reuses cached promise + return getProductReviews(product.id); + }); + + return ( + + ); +} +``` + +## Key Architectural Principles + +1. **App Router Architecture**: Built on Next.js App Router with React Server Components for optimal performance +2. **Routing Flexibility**: Unlike typical Next.js applications, URLs are resolved dynamically via GraphQL rather than file structure +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. **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 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/CHANGELOG.md b/core/CHANGELOG.md index a52c8193de..68c77560e3 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,10 +1,126 @@ # Changelog -## 1.1.3 +## 1.5.0 + +### Minor Changes + +- Pulls in changes from the `@bigcommerce/catalyst-core@1.5.0` release. For more information about what was included in the `@bigcommerce/catalyst-core@1.5.0` release, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob/0d951ab190c6bf1573ca7de925ecb0017d954cb7/core/CHANGELOG.md#150). + +### Patch Changes + +- [#2919](https://github.com/bigcommerce/catalyst/pull/2919) [`32be644`](https://github.com/bigcommerce/catalyst/commit/32be6443457965467fb793e524347a9e43a9338e) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Upgrade `@makeswift/runtime` to `0.26.3`, which uses the `` API instead of ``. This fixes layout shift both when loading a Makeswift page directly and when client-side navigating from a non-Makeswift page to a Makeswift page after a hard refresh. + +- [#2862](https://github.com/bigcommerce/catalyst/pull/2862) [`52207b6`](https://github.com/bigcommerce/catalyst/commit/52207b69c50f58027400f716bf18c53cac82189b) Thanks [@Codeseph](https://github.com/Codeseph)! - fix: handle OPTIONS requests via MakeswiftApiHandler + +- [#2860](https://github.com/bigcommerce/catalyst/pull/2860) [`5097034`](https://github.com/bigcommerce/catalyst/commit/5097034e93a9135b646f53445c83c8716fbeb76f) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add explicit Makeswift SEO metadata support to public-facing pages. When configured in Makeswift, the SEO title and description will take priority over the default values from BigCommerce or static translations. + + The following pages now support Makeswift SEO metadata: + - Home page (`/`) + - Catch-all page (`/[...rest]`) + - Product page (`/product/[slug]`) + - Brand page (`/brand/[slug]`) + - Category page (`/category/[slug]`) + - Blog list page (`/blog`) + - Blog post page (`/blog/[blogId]`) + - Search page (`/search`) + - Cart page (`/cart`) + - Compare page (`/compare`) + - Gift certificates page (`/gift-certificates`) + - Gift certificates balance page (`/gift-certificates/balance`) + - Contact webpage (`/webpages/[id]/contact`) + - Normal webpage (`/webpages/[id]/normal`) + + ## Migration steps + + ### Step 1: Add `getMakeswiftPageMetadata` function + + Add the `getMakeswiftPageMetadata` function to `core/lib/makeswift/client.ts`: + + ```diff + + export async function getMakeswiftPageMetadata({ path, locale }: { path: string; locale: string }) { + + const { data: pages } = await client.getPages({ + + pathPrefix: path, + + locale: normalizeLocale(locale), + + siteVersion: await getSiteVersion(), + + }); + + + + if (pages.length === 0 || !pages[0]) { + + return null; + + } + + + + const { title, description } = pages[0]; + + + + return { + + ...(title && { title }), + + ...(description && { description }), + + }; + + } + ``` + + Export the function from `core/lib/makeswift/index.ts`: + + ```diff + export { Page } from './page'; + - export { client } from './client'; + + export { client, getMakeswiftPageMetadata } from './client'; + ``` + + ### Step 2: Update page metadata + + Each page's `generateMetadata` function has been updated to fetch Makeswift metadata and use it as the primary source, falling back to existing values. Here's an example using the cart page: + + Update `core/app/[locale]/(default)/cart/page.tsx`: + + ```diff + import { getPreferredCurrencyCode } from '~/lib/currency'; + + import { getMakeswiftPageMetadata } from '~/lib/makeswift'; + import { Slot } from '~/lib/makeswift/slot'; + ``` + + ```diff + 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, + }; + } + ``` + + Apply the same pattern to the other pages listed above, using the appropriate path for each page (e.g., `/blog`, `/search`, `/compare`, etc.). + +## 1.4.2 + +### Patch Changes + +- [#2845](https://github.com/bigcommerce/catalyst/pull/2845) [`7f82903`](https://github.com/bigcommerce/catalyst/commit/7f82903074ec9831c48246aa748748aede51fe1a) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Pulls in changes from the `@bigcommerce/catalyst-core@1.4.2` patch. + +## 1.4.1 + +### Patch Changes + +- [#2839](https://github.com/bigcommerce/catalyst/pull/2839) [`db27c0f`](https://github.com/bigcommerce/catalyst/commit/db27c0f6761786acce3fa1ce9b509a526a47a931) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Pulls in changes from the `@bigcommerce/catalyst-core@1.4.1` patch. + +## 1.4.0 + +### Minor Changes + +- [#2808](https://github.com/bigcommerce/catalyst/pull/2808) [`d6e2f1b`](https://github.com/bigcommerce/catalyst/commit/d6e2f1b5f9c726ed328fde76b8bedd0b95cec72e) Thanks [@matthewvolk](https://github.com/matthewvolk)! - This release includes all changes included in the `canary` 1.4.0 release (see the 1.4.0 changelog for more details: https://github.com/bigcommerce/catalyst/blob/44c682ef988030d7500275f3e4e4503a3a1af63c/core/CHANGELOG.md#140) + +### Patch Changes + +- [#2791](https://github.com/bigcommerce/catalyst/pull/2791) [`bd30ed3`](https://github.com/bigcommerce/catalyst/commit/bd30ed3ebc73d91136190c7ba457458fed6f3eb7) Thanks [@migueloller](https://github.com/migueloller)! - Fix sort order of `additionalProducts` prop in `ProductsCarousel` Makeswift component. + +## 1.3.8 ### Patch Changes -- [#2772](https://github.com/bigcommerce/catalyst/pull/2772) [`2670f4d`](https://github.com/bigcommerce/catalyst/commit/2670f4d0837d843e425a179bff588119f689567f) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Catalyst has been upgraded to Next.js 15.5.9. This is a patch version upgrade that requires migration steps for existing stores to fix a security vulnerability. +- [#2773](https://github.com/bigcommerce/catalyst/pull/2773) [`b475a36`](https://github.com/bigcommerce/catalyst/commit/b475a3670a3a9b265e4fddddd506bfea943772cf) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Catalyst has been upgraded to Next.js 15.5.9. This is a patch version upgrade that requires migration steps for existing stores to fix a security vulnerability. ## 🔒 Security Update @@ -44,11 +160,11 @@ pnpm install ``` -## 1.1.2 +## 1.3.7 ### Patch Changes -- [#2762](https://github.com/bigcommerce/catalyst/pull/2762) [`7f3a184`](https://github.com/bigcommerce/catalyst/commit/7f3a184508acb50a09ecbdb811ec5ce34865e363) Thanks [@chanceaclark](https://github.com/chanceaclark)! - # Next.js 15.5.8 Upgrade +- [#2764](https://github.com/bigcommerce/catalyst/pull/2764) [`83c5b75`](https://github.com/bigcommerce/catalyst/commit/83c5b758a4a19e76fbb89bcea48f95a3da3f76b4) Thanks [@chanceaclark](https://github.com/chanceaclark)! - # Next.js 15.5.8 Upgrade Catalyst has been upgraded to Next.js 15.5.8. This is a patch version upgrade that requires migration steps for existing stores to fix a security vulnerability. @@ -90,988 +206,104 @@ pnpm install ``` -## 1.1.1 +## 1.3.6 ### Patch Changes -Catalyst has been upgraded to Next.js 15.5.7. This is a patch version upgrade that requires migration steps for existing stores to fix a security vulnerability. - -## 🔒 Critical Security Update - -**This upgrade addresses a critical security vulnerability ([CVE-2025-55182](https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components))** that affects React Server Components. The vulnerability allowed unauthenticated remote code execution on servers running React Server Components. This upgrade includes: -- Next.js 15.5.7 with the security patch -- React 19.1.2 and React DOM 19.1.2 with the security patch - -**All users are strongly encouraged to upgrade immediately.** - -## Key Changes -- ⚡ **Next.js 15.5.7**: Upgraded from Next.js 15.5.1-canary.4 to 15.5.7 (no more canary) -- ⚛️ **React 19**: Upgraded to React 19.1.2 and React DOM 19.1.2 -- 🔄 **Partial Prerendering (PPR) Removed**: Removed partial prerendering as it's unsupported in non-canary versions of Next.js 15. - -### ⚠️ Partial Prerendering (PPR) Removed +- [#2746](https://github.com/bigcommerce/catalyst/pull/2746) [`a0408ee`](https://github.com/bigcommerce/catalyst/commit/a0408ee75d712f30d85860d7ab41667e98fb0d5a) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Pulls in changes from the `@bigcommerce/catalyst-core@1.3.5` patch. -**Important**: PPR (Partial Prerendering) has been **removed** in this release as it's unsupported in non-canary versions of Next.js 15. -- The `ppr` experimental flag has been removed from `next.config.ts` -- Full support for Next.js 16's and it's new cache component patterns will be added in a future release -- This may result in different performance characteristics compared to the Next.js 15 + PPR setup +## 1.3.5 -## Migration Guide - -### Step 1: Update Dependencies - -If you're maintaining a custom Catalyst store, update your `package.json`: - -```json -{ - "dependencies": { - "next": "15.5.7", - "react": "^19.1.2", - "react-dom": "^19.1.2" - }, - "devDependencies": { - "@next/bundle-analyzer": "15.5.7", - "eslint-config-next": "15.5.7" - } -} -``` - -Then run: - -```bash -pnpm install -``` - -### Step 2: Update next.config.ts - -Remove or comment out PPR configuration: - -```typescript -// Remove or disable: -// experimental: { -// ppr: 'incremental', -// } -``` - -### Step 3: Remove `export const experimental_ppr` +### Patch Changes -Remove any references to `export const experimental_ppr` in your codebase as it is not being used anymore. +- [#2736](https://github.com/bigcommerce/catalyst/pull/2736) [`05f40a2`](https://github.com/bigcommerce/catalyst/commit/05f40a2f21d7e815811767e9cd346731d94ab39e) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Enable Makeswift builder to work in different environments by adding `apiOrigin` and `appOrigin` props to `ReactRuntimeProvider`. -## 1.1.0 + **Action required:** Add the following environment variables: + - `NEXT_PUBLIC_MAKESWIFT_API_ORIGIN` + - `NEXT_PUBLIC_MAKESWIFT_APP_ORIGIN` -### Minor Changes + **Deprecation notice:** `MAKESWIFT_API_ORIGIN` and `MAKESWIFT_APP_ORIGIN` are deprecated and will be removed in v1.4.0. Prefix `MAKESWIFT_API_ORIGIN` and `MAKESWIFT_APP_ORIGIN` with `NEXT_PUBLIC_` to migrate. -- [#2477](https://github.com/bigcommerce/catalyst/pull/2477) [`02af32c`](https://github.com/bigcommerce/catalyst/commit/02af32c459719f97e8973a19b6889e5fa73d0c38) Thanks [@bookernath](https://github.com/bookernath)! - Add support for Scripts API/Script Manager scripts rendering via next/script +## 1.3.4 ### Patch Changes -- [#2465](https://github.com/bigcommerce/catalyst/pull/2465) [`a438bb6`](https://github.com/bigcommerce/catalyst/commit/a438bb660bc3bd11adacd125769ba99ba2e1c38d) Thanks [@bookernath](https://github.com/bookernath)! - Bump next to 15.4.0-canary.114 to fix issue with PDPs 500ing on Docker builds +- [#2723](https://github.com/bigcommerce/catalyst/pull/2723) [`dbafb31`](https://github.com/bigcommerce/catalyst/commit/dbafb317fc27c976ae48f23ee4645c9980253a3a) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Noop release to account for typecheck issue. -- [#2474](https://github.com/bigcommerce/catalyst/pull/2474) [`989bf97`](https://github.com/bigcommerce/catalyst/commit/989bf974c534a7201782ace9a4bf3fe745e8af01) Thanks [@bookernath](https://github.com/bookernath)! - Respect min/max purchase quantity from API in quantity selector +## 1.3.3 -- [#2464](https://github.com/bigcommerce/catalyst/pull/2464) [`474f960`](https://github.com/bigcommerce/catalyst/commit/474f960c4c428e28874022b36ae2b03e0b301e20) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove edge runtime declarations to be able to run Catalyst with OpenNext. +### Patch Changes -- [#2468](https://github.com/bigcommerce/catalyst/pull/2468) [`8b64931`](https://github.com/bigcommerce/catalyst/commit/8b6493156a70490c0c35c35d45ebd9ad8f23615c) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. +- [#2718](https://github.com/bigcommerce/catalyst/pull/2718) [`a66b9ae`](https://github.com/bigcommerce/catalyst/commit/a66b9ae11083bf56135a3c3a00d6974ae2c86593) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Pulls in changes from the @bigcommerce/catalyst-core@1.3.3 patch. -## 1.0.1 +## 1.3.2 ### Patch Changes -- [#2448](https://github.com/bigcommerce/catalyst/pull/2448) [`e4444a2`](https://github.com/bigcommerce/catalyst/commit/e4444a2ca83b5b73776c842feff56e47f57344dc) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Fixes an issue where the anonymous session wasn't getting cleared after an actual session was established. +- [`6c94dec`](https://github.com/bigcommerce/catalyst/commit/6c94dece0f72f2bc74ef67b456f2522475e2f129) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Pulls in changes from the `@bigcommerce/catalyst-core@1.3.2` patch. -## 1.0.0 - -### Major Changes +## 1.3.1 -- [`6b17bdb`](https://github.com/bigcommerce/catalyst/commit/6b17bdb) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Introduce Soul VIBE UI library to the repository. +### Patch Changes -- Added a collection of reusable primitives with modern styles -- Prebuilt sections and page templates that are easy to use -- Fast performance and modern patterns leveraging the latest features of Next.js -- Easy customization to best represent your brand -- Utilize @conform-to/react for progressively enhanced HTML forms +- [#2685](https://github.com/bigcommerce/catalyst/pull/2685) [`bf176c2`](https://github.com/bigcommerce/catalyst/commit/bf176c28bddc17455cab2d217156dc1822e1e86f) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Includes all changes included in the [1.3.1 release](https://github.com/bigcommerce/catalyst/blob/61183ca1cd2957a2ebc114aa1b15a9d5184c8966/core/CHANGELOG.md#131) of `@bigcommerce/catalyst-core` -Join the discussion [here](https://github.com/bigcommerce/catalyst/discussions/1861) for more details of this major milestone for Catalyst! +## 1.3.0 ### Minor Changes -- [`589c91a`](https://github.com/bigcommerce/catalyst/commit/589c91a) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Enable cart restoration on non-persistent cart logouts. - -**Migration** - -Update the logout mutation to include the `cartEntityId` variable + the `cartUnassignResult` node and make sure the `client.fetch` method contains the new variable. - -```diff --mutation LogoutMutation { -+mutation LogoutMutation($cartEntityId: String) { -- logout { -+ logout(cartEntityId: $cartEntityId) { - result -+ cartUnassignResult { -+ cart { -+ entityId -+ } -+ } - } -} -``` - -- [`32a28b9`](https://github.com/bigcommerce/catalyst/commit/32a28b9) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Use isomorphic-dompurify to santize any sort of shopper supplied input. - -- [`f039b2c`](https://github.com/bigcommerce/catalyst/commit/f039b2c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Properly handle `BigCommerceGQLError` in actions, by returning the error messages from the request. - -- [`dd66f96`](https://github.com/bigcommerce/catalyst/commit/dd66f96) Thanks [@matthewvolk](https://github.com/matthewvolk)! - In order to maintain parity with Stencil's 404 page, we wanted to allow the user to search from the 404 page. Since the search included with the header component is fully featured, we included a CTA to open the same search that you get when clicking the search icon in the header. - -**Migration** - -Most changes are additive, so they should hopefully be easy to resolve if flagged for merge conflicts. Change #3 below replaces the Search state with the new search context, be sure to pay attention to the new - -1. This change adds a new directory under `core/` called `context/` containing a `search-context.tsx` file. Since this is a new file, there shouldn't be any merge conflicts -2. `SearchProvider` is imported into `core/app/providers` and replaces the React fragment (`<>`) that currently wraps `` and `{children}` -3. In `core/vibes/soul/primitives/navigation`, replace `useState` with `useSearch` imported from the new context file, and update the dependency arrays for the `useEffect`'s in the `Navigation component` -4. Add search `Button` that calls `setIsSearchOpen(true)` to the `NotFound` component in `core/vibes/sections/not-found/index.tsx` - -- [`62b891c`](https://github.com/bigcommerce/catalyst/commit/62b891c) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Adds support for nested web page children / trees. Restructure web page routing to support a layout file. - -- [`44342ee`](https://github.com/bigcommerce/catalyst/commit/44342ee) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Sets a default session when any user first visits the page. - -- [`ff57b8a`](https://github.com/bigcommerce/catalyst/commit/ff57b8a) Thanks [@eugene(yevhenii)kuzmenko]()! - Pass analytics cookies to checkout mutation to preserve the analytics session whenever shopper redirects to the external checkout - -- [`067d5a4`](https://github.com/bigcommerce/catalyst/commit/067d5a4) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Move the anonymous session into it's own cookie, separate from Auth.js in order to have better non-persistent cart support. - -**Migration** - -If you were using `await signIn('anonymous', { redirect: false });`, you'll need to migrate over to using the `await anonymousSignIn()` function. Otherwise, we am only changing the underlying logic in existing API's so pulling in the changes should immediately pick this up. - -- [`9b3541d`](https://github.com/bigcommerce/catalyst/commit/9b3541d) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Adds a new analytics provider meant to replace the other provider. This provider is built being framework agnostic but exposes a react provider to use within context. The initial implementation comes with a Google Analytics provider with some basic events to get started. We need to add some other events around starting checkout, banners, consent loading, and search. This change is additive only so no migration is needed until consumption. - -- [`bd3bc8b`](https://github.com/bigcommerce/catalyst/commit/bd3bc8b) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Implement the new analytics provider, utilizing the GoogleAnalytics provider as the first analytics solution. - -Most changes are additive so merge conflicts should be easy to resolve. In order to use the new provider from the previous provider, if it's already not setup in the BigCommerce control panel for checkout analytics, you'll need to add the GA4 property ID. This will automatically be used by the new GoogleAnalytics provider. - -- [`70afa5a`](https://github.com/bigcommerce/catalyst/commit/70afa5a) Thanks [@eugene(yevhenii)kuzmenko]()! - Dispatch Visit started and Product Viewed analytics events - -- [`da2a462`](https://github.com/bigcommerce/catalyst/commit/da2a462) Thanks [@bookernath](https://github.com/bookernath)! - Add currency selector to header - -- [`f3b4d90`](https://github.com/bigcommerce/catalyst/commit/f3b4d90) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add Wishlist account pages and public wishlist page - -- [`59ff1ce`](https://github.com/bigcommerce/catalyst/commit/59ff1ce) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Fetches the stores URLs on build which can remove the need of setting NEXT_PUBLIC_BIGCOMMERCE_CDN_HOSTNAME. The environment variable is still provided in case customization is needed. - -- [`a0e6425`](https://github.com/bigcommerce/catalyst/commit/a0e6425) Thanks [@eugene(yevhenii)kuzmenko]()! - Adds analytics cookies needed for native analytics. - -This is a add-only change, so migration should be as simple as pulling in the new code. - -- [`a601f7e`](https://github.com/bigcommerce/catalyst/commit/a601f7e) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes compare for caching and the eventual use of dynamicIO. +- [#2678](https://github.com/bigcommerce/catalyst/pull/2678) [`f121097`](https://github.com/bigcommerce/catalyst/commit/f1210973265b481ec964a71848304f2ca1931162) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Synced the changes made on `canary`. See the 1.3.0 changelog for more details: https://github.com/bigcommerce/catalyst/releases/tag/%40bigcommerce%2Fcatalyst-core%401.3.0 -**Key modifications include:** - -- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. -- Use `Streamable.from` to generate our streaming props that are passed to our UI components. - -**Migration instructions:** - -- Updated `/app/[locale]/(default)/compare/page.tsx` to use `Streamable.from` pattern. -- Renamed `getCompareData` query to `getComparedProducts`. - - Updated query - - Returns empty `[]` if no product ids are passed - -- [`c6e38a6`](https://github.com/bigcommerce/catalyst/commit/c6e38a6) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Reorganize and cleanup files: -- Moved `core/context/search-context` to `core/lib/search`. -- Moved `core/client/mutations/add-cart-line-item.ts` and `core/client/mutations/create-cart.ts` into `core/lib/cart/*`. -- Removed `core/client/queries/get-cart.ts` in favor of a smaller, more focused query within `core/lib/cart/validate-cart.ts`. - -**Migration** - -- Replace imports from `~/context/search-context` to `~/lib/search`. -- Replace imports from `~/client/mutations/` to `~/lib/cart/`. -- Remove any direct imports from `~/client/queries/get-cart.ts` and use the new `validate-cart.ts` query instead. If you need the previous `getCart` function, you can copy it from the old file and adapt it to your needs. - -- [`7b3b81c`](https://github.com/bigcommerce/catalyst/commit/7b3b81c) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Replaces the REST-powered `client.fetchShippingZones` method with a GraphQL-powered query containing the `site.settings.shipping.supportedShippingDestinations` field. - -**Migration:** - -1. The return type of `getShippingCountries` has the same shape as the `Country` BigCommerce GraphQL type, so you should be able to copy the graphql query from `core/app/[locale]/(default)/cart/page-data.ts` into your project and replace the existing `getShippingCountries` method in there. -2. Remove the argument `data.geography` from the `getShippingCountries` invocation in `core/app/[locale]/(default)/cart/page.tsx` -3. Finally, you should be able to delete the file `core/client/management/get-shipping-zones.ts` assuming it is no longer referenced anywhere in `core/` - -- [`53e0b5e`](https://github.com/bigcommerce/catalyst/commit/53e0b5e) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes category PLP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. - -**Key modifications include:** - -- We don't stream in Category page data, instead it's a blocking call that will redirect to `notFound` when category is not found. Same for metadata. -- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. -- Use `Streamable.from` to generate our streaming props that are passed to our UI components. -- Remove use of nuqs' `createSearchParamsCache` in favor of nuqs' `createLoader`. - -**Migration instructions:** - -- Update `/(facted)/category/[slug]/page.tsx` - - For this page we are now doing a blocking request for category page data. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. We still stream in filter and product data. -- Update `/(facted)/category/[slug]/page-data.tsx` - - Request now accept `customerAccessToken` as a prop instead of calling internally. -- Update`/(facted)/category/[slug]/fetch-compare-products.ts` - - Request now accept `customerAccessToken` as a prop instead of calling internally. -- Update `/(faceted)/fetch-faceted-search.ts` - - Request now accept `customerAccessToken` and `currencyCode` as a prop instead of calling internally. - -- [`537db2c`](https://github.com/bigcommerce/catalyst/commit/537db2c) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Add the ability to redirect from the login page. Developers can now append a relative path to the `?redirectTo=` query param on the `/login` page. When a shopper successfully logs in, it'll redirect them to the given relative path. Defaults to `/account/orders` to prevent a breaking change. - -- [`b20dfb0`](https://github.com/bigcommerce/catalyst/commit/b20dfb0) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Adds an eslint rule to import expect and test from ~/tests/fixtures instead of the @playwright/test module. This is to create a more consistent testing experience across the codebase. - -**Migration** - -Any import statements that import `expect` and `test` from `@playwright/test` should be updated to import from `~/tests/fixtures` instead. All other imports from `@playwright/test` should remain unchanged. - -```diff --import { expect, type Page, test } from '@playwright/test'; -+import { type Page } from '@playwright/test'; -+ -+import { expect, test } from '~/tests/fixtures'; -``` - -- [`f0464a8`](https://github.com/bigcommerce/catalyst/commit/f0464a8) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Drops CSS support for Safari < 15 due to those versions only having 0.09% global usage. - -- [`1d6cf64`](https://github.com/bigcommerce/catalyst/commit/1d6cf64) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Render address fields for customer registration form. - -- [`42ded4a`](https://github.com/bigcommerce/catalyst/commit/42ded4a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes home page, header, and footer for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. - -**Key modifications include:** - -- Header and Footer now have a blocking request for the shared data that is the same for all users. -- Data that can change for logged in users is now a separate request. -- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. -- Dynamic fetches (using customerAccessToken or preferred currency) are now all streaming queries. -- Use `Streamable.from` to generate our streaming props that are passed to our UI components. -- Update Header UI component to allow streaming in of currencies data. - -**Migration instructions:** - -- Renamed `/app/[locale]/(default)/query.ts` to `/app/[locale]/(default)/page-data.ts`, include page query on this page. -- Updated `/app/[locale]/(default)/page.ts` to use `Streamable.from` pattern. -- Split data that can vary by user from `core/components/footer/fragment.ts` and `core/components/header/fragment.ts` -- Updated `core/components/header/index.tsx` and `core/components/footer/index.tsx` to fetch shared data in a blocking request and pass data that varies by customer as streamable data. Updated to use the new `Streamable.from` pattern. -- [`061063f`](https://github.com/bigcommerce/catalyst/commit/061063f) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes search PLP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. - -**Key modifications include:** - -- We don't stream in Search page data, instead it's a blocking call to get page data. -- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. -- Use `Streamable.from` to generate our streaming props that are passed to our UI components. -- Remove use of nuqs' `createSearchParamsCache` in favor of nuqs' `createLoader`. - -**Migration instructions:** - -- Update `/(facted)/search/page.tsx` - - For this page we are now doing a blocking request for brand page data. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. We still stream in filter and product data. - -- [`da2a462`](https://github.com/bigcommerce/catalyst/commit/da2a462) Thanks [@bookernath](https://github.com/bookernath)! - Adds the ability to redirect after logout. - -- [`863d744`](https://github.com/bigcommerce/catalyst/commit/863d744) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Removes the old analytics provider in favor of the provider that fetches the configuration from the GraphQL API. - -- [`061063f`](https://github.com/bigcommerce/catalyst/commit/061063f) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes brand PLP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. - -**Key modifications include:** - -- We don't stream in Brand page data, instead it's a blocking call that will redirect to `notFound` when brand is not found. Same for metadata. -- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. -- Use `Streamable.from` to generate our streaming props that are passed to our UI components. -- Remove use of nuqs' `createSearchParamsCache` in favor of nuqs' `createLoader`. - -**Migration instructions:** - -- Update `/(facted)/brand/[slug]/page.tsx` - - For this page we are now doing a blocking request for brand page data. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. We still stream in filter and product data. -- Update `/(facted)/brand/[slug]/page-data.tsx` - - Request now accept `customerAccessToken` as a prop instead of calling internally. +- [#2593](https://github.com/bigcommerce/catalyst/pull/2593) [`9e2f992`](https://github.com/bigcommerce/catalyst/commit/9e2f992a3f6b6e3fcd45a823343859403cda9542) Thanks [@hunterbecton](https://github.com/hunterbecton)! - Create alias for variables for button and button-link components ### Patch Changes -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`c73b57e`](https://github.com/bigcommerce/catalyst/commit/c73b57e) Thanks [@migueloller](https://github.com/migueloller)! - Use `setRequestLocale` in all pages and layouts and pass `locale` parameter to `getTranslations` in all `generateMetadata` to maximize static rendering. This is part of the ongoing work in preparation of enabling PPR and `dynamicIO` for all routes. - -- [`d70596e`](https://github.com/bigcommerce/catalyst/commit/d70596e) Thanks [@alanpledger](https://github.com/alanpledger)! - Fixes types for signIn credentials and improves error handling for registering a customer. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Applied streamable pattern to Cart. - -- [`54ee390`](https://github.com/bigcommerce/catalyst/commit/54ee390) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove unecessary `fetchOptions` in object that has nothing to do with a client request. - -- [`ab1f0a0`](https://github.com/bigcommerce/catalyst/commit/ab1f0a0) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add wishlist support to product display pages - -**Migration** - -- Ensure WishlistButton component is passed to additionalActions prop on ProductDetail -- Ensure WishlistButtonForm is used on product page - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add persistent cart support - -- [`27b2823`](https://github.com/bigcommerce/catalyst/commit/27b2823) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix issue where delete button is not displayed if you have only 1 address - -**Migration steps:** - -Update `/core/app/[locale]/(default)/account/addresses/page.tsx` and pass the `minimumAddressCount={0}` prop to the AddressListSection component. - -Example: - -```tsx -return ( - -); -``` - -- [`0779856`](https://github.com/bigcommerce/catalyst/commit/0779856) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Adds Tailwind classes used to style the checkbox input and label based on the disabled state of the checkbox. - -**Migration:** - -Since this is a one-file change, you should be able to simply grab the diff from [this PR](https://github.com/bigcommerce/catalyst/pull/2399). The main changes to note are that we are [adding a `peer` class](https://v3.tailwindcss.com/docs/hover-focus-and-other-states#styling-based-on-sibling-state) to the CheckboxPrimitive.Root, explicitly styling the `enabled` pseudoclass, and only applying hover styles when the checkbox is enabled. - -- [`604450d`](https://github.com/bigcommerce/catalyst/commit/604450d) Thanks [@bookernath](https://github.com/bookernath)! - Re-apply auth grouping approach with middleware exemption to preserve functionality of /login/token endpoint for Customer Login API - -- [`82290cd`](https://github.com/bigcommerce/catalyst/commit/82290cd) Thanks [@migueloller](https://github.com/migueloller)! - Upgrade `next-intl` to v4 and add strong types for translated messages via TypeScript type augmentation. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Clean up 'en' dictionary. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove unused dependencies. - -- [`6b0c85a`](https://github.com/bigcommerce/catalyst/commit/6b0c85a) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Remove unused search props, add missing search translations - -**Migration** - -`core/components/header/index.tsx` - -Ensure the following props are passed to the `HeaderSection` navigation prop: - -```tsx - searchInputPlaceholder: t('Search.inputPlaceholder'), - searchSubmitLabel: t('Search.submitLabel'), -``` - -`core/messages/en.json` - -Add the following keys to the `Components.Header.Search` translations: - -```json - "somethingWentWrong": "Something went wrong. Please try again.", - "inputPlaceholder": "Search products, categories, brands...", - "submitLabel": "Search" -``` - -`core/vibes/soul/primitives/navigation/index.tsx` - -Copy all changes from this file: - -1. Create `searchSubmitLabel?: string;` property, ensure it is passed into `SearchForm` -2. On the `SearchForm`, remove the `searchCtaLabel = 'View more',` property, as it is unused, and rename `submitLabel` to `searchSubmitLabel` -3. Ensure that `SearchForm` passes `searchSubmitLabel` to the `SearchButton`: `` -4. Remove the `searchCtaLabel` property from the `SearchResults` component - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Format totalCount value for i18n. - -- [`dd42b25`](https://github.com/bigcommerce/catalyst/commit/dd42b25) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Fix the faceted search pages to account for facets with spaces or other special characters in the name. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add date field to product details form. - -- [`d9685ee`](https://github.com/bigcommerce/catalyst/commit/d9685ee) Thanks [@bookernath](https://github.com/bookernath)! - Remove featured products panel from 404 page, allowing the page to be static in preparation for adding a search box - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`8baf8b3`](https://github.com/bigcommerce/catalyst/commit/8baf8b3) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Memoize `GetCartCountQuery` using React.js `cache()` so that it only hits the GraphQL API once per render, instead of twice. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add shipping selection to checkout. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`6401bb2`](https://github.com/bigcommerce/catalyst/commit/6401bb2) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update ProductListSection's and ReviewsSection's `totalCount` prop to string. - -- [`43351ab`](https://github.com/bigcommerce/catalyst/commit/43351ab) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Dedupe requests in various pages by properly caching/memoizing the function per page render. - -- [`b19ee74`](https://github.com/bigcommerce/catalyst/commit/b19ee74) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Updates `SelectField` to have a hidden input to pass the value of the select to the form. This is a workaround for a [Radix Select issue](https://github.com/radix-ui/primitives/issues/3198) that auto selects the first option in the select when submitting a form (even when no selection has been made). - -Additionally, fixes an issue of incorrectly adding an empty query param for product options when an option is empty. - -**Migration** - -Migration is straighforward and requires adding the hidden input to the component and renaming the `name` prop for the `Select` component to something temporary. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`d663741`](https://github.com/bigcommerce/catalyst/commit/d663741) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Revert UI changes for product form since streaming in fields causes an issue with the form. +- [#2628](https://github.com/bigcommerce/catalyst/pull/2628) [`d52d6f6`](https://github.com/bigcommerce/catalyst/commit/d52d6f67deef8b957040ee40ee16ca6563510f7c) Thanks [@arvinpoddar](https://github.com/arvinpoddar)! - Switch to using `ReactRuntimeCore` instead of `ReactRuntime`, avoiding the bundling of unused dependencies from some Makeswift builtin components. Also bumps to latest `@makeswift/runtime`. -- [`7bc57c8`](https://github.com/bigcommerce/catalyst/commit/7bc57c8) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Set a min-height for the Navigation fallback skeleton to prevent layout shift. +- [#2621](https://github.com/bigcommerce/catalyst/pull/2621) [`eb2d4e1`](https://github.com/bigcommerce/catalyst/commit/eb2d4e16e61c5822111fa50d8bc27db86cd5e9c4) Thanks [@agurtovoy](https://github.com/agurtovoy)! - Fix locale switcher on localized Makeswift pages with different paths -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. +## 1.2.0 -- [`c70bff2`](https://github.com/bigcommerce/catalyst/commit/c70bff2) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Dedupe requests in "webpages" by properly caching/memoizing the fetch function per page render. - -- [`5a853c2`](https://github.com/bigcommerce/catalyst/commit/5a853c2) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Check for `error.type` instead of `error.name` auth error in Login, since `error.name` gets minified in production and the check never returns `true`. Additionally, add a check for the `cause.err` to be of type `BigcommerceGQLError`. - -**Migration:** - -- Change `error.name === 'CallbackRouteError'` to `error.type === 'CallbackRouteError'` check in the error handling of the login action and include `error.cause.err instanceof BigCommerceGQLError`. - -- [`fada842`](https://github.com/bigcommerce/catalyst/commit/fada842) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Adds the `__Secure-` prefix to the add additional broswer security policies around this cookie. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`976c74d`](https://github.com/bigcommerce/catalyst/commit/976c74d) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix blog post card date formatting on alternate locales - -**Migration** - -`core/vibes/soul/primitives/blog-post-card/index.tsx` - -Update the component to use `` for the date, instead of calling `new Date(date).toLocaleDateString(...)`. - -- [`9176f56`](https://github.com/bigcommerce/catalyst/commit/9176f56) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix possibility of duplicate `key` error in Breadcrumbs component for truncated breadcrumbs. - -**Migration** - -Update `core/vibes/soul/sections/breadcrumbs/index.tsx` to use `index` as the `key` property instead of `href` - -- [`9827e4c`](https://github.com/bigcommerce/catalyst/commit/9827e4c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Translate home breadcrumb in Contact Us page. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`48d5c99`](https://github.com/bigcommerce/catalyst/commit/48d5c99) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix public wishlist analytics/server error - -- Add translation key for a Publish Wishlist empty state - -**Migration** - -1. Add the following imports to `core/app/[locale]/(default)/wishlist/[token]/page.tsx`: - -```tsx -import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { WishlistAnalyticsProvider } from '~/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider'; -``` - -2. Add the following function into the file: - -```tsx -const getAnalyticsData = async (token: string, searchParamsPromise: Promise) => { - const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlist = await getPublicWishlist(token, searchParamsParsed); - - if (!wishlist) { - return []; - } - - return removeEdgesAndNodes(wishlist.items) - .map(({ product }) => product) - .filter((product) => product !== null) - .map((product) => { - return { - id: product.entityId, - name: product.name, - sku: product.sku, - brand: product.brand?.name ?? '', - price: product.prices?.price.value ?? 0, - currency: product.prices?.price.currencyCode ?? '', - }; - }); -}; -``` - -3. Wrap the component in the `WishlistAnalyticsProvider`: - -```tsx -export default async function PublicWishlist({ params, searchParams }: Props) { - // ... - return ( - getAnalyticsData(token, searchParams))}> - // ... - - ); -} -``` - -4. Update `/core/messages/en.json` "PublishWishlist" to have translations: - -```json - "PublicWishlist": { - "title": "Public Wish List", - "defaultName": "Public wish list", - "emptyWishlist": "This wish list doesn't have any products yet." - }, -``` - -5. Update `WishlistDetails` component to accept the `emptyStateText` and `placeholderCount` props: - -```tsx -// ... -export const WishlistDetails = ({ - className = '', - wishlist: streamableWishlist, - emptyStateText, - paginationInfo, - headerActions, - prevHref, - placeholderCount, - action, - removeAction, -}: Props) => { -``` - -6. Update `WishlistDetails` component to pass the `emptyStateText` and `placeholderCount` props to both the `WishlistDetailSkeleton` and `WishlistItems` components: - -```tsx - -``` - -```tsx - -``` - -- [`1147a9e`](https://github.com/bigcommerce/catalyst/commit/1147a9e) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Deduplicate default image in the image gallery in PDP. - -- [`47b3ad0`](https://github.com/bigcommerce/catalyst/commit/47b3ad0) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fix an issue with orders with deleted products throwing an error and stopping page render by settings the errorPolicy for requests to ignore errors and update Soul components to render the products without using links for these cases. - -- [`589c91a`](https://github.com/bigcommerce/catalyst/commit/589c91a) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Remove cache from a customer-specific wishlist query. - -- [`aecc145`](https://github.com/bigcommerce/catalyst/commit/aecc145) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: localized home page routes are rewritten to the "catch all" page - -- [`3015503`](https://github.com/bigcommerce/catalyst/commit/3015503) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Fix style override issues with the latest version of the Tailwind bump. Changes should be easily rebasable. - -- [`a7b369c`](https://github.com/bigcommerce/catalyst/commit/a7b369c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fixes the error warning by having a `ProductPickList` with no images, by making the `image` prop optional for when it is not needed. - -**Migration** - -- Update `schema.ts` to allow optional `image` prop for `CardRadioField` -- Update `productOptionsTransformer` switch to have two cases for `ProductPickList` - - `ProductPickList` with no image object - - `ProductPickListWithImages` with image object -- Update ui component to make the `image` prop optional and conditionally render the image. - -- [`f16a6be`](https://github.com/bigcommerce/catalyst/commit/f16a6be) Thanks [@migueloller](https://github.com/migueloller)! - Adds `Streamable.from` and uses it wherever we were unintentionally executing an async function in a React Server Component. - -- [`43351ab`](https://github.com/bigcommerce/catalyst/commit/43351ab) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Pass in currency code to quick search results. - -- [`17d72ca`](https://github.com/bigcommerce/catalyst/commit/17d72ca) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Add the `store_hash` `` element to better support merchants. This enabled BigCommerce to identify the store more easily. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`7071dfe`](https://github.com/bigcommerce/catalyst/commit/7071dfe) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add locale prefix to auth middleware protected route URLPattern - -**Migration** - -In `core/middlewares/with-auth.ts`, update the `protectedPathPattern` variable to include an optional path segment for the locale: - -```tsx -const protectedPathPattern = new URLPattern({ pathname: `{/:locale}?/(account)/*` }); -``` - -- [`67715bf`](https://github.com/bigcommerce/catalyst/commit/67715bf) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Update GQL client and auth middleware to handle invalid tokens and invalidate session. - -**Summary** - -This will ensure that if a user is logged out elsewhere, they will be redirected to the /login page when they try to access a protected route. +### Minor Changes -Previously, the pages would 404 which is misleading. +- [#2546](https://github.com/bigcommerce/catalyst/pull/2546) [`63d2dd7`](https://github.com/bigcommerce/catalyst/commit/63d2dd70d8054344d8ad35bafc0e38661ea2d247) Thanks [@bookernath](https://github.com/bookernath)! - Bump Makeswift runtime to 0.25.0 for Next v15.5 compatibility -**Migration** +- [#2568](https://github.com/bigcommerce/catalyst/pull/2568) [`4145846`](https://github.com/bigcommerce/catalyst/commit/4145846667bfe8c3976271e6e50b5c3ec462dc1c) Thanks [@hunterbecton](https://github.com/hunterbecton)! - Now have the ability to make footer links open in new tabs -1. Copy all changes from the `/core/client` directory and the `/packages/client` directory -2. Copy translation values -3. Copy all changes from the `/core/app/[locale]/(default)/account/` directory server actions -4. Copy all changes from the `/core/app/[locale]/(default)/checkout/route.ts` file +- [#2582](https://github.com/bigcommerce/catalyst/pull/2582) [`b3c7bbf`](https://github.com/bigcommerce/catalyst/commit/b3c7bbfcda943332fd6d8488289b7d7f417ff38c) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Includes all changes included in the [1.2.0 release](https://github.com/bigcommerce/catalyst/blob/bb7940cedd169f053d55b787cc2b7183f737edba/core/CHANGELOG.md?plain=1#L3) of `@bigcommerce/catalyst-core` -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. +## 1.0.1 -- [`6c77e57`](https://github.com/bigcommerce/catalyst/commit/6c77e57) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes PDP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. +### Patch Changes -**Key modifications include:** +- [#2470](https://github.com/bigcommerce/catalyst/pull/2470) [`094b6af`](https://github.com/bigcommerce/catalyst/commit/094b6af34b47065e320ca388f539285f607f4d7a) Thanks [@agurtovoy](https://github.com/agurtovoy)! - feat: add slots for user-provided content on category pages -- Split queries into four: - - Page Metadata (metadata fields that only depend on locale) - - Product (for fields that only depend on locale) - - Streamable Product (for fields that depend on locale and variant selection) - - Product Pricing and Related Products (for fields that require locale, variant selection, and currency -- in this case, pricing and related products) -- We don't stream in Product data, instead it's a blocking call that will redirect to `notFound` when product is not found. -- Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. -- Use `Streamable.from` to generate our streaming props that are passed to our UI components. -- Update UI components to allow streaming product options before streaming in buy button. +## 1.0.0 -**Migration instructions:** +### Major Changes -- Update `/product/[slug]/page.tsx` - - For this page we are now doing a blocking request that is simplified for metadata and as a base product. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. -- Update `/product/[slug]/page-data.tsx` - - Expect our requests to be simplified/merged, essentially replacing what we had before for new requests and functions. -- Update`/product/[slug]/_components`. - - Similar to `page.tsx` and `page.data`, expect changes in the fragments defined and how we pass streamable functions to UI components. -- Update `/vibes/soul/product-detail/index.tsx` & `/vibes/soul/product-detail/product-detail-form.tsx` - - Minor changes to allow streaming in data. +- [`7f1e73d`](https://github.com/bigcommerce/catalyst/commit/7f1e73d) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Release 1.0.0 (see [`core/CHANGELOG.md`](https://github.com/bigcommerce/catalyst/blob/canary/core/CHANGELOG.md#100) for more details) -- [`8a25424`](https://github.com/bigcommerce/catalyst/commit/8a25424) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Refactors the sign in functionality to use two separate providers instead of one. This is some work needed to be done in order to provide a better API for session syncing so it shouldn't effect any existing functionality. +### Patch Changes +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Re-enable Customer Group Slot component +- [`281be00`](https://github.com/bigcommerce/catalyst/commit/281be00) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: configure NextAuth cookies to work inside of the Makeswift Builder's canvas +- [`dea8eab`](https://github.com/bigcommerce/catalyst/commit/dea8eab) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: flicker on rerendering `` +- [`2cdd078`](https://github.com/bigcommerce/catalyst/commit/2cdd078) Thanks [@arvinpoddar](https://github.com/arvinpoddar)! - fix: bump makeswift runtime version to `v0.24.0`, fixing issue with editing on SSGd pages +- [`5c198be`](https://github.com/bigcommerce/catalyst/commit/5c198be) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: disable cookie- and language-based locale detection in the builder +- [`a298d28`](https://github.com/bigcommerce/catalyst/commit/a298d28) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - Upgrade `@makeswift/runtime` to the [0.23.3 release](https://github.com/makeswift/makeswift/releases). +- [`34ac728`](https://github.com/bigcommerce/catalyst/commit/34ac728) Thanks [@arvinpoddar](https://github.com/arvinpoddar)! - refactor: upgrade Makeswift runtime and use provided utility to construct draft request +- [`5ea6d6d`](https://github.com/bigcommerce/catalyst/commit/5ea6d6d) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - Usability/terminology fix, Accordions -> Accordion +- [`8e8b31c`](https://github.com/bigcommerce/catalyst/commit/8e8b31c) Thanks [@pvaladez](https://github.com/pvaladez)! - Add core/lib/makeswift/components folder to tailwind config content property so that tailwind classes can be used in components there. +- [`89e912e`](https://github.com/bigcommerce/catalyst/commit/89e912e) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - Upgrade to the latest version of Makeswift runtime (0.24.6) +- [`e5ad65c`](https://github.com/bigcommerce/catalyst/commit/e5ad65c) Thanks [@fikrikarim](https://github.com/fikrikarim)! - Fix: missing **Add-to-cart** feedback on `integrations/makeswift` branch: Toast success notification now appears when **Add to cart** is clicked. Cart button badge in the header now updates to show `1` when the first item is added. +- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Unify Makeswift component registrations +- [`e6a98a4`](https://github.com/bigcommerce/catalyst/commit/e6a98a4) Thanks [@arvinpoddar](https://github.com/arvinpoddar)! - fix: upgrade Makeswift runtime. Includes prop editing performance improvements, a bug fix for link editing, and a fix to avoid CSS class collision by using a different Emotion key. - [`e968366`](https://github.com/bigcommerce/catalyst/commit/e968366) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: `useCompareDrawer` does not throw on missing context - -- [`a19b3ba`](https://github.com/bigcommerce/catalyst/commit/a19b3ba) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix persistent cart behavior during login. - -**Migration** - -In `core/auth/index.ts`, create the `cartIdSchema` variable: - -```ts -const cartIdSchema = z - .string() - .uuid() - .or(z.literal('undefined')) // auth.js seems to pass the cart id as a string literal 'undefined' when not set. - .optional() - .transform((val) => (val === 'undefined' ? undefined : val)); -``` - -Then, update all `Credentials` schemas to use this new `cartIdSchema`: - -```ts -const PasswordCredentials = z.object({ - email: z.string().email(), - password: z.string().min(1), - cartId: cartIdSchema, -}); - -const AnonymousCredentials = z.object({ - cartId: cartIdSchema, -}); - -const JwtCredentials = z.object({ - jwt: z.string(), - cartId: cartIdSchema, -}); - -const SessionUpdate = z.object({ - user: z.object({ - cartId: cartIdSchema, - }), -}); -``` - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add discounts summary item to Cart. - -- [`2de3c51`](https://github.com/bigcommerce/catalyst/commit/2de3c51) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fixes an issue with the checkbox not properly triggering the required validation. -- Fixes an issue with the checkbox not setting the default value from the API. -- Fixes an issue with the field value being incorrectly set as `undefined` - -**Migration** - -Update the props to set a `checked` value and pasa an empty string when checked box is unselected. - -``` -case 'checkbox': - return ( - handleChange(value ? 'true' : '')} - onFocus={controls.focus} - required={formField.required} - value={controls.value ?? ''} - /> - ); -``` - -- [`c5ce9dc`](https://github.com/bigcommerce/catalyst/commit/c5ce9dc) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Properly handle the auth error when login is invalid. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`2a7b05f`](https://github.com/bigcommerce/catalyst/commit/2a7b05f) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add translations for 'Search' button on 404 page - -**Migration** - -1. Add `"search"` translation key in the `"NotFound"` translations -2. In `core/vibes/soul/sections/not-found/index.tsx`, add a `ctaLabel` property and ensure it is used in place of the "Search" text -3. In `core/app/[locale]/not-found.tsx`, pass the `ctaLabel` prop as the new translation key `ctaLabel={t('search')}` - -- [`c095663`](https://github.com/bigcommerce/catalyst/commit/c095663) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Moves some auth related route handlers under the (auth) route group. This is to cleanup some of the routing. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add result type to all `generateMetadata`. - -- [`a15d84c`](https://github.com/bigcommerce/catalyst/commit/a15d84c) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Renames `core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider/index.tsx` to `core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider.tsx` for consistency with the other analytics components. - -**Migration** - -To migrate, rename the file with git: - -```bash -git mv core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider/index.tsx core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider.tsx -``` - -- [`5e5314b`](https://github.com/bigcommerce/catalyst/commit/5e5314b) Thanks [@jorgemoya](https://github.com/jorgemoya)! - We want state to be persitent on the `ProductDetailForm`, even after submit. This change will allow the API error messages to properly show when the form is submitted. Additionally, other form fields will retain state (like item quantity). - -**Migration** - -- Update `ProductDetailForm` to prevent reset on submit, by removing `requestFormReset` in the `onSubmit`. -- Remove `router.refresh()` call and instead call new `revalidateCart` action. - - `revalidateCart` is an action that `revalidateTag(TAGS.cart)` - - This prevents the form from fully refreshing on success. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`8c4f374`](https://github.com/bigcommerce/catalyst/commit/8c4f374) Thanks [@jordanarldt](https://github.com/jordanarldt)! - - Redirect to `/account/wishlists/` when a wishlist ID is not found -- Pass `actionsTitle` to WishlistActionsMenu on WishlistDetails page - -**Migration** - -1. Copy changes from `/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-actions.tsx` - Ensure that `actionsTitle` is an allowed property and that it is passed into the `WishlistActionsMenu` component -2. Copy changes from `/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx` - Redirect to `/account/wishlists/` on 404 -3. Ensure that the `removeButtonTitle` prop is passed down all the way to the `RemoveWishlistItemButton` component in the `WishlistItemCard` component - -- [`45bbd92`](https://github.com/bigcommerce/catalyst/commit/45bbd92) Thanks [@jordanarldt](https://github.com/jordanarldt)! - - Update the account pages to match the style of VIBES and remain consistent with the rest of Catalyst. -- Updated OrderDetails line items styling to display cost of each item and the selected `productOptions` -- Created OrderDetails skeletons -- Updated /account/orders/[id] to use `Streamable` - -**Migration** - -1. Copy all changes in the `/core/vibes/soul` directory -2. Copy all changes in the `/core/app/[locale]/(default)/account` directory - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add coupon code form to Cart page. - -- [`e8c693a`](https://github.com/bigcommerce/catalyst/commit/e8c693a) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add toast message when changing password - -**Migration** - -`core/vibes/soul/sections/account-settings/change-password-form.tsx` - -1. Import `toast`: - -```ts -import { toast } from '@/vibes/soul/primitives/toaster'; -``` - -2. Update the `ChangePasswordAction` types: - -```ts -type Action = (state: Awaited, payload: P) => S | Promise; - -interface State { - lastResult: SubmissionResult | null; - successMessage?: string; -} - -export type ChangePasswordAction = Action; -``` - -3. Update the `useActionState` hook: - -```ts -const [state, formAction] = useActionState(action, { lastResult: null }); -``` - -4. Update the `useEffect` hook to display a toast message on success: - -```ts -useEffect(() => { - if (state.lastResult?.status === 'success' && state.successMessage != null) { - toast.success(state.successMessage); - } - - if (state.lastResult?.error) { - // eslint-disable-next-line no-console - console.log(state.lastResult.error); - } -}, [state]); -``` - -`core/app/[locale]/(default)/account/settings/_actions/change-password.ts` - -Update all of the `return` values to match the new `ChangePasswordAction` interface, and return the `passwordUpdated` message on success. - -```ts -export const changePassword: ChangePasswordAction = async (prevState, formData) => { - const t = await getTranslations('Account.Settings'); - const customerAccessToken = await getSessionCustomerAccessToken(); - - const submission = parseWithZod(formData, { schema: changePasswordSchema }); - - if (submission.status !== 'success') { - return { lastResult: submission.reply() }; - } - - const input = { - currentPassword: submission.value.currentPassword, - newPassword: submission.value.password, - }; - - try { - const response = await client.fetch({ - document: CustomerChangePasswordMutation, - variables: { - input, - }, - customerAccessToken, - }); - - const result = response.data.customer.changePassword; - - if (result.errors.length > 0) { - return { - lastResult: submission.reply({ formErrors: result.errors.map((error) => error.message) }), - }; - } - - return { - lastResult: submission.reply(), - successMessage: t('passwordUpdated'), - }; - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - - if (error instanceof BigCommerceGQLError) { - return { - lastResult: submission.reply({ - formErrors: error.errors.map(({ message }) => message), - }), - }; - } - - if (error instanceof Error) { - return { - lastResult: submission.reply({ formErrors: [error.message] }), - }; - } - - return { lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }) }; - } -}; -``` - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Disable prefetch for the `/logout` link. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add textarea field to product details form. - -- [`525afdb`](https://github.com/bigcommerce/catalyst/commit/525afdb) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update empty state for account pages, adjusting headers and empty designs. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Set currency on cart at creation time - -- [`e145673`](https://github.com/bigcommerce/catalyst/commit/e145673) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Allow a list of CDN hostnames for cases when there can be more than one CDN available for image loader. - -**Migration:** - -- Update `build-config` schema to make `cdnUrls` an array of strings. -- Update `next.config.ts` to set `cdnUrls` as an array, and set multiple preconnected Link headers (one per CDN). -- `shouldUseLoaderProp` function now reads from array. - -- [`6b99400`](https://github.com/bigcommerce/catalyst/commit/6b99400) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Split coupon discounts and regular discounts from summary items, use total `cart.discountedAmount` for discounts. - -- [`0900330`](https://github.com/bigcommerce/catalyst/commit/0900330) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Refactors redirecting to checkout as a route. This will enable session syncing to happen through a redirect using the sites and routes API. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`7668774`](https://github.com/bigcommerce/catalyst/commit/7668774) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Disable PPR in Compare page due to an issue of Next.js and PPR, which causes the products to be removed once one is added to cart. More info: https://github.com/vercel/next.js/issues/59407. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`e8a9ebf`](https://github.com/bigcommerce/catalyst/commit/e8a9ebf) Thanks [@bookernath](https://github.com/bookernath)! - Revert auth route reorganization to fix regression with /login/token endpoint - -- [`84d416a`](https://github.com/bigcommerce/catalyst/commit/84d416a) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Soft fail analytics events if the provider is not rendered - -- [`6aef70b`](https://github.com/bigcommerce/catalyst/commit/6aef70b) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Refactors the add to cart logic to handle some shared functionality like revalidating the tags and setting the cart state. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Use `setRequestLocale` only where needed - -- [`96f7c8e`](https://github.com/bigcommerce/catalyst/commit/96f7c8e) Thanks [@jordanarldt](https://github.com/jordanarldt)! - - Fix incorrect/missing translation messages -- Separate defaultLocale in to a separate file -- Remove caching in `/account` pages -- Update `WishlistListItem` for better accessibility - -**Migration** - -Use this PR as a reference: https://github.com/bigcommerce/catalyst/pull/2341 - -1. Update your `messages/en.json` file with the translation keys added in this PR -2. Ensure that all components are being passed the correct translation keys -3. Update all references to `defaultLocale` to point to the `~/i18n/locales` file created in this PR -4. Update all pages in `/core/app/[locale]/(default)/account/` and ensure that `cache: 'no-store'` is set on the `client.fetch` calls -5. Update the `WishlistListItem` component to use the new accessibility features/tags as shown in the PR - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`5b83a97`](https://github.com/bigcommerce/catalyst/commit/5b83a97) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Pass search params to router.redirect when swapping locales. - -**Migration** - -Modify `useSwitchLocale` hook to include `Object.fromEntries(searchParams.entries())`. - -- [`edda0e3`](https://github.com/bigcommerce/catalyst/commit/edda0e3) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add missing border style for `Input`, `NumberInput` and `DatePicker`. - -**Migration** - -Following convention, add these conditional classes to the fields using `clsx`: - -``` -{ -light: - errors && errors.length > 0 - ? 'border-[var(--input-light-border-error,hsl(var(--error)))]' - : 'border-[var(--input-light-border,hsl(var(--contrast-100)))]', -dark: - errors && errors.length > 0 - ? 'border-[var(--input-dark-border-error,hsl(var(--error)))]' - : 'border-[var(--input-dark-border,hsl(var(--contrast-500)))]', -}[colorScheme], -``` - -- [`aade48a`](https://github.com/bigcommerce/catalyst/commit/aade48a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove explicit locale override in Link component that was appending default locale to links even with the 'as-needed' mode. - -- [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - -- [`157ea54`](https://github.com/bigcommerce/catalyst/commit/157ea54) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Rename some GQL query/mutations/fragments to standardized naming. - -- [`c4e56c6`](https://github.com/bigcommerce/catalyst/commit/c4e56c6) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: switching locales redirects user to the home page - -- [`d9edb44`](https://github.com/bigcommerce/catalyst/commit/d9edb44) Thanks [@bookernath](https://github.com/bookernath)! - Remove unused variants collection from query for PDP - -- [`816290a`](https://github.com/bigcommerce/catalyst/commit/816290a) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add aria-label to currency selector and PDP wishlist buttons - -**Migration** - -1. Copy all changes from the `/messages/en.json` file to get updated translation keys -2. Add the `label` prop to the `Heart` component in `/core/vibes/soul/primitives/favorite/heart.tsx` -3. Add the `label` prop to the `Favorite` component in `/core/vibes/soul/primitives/favorite/index.tsx` and pass it to the `Heart` component -4. Copy all changes in the `/core/vibes/soul/navigation/index.tsx` file to add the `switchCurrencyLabel` property -5. Update `/core/components/header/index.tsx` file to pass the `switchCurrencyLabel` to the `HeaderSection` component -6. Update `/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/index.tsx` to pass the `label` prop to the `Favorite` component +- [`037663b`](https://github.com/bigcommerce/catalyst/commit/037663b) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: configure `NEXT_LOCALE` cookie to work inside of the Makeswift Builder's canvas +- [`881a532`](https://github.com/bigcommerce/catalyst/commit/881a532) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix(theming): changing price label colors has no effect +- [`12d60da`](https://github.com/bigcommerce/catalyst/commit/12d60da) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: deletion of `NEXT_LOCALE` cookie triggers page rerender +- [`ed3202d`](https://github.com/bigcommerce/catalyst/commit/ed3202d) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: support switching of the page's locale in the Makeswift Builder ## 0.24.1 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)/(auth)/login/_actions/login.ts b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts index 608575dafe..640ccc5d4f 100644 --- a/core/app/[locale]/(default)/(auth)/login/_actions/login.ts +++ b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts @@ -43,6 +43,16 @@ export const login = async ( }); } + if ( + error instanceof AuthError && + error.type === 'CallbackRouteError' && + error.cause && + error.cause.err instanceof BigCommerceGQLError && + error.cause.err.message.includes('Reset password"') + ) { + return submission.reply({ formErrors: [t('passwordResetRequired')] }); + } + if ( error instanceof AuthError && error.type === 'CallbackRouteError' && diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts b/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts index 52f6d9c301..3001d9b27d 100644 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts +++ b/core/app/[locale]/(default)/(auth)/login/forgot-password/_actions/reset-password.ts @@ -10,9 +10,9 @@ import { client } from '~/client'; import { graphql } from '~/client/graphql'; const ResetPasswordMutation = graphql(` - mutation ResetPasswordMutation($input: RequestResetPasswordInput!, $reCaptcha: ReCaptchaV2Input) { + mutation ResetPasswordMutation($input: RequestResetPasswordInput!) { customer { - requestResetPassword(input: $input, reCaptchaV2: $reCaptcha) { + requestResetPassword(input: $input) { __typename errors { __typename @@ -28,8 +28,6 @@ const ResetPasswordMutation = graphql(` export const resetPassword = async ( _lastResult: { lastResult: SubmissionResult | null; successMessage?: string }, formData: FormData, - // TODO: add recaptcha token - // reCaptchaToken, ): Promise<{ lastResult: SubmissionResult | null; successMessage?: string }> => { const t = await getTranslations('Auth.Login.ForgotPassword'); @@ -47,7 +45,6 @@ export const resetPassword = async ( email: submission.value.email, path: '/change-password', }, - // ...(reCaptchaToken && { reCaptchaV2: { token: reCaptchaToken } }), }, fetchOptions: { cache: 'no-store', diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx b/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx index 9b7ecbc6af..58dd54691f 100644 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx @@ -1,29 +1,10 @@ import { Metadata } from 'next'; import { getTranslations, setRequestLocale } from 'next-intl/server'; -// import { client } from '~/client'; -// import { graphql } from '~/client/graphql'; -// import { revalidate } from '~/client/revalidate-target'; -// import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; - import { ForgotPasswordSection } from '@/vibes/soul/sections/forgot-password-section'; import { resetPassword } from './_actions/reset-password'; -// TODO: add recaptcha token -// const ResetPageQuery = graphql(` -// query ResetPageQuery { -// site { -// settings { -// reCaptcha { -// isEnabledOnStorefront -// siteKey -// } -// } -// } -// } -// `); - interface Props { params: Promise<{ locale: string }>; } @@ -45,13 +26,6 @@ export default async function Reset(props: Props) { const t = await getTranslations('Auth.Login.ForgotPassword'); - // TODO: add recaptcha token - // const { data } = await client.fetch({ - // document: ResetPageQuery, - // fetchOptions: { next: { revalidate } }, - // }); - // const recaptchaSettings = await bypassReCaptcha(data.site.settings?.reCaptcha); - return ( ); diff --git a/core/app/[locale]/(default)/(auth)/login/page.tsx b/core/app/[locale]/(default)/(auth)/login/page.tsx index af0f47ecab..5e4bf41025 100644 --- a/core/app/[locale]/(default)/(auth)/login/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/page.tsx @@ -14,6 +14,7 @@ interface Props { params: Promise<{ locale: string }>; searchParams: Promise<{ redirectTo?: string; + error?: string; }>; } @@ -29,7 +30,7 @@ export async function generateMetadata({ params }: Props): Promise { export default async function Login({ params, searchParams }: Props) { const { locale } = await params; - const { redirectTo = '/?section=orders' } = await searchParams; + const { redirectTo = '/?section=orders', error } = await searchParams; setRequestLocale(locale); @@ -38,6 +39,7 @@ export default async function Login({ params, searchParams }: Props) { const vanityUrl = buildConfig.get('urls').vanityUrl; const redirectUrl = new URL(redirectTo, vanityUrl); const redirectTarget = redirectUrl.pathname + redirectUrl.search; + const tokenErrorMessage = error === 'InvalidToken' ? t('invalidToken') : undefined; return ( <> @@ -45,6 +47,7 @@ export default async function Login({ params, searchParams }: Props) { { - 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 }) }), }; } @@ -103,6 +110,8 @@ export default async function Brand(props: Props) { return notFound(); } + const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + const productComparisonsEnabled = settings?.storefront.catalog?.productComparisonsEnabled ?? false; @@ -132,16 +141,15 @@ export default async function Brand(props: Props) { const search = await streamableFacetedSearch; const products = search.products.items; - return products.map((product) => ({ - id: product.entityId.toString(), - title: product.name, - href: product.path, - image: product.defaultImage - ? { src: product.defaultImage.url, alt: product.defaultImage.altText } - : undefined, - price: pricesTransformer(product.prices, format), - subtitle: product.brand?.name ?? undefined, - })); + const { defaultOutOfStockMessage, showOutOfStockMessage, showBackorderMessage } = + settings?.inventory ?? {}; + + return productCardTransformer( + products, + format, + showOutOfStockMessage ? defaultOutOfStockMessage : undefined, + showBackorderMessage, + ); }); const streamableTotalCount = Streamable.from(async () => { @@ -221,6 +229,7 @@ export default async function Brand(props: Props) { removeLabel={t('Compare.remove')} resetFiltersLabel={t('FacetedSearch.resetFilters')} showCompare={productComparisonsEnabled} + showRating={showRating} sortDefaultValue="featured" sortLabel={t('Search.title')} sortOptions={[ 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 7df39363d3..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 @@ -35,11 +36,22 @@ const CategoryPageQuery = graphql( } } settings { + inventory { + defaultOutOfStockMessage + showOutOfStockMessage + showBackorderMessage + } storefront { catalog { productComparisonsEnabled } } + display { + showProductRating + } + reviews { + enabled + } } } } diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index ce8d14e662..3742f4e96d 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -12,9 +12,11 @@ import { getFilterParsers } from '@/vibes/soul/sections/products-list-section/fi import { getSessionCustomerAccessToken } from '~/auth'; import { facetsTransformer } from '~/data-transformers/facets-transformer'; import { pageInfoTransformer } from '~/data-transformers/page-info-transformer'; -import { pricesTransformer } from '~/data-transformers/prices-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 }), + }), }; } @@ -114,6 +126,8 @@ export default async function Category(props: Props) { href: path ?? '#', })); + const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + const productComparisonsEnabled = settings?.storefront.catalog?.productComparisonsEnabled ?? false; @@ -146,16 +160,15 @@ export default async function Category(props: Props) { const search = await streamableFacetedSearch; const products = search.products.items; - return products.map((product) => ({ - id: product.entityId.toString(), - title: product.name, - href: product.path, - image: product.defaultImage - ? { src: product.defaultImage.url, alt: product.defaultImage.altText } - : undefined, - price: pricesTransformer(product.prices, format), - subtitle: product.brand?.name ?? undefined, - })); + const { defaultOutOfStockMessage, showOutOfStockMessage, showBackorderMessage } = + settings?.inventory ?? {}; + + return productCardTransformer( + products, + format, + showOutOfStockMessage ? defaultOutOfStockMessage : undefined, + showBackorderMessage, + ); }); const streamableTotalCount = Streamable.from(async () => { @@ -262,6 +275,7 @@ export default async function Category(props: Props) { removeLabel={t('Compare.remove')} resetFiltersLabel={t('FacetedSearch.resetFilters')} showCompare={productComparisonsEnabled} + showRating={showRating} sortDefaultValue="featured" sortLabel={t('SortBy.sortBy')} sortOptions={[ diff --git a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts index 2a08b5c370..115d639d1c 100644 --- a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts +++ b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts @@ -39,10 +39,11 @@ const GetProductSearchResultsQuery = graphql( edges { node { __typename - name + displayName isCollapsedByDefault ... on BrandSearchFilter { displayProductCount + displayName brands { pageInfo { ...PaginationFragment @@ -60,6 +61,7 @@ const GetProductSearchResultsQuery = graphql( } ... on CategorySearchFilter { displayProductCount + displayName categories { pageInfo { ...PaginationFragment @@ -92,6 +94,8 @@ const GetProductSearchResultsQuery = graphql( ... on ProductAttributeSearchFilter { displayProductCount filterName + filterKey + displayName attributes { pageInfo { ...PaginationFragment @@ -107,6 +111,7 @@ const GetProductSearchResultsQuery = graphql( } } ... on RatingSearchFilter { + displayName ratings { pageInfo { ...PaginationFragment @@ -122,6 +127,7 @@ const GetProductSearchResultsQuery = graphql( } } ... on PriceSearchFilter { + displayName selected { minPrice maxPrice diff --git a/core/app/[locale]/(default)/(faceted)/search/page-data.ts b/core/app/[locale]/(default)/(faceted)/search/page-data.ts index dc523bcd82..37f571c49b 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/search/page-data.ts @@ -8,11 +8,22 @@ const SearchPageQuery = graphql(` query SearchPageQuery { site { settings { + inventory { + defaultOutOfStockMessage + showOutOfStockMessage + showBackorderMessage + } storefront { catalog { productComparisonsEnabled } } + display { + showProductRating + } + reviews { + enabled + } } } } diff --git a/core/app/[locale]/(default)/(faceted)/search/page.tsx b/core/app/[locale]/(default)/(faceted)/search/page.tsx index 7b1c0d1ca3..c13ef0a71c 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/search/page.tsx @@ -10,8 +10,9 @@ import { getFilterParsers } from '@/vibes/soul/sections/products-list-section/fi import { getSessionCustomerAccessToken } from '~/auth'; import { facetsTransformer } from '~/data-transformers/facets-transformer'; import { pageInfoTransformer } from '~/data-transformers/page-info-transformer'; -import { pricesTransformer } from '~/data-transformers/prices-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, }; } @@ -78,6 +81,8 @@ export default async function Search(props: Props) { const { settings } = await getSearchPageData(); + const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + const productComparisonsEnabled = settings?.storefront.catalog?.productComparisonsEnabled ?? false; @@ -117,16 +122,15 @@ export default async function Search(props: Props) { const search = await streamableFacetedSearch; const products = search.products.items; - return products.map((product) => ({ - id: product.entityId.toString(), - title: product.name, - href: product.path, - image: product.defaultImage - ? { src: product.defaultImage.url, alt: product.defaultImage.altText } - : undefined, - price: pricesTransformer(product.prices, format), - subtitle: product.brand?.name ?? undefined, - })); + const { defaultOutOfStockMessage, showOutOfStockMessage, showBackorderMessage } = + settings?.inventory ?? {}; + + return productCardTransformer( + products, + format, + showOutOfStockMessage ? defaultOutOfStockMessage : undefined, + showBackorderMessage, + ); }); const streamableTitle = Streamable.from(async () => { @@ -253,6 +257,7 @@ export default async function Search(props: Props) { removeLabel={t('Compare.remove')} resetFiltersLabel={t('FacetedSearch.resetFilters')} showCompare={productComparisonsEnabled} + showRating={showRating} sortDefaultValue="featured" sortLabel={t('SortBy.sortBy')} sortOptions={[ 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 f891ad199c..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'; @@ -14,7 +14,7 @@ import { FieldNameToFieldId } from '~/data-transformers/form-field-transformer/u import { type State } from './address-action'; -export const UpdateCustomerAddressMutation = graphql(` +const UpdateCustomerAddressMutation = graphql(` mutation UpdateCustomerAddressMutation($input: UpdateCustomerAddressInput!) { customer { updateCustomerAddress(input: $input) { @@ -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/orders/[id]/page-data.tsx b/core/app/[locale]/(default)/account/orders/[id]/page-data.tsx index b6edffc916..9fd6c21afc 100644 --- a/core/app/[locale]/(default)/account/orders/[id]/page-data.tsx +++ b/core/app/[locale]/(default)/account/orders/[id]/page-data.tsx @@ -6,7 +6,7 @@ import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; -import { OrderItemFragment } from '../fragment'; +import { OrderGiftCertificateItemFragment, OrderItemFragment } from '../fragment'; const CustomerOrderDetails = graphql( ` @@ -59,6 +59,28 @@ const CustomerOrderDetails = graphql( postalCode country } + payments { + edges { + node { + paymentMethodId + paymentMethodName + detail { + __typename + ... on CreditCardPaymentInstrument { + brand + last4 + } + ... on GiftCertificatePaymentInstrument { + code + } + } + amount { + value + currencyCode + } + } + } + } consignments { shipping { edges { @@ -109,12 +131,28 @@ const CustomerOrderDetails = graphql( } } } + email { + giftCertificates { + edges { + node { + recipientEmail + lineItems { + edges { + node { + ...OrderGiftCertificateItemFragment + } + } + } + } + } + } + } } } } } `, - [OrderItemFragment], + [OrderItemFragment, OrderGiftCertificateItemFragment], ); export const getCustomerOrderDetails = cache(async (id: number) => { @@ -150,6 +188,20 @@ export const getCustomerOrderDetails = cache(async (id: number) => { shipments: removeEdgesAndNodes(consignment.shipments), }; }), + email: + order.consignments?.email && + removeEdgesAndNodes(order.consignments.email.giftCertificates).map( + ({ recipientEmail, lineItems }) => { + return { + email: recipientEmail, + lineItems: removeEdgesAndNodes(lineItems).map(({ entityId, name, salePrice }) => ({ + entityId, + name, + salePrice, + })), + }; + }, + ), }, }; }); diff --git a/core/app/[locale]/(default)/account/orders/fragment.ts b/core/app/[locale]/(default)/account/orders/fragment.ts index 5eac9d85e9..5e7454867b 100644 --- a/core/app/[locale]/(default)/account/orders/fragment.ts +++ b/core/app/[locale]/(default)/account/orders/fragment.ts @@ -33,3 +33,15 @@ export const OrderItemFragment = graphql(` } } `); + +export const OrderGiftCertificateItemFragment = graphql(` + fragment OrderGiftCertificateItemFragment on OrderGiftCertificateLineItem { + entityId + name + salePrice { + value + formattedV2 + currencyCode + } + } +`); diff --git a/core/app/[locale]/(default)/account/orders/page-data.ts b/core/app/[locale]/(default)/account/orders/page-data.ts index a6082b6c47..a2b240f4f4 100644 --- a/core/app/[locale]/(default)/account/orders/page-data.ts +++ b/core/app/[locale]/(default)/account/orders/page-data.ts @@ -7,7 +7,7 @@ import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql, VariablesOf } from '~/client/graphql'; import { TAGS } from '~/client/tags'; -import { OrderItemFragment } from './fragment'; +import { OrderGiftCertificateItemFragment, OrderItemFragment } from './fragment'; const CustomerAllOrders = graphql( ` @@ -51,6 +51,21 @@ const CustomerAllOrders = graphql( } } } + email { + giftCertificates { + edges { + node { + lineItems { + edges { + node { + ...OrderGiftCertificateItemFragment + } + } + } + } + } + } + } } } } @@ -58,7 +73,7 @@ const CustomerAllOrders = graphql( } } `, - [OrderItemFragment, PaginationFragment], + [OrderItemFragment, OrderGiftCertificateItemFragment, PaginationFragment], ); type OrdersFiltersInput = VariablesOf['filters']; @@ -116,6 +131,21 @@ export const getCustomerOrders = cache( lineItems: removeEdgesAndNodes(consignment.lineItems), }; }), + email: + order.consignments?.email && + removeEdgesAndNodes(order.consignments.email.giftCertificates).map( + ({ lineItems }) => { + return { + lineItems: removeEdgesAndNodes(lineItems).map( + ({ entityId, name, salePrice }) => ({ + entityId, + name, + salePrice, + }), + ), + }; + }, + ), }, }; }), 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 new file mode 100644 index 0000000000..2ecf52d63c --- /dev/null +++ b/core/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts @@ -0,0 +1,151 @@ +'use server'; + +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { revalidateTag } from 'next/cache'; +import { getTranslations } from 'next-intl/server'; +import { z } from 'zod'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; + +const updateNewsletterSubscriptionSchema = z.object({ + intent: z.enum(['subscribe', 'unsubscribe']), +}); + +const SubscribeToNewsletterMutation = graphql(` + mutation SubscribeToNewsletterMutation($input: CreateSubscriberInput!) { + newsletter { + subscribe(input: $input) { + errors { + __typename + ... on CreateSubscriberAlreadyExistsError { + message + } + ... on CreateSubscriberEmailInvalidError { + message + } + ... on CreateSubscriberUnexpectedError { + message + } + ... on CreateSubscriberLastNameInvalidError { + message + } + ... on CreateSubscriberFirstNameInvalidError { + message + } + } + } + } + } +`); + +const UnsubscribeFromNewsletterMutation = graphql(` + mutation UnsubscribeFromNewsletterMutation($input: RemoveSubscriberInput!) { + newsletter { + unsubscribe(input: $input) { + errors { + __typename + ... on RemoveSubscriberEmailInvalidError { + message + } + ... on RemoveSubscriberUnexpectedError { + message + } + } + } + } + } +`); + +export const updateNewsletterSubscription = async ( + { + customerInfo, + }: { + customerInfo: { + email: string; + firstName: string; + lastName: string; + }; + }, + _prevState: { lastResult: SubmissionResult | null }, + formData: FormData, +) => { + const t = await getTranslations('Account.Settings.NewsletterSubscription'); + + const submission = parseWithZod(formData, { schema: updateNewsletterSubscriptionSchema }); + + if (submission.status !== 'success') { + return { lastResult: submission.reply() }; + } + + try { + let errors; + + if (submission.value.intent === 'subscribe') { + const response = await client.fetch({ + document: SubscribeToNewsletterMutation, + variables: { + input: { + email: customerInfo.email, + firstName: customerInfo.firstName, + lastName: customerInfo.lastName, + }, + }, + }); + + errors = response.data.newsletter.subscribe.errors; + } else { + const response = await client.fetch({ + document: UnsubscribeFromNewsletterMutation, + variables: { + input: { + email: customerInfo.email, + }, + }, + }); + + errors = response.data.newsletter.unsubscribe.errors; + } + + if (errors.length > 0) { + // Not handling returned errors from API since we will display a generic error message to the user + // Still returning the errors to the client for debugging purposes + return { + lastResult: submission.reply({ + formErrors: errors.map(({ message }) => message), + }), + }; + } + + revalidateTag(TAGS.customer, { expire: 0 }); + + return { + lastResult: submission.reply(), + successMessage: t('marketingPreferencesUpdated'), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), + }; + } + + if (error instanceof Error) { + return { + lastResult: submission.reply({ formErrors: [error.message] }), + }; + } + + return { + lastResult: submission.reply({ formErrors: [String(error)] }), + }; + } +}; diff --git a/core/app/[locale]/(default)/account/settings/page-data.tsx b/core/app/[locale]/(default)/account/settings/page-data.tsx index 43dfa5d323..136ef5c991 100644 --- a/core/app/[locale]/(default)/account/settings/page-data.tsx +++ b/core/app/[locale]/(default)/account/settings/page-data.tsx @@ -6,9 +6,9 @@ import { graphql, VariablesOf } from '~/client/graphql'; import { TAGS } from '~/client/tags'; import { FormFieldsFragment } from '~/data-transformers/form-field-transformer/fragment'; -const CustomerSettingsQuery = graphql( +const AccountSettingsQuery = graphql( ` - query CustomerSettingsQuery( + query AccountSettingsQuery( $customerFilters: FormFieldFiltersInput $customerSortBy: FormFieldSortInput $addressFilters: FormFieldFiltersInput @@ -20,6 +20,7 @@ const CustomerSettingsQuery = graphql( firstName lastName company + isSubscribedToNewsletter } site { settings { @@ -31,6 +32,20 @@ const CustomerSettingsQuery = graphql( ...FormFieldsFragment } } + newsletter { + showNewsletterSignup + } + customers { + passwordComplexitySettings { + minimumNumbers + minimumPasswordLength + minimumSpecialCharacters + requireLowerCase + requireNumbers + requireSpecialCharacters + requireUpperCase + } + } } } } @@ -38,7 +53,7 @@ const CustomerSettingsQuery = graphql( [FormFieldsFragment], ); -type Variables = VariablesOf; +type Variables = VariablesOf; interface Props { address?: { @@ -52,11 +67,11 @@ interface Props { }; } -export const getCustomerSettingsQuery = cache(async ({ address, customer }: Props = {}) => { +export const getAccountSettingsQuery = cache(async ({ address, customer }: Props = {}) => { const customerAccessToken = await getSessionCustomerAccessToken(); const response = await client.fetch({ - document: CustomerSettingsQuery, + document: AccountSettingsQuery, variables: { addressFilters: address?.filters, addressSortBy: address?.sortBy, @@ -70,6 +85,9 @@ export const getCustomerSettingsQuery = cache(async ({ address, customer }: Prop const addressFields = response.data.site.settings?.formFields.shippingAddress; 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; @@ -79,5 +97,7 @@ export const getCustomerSettingsQuery = cache(async ({ address, customer }: Prop addressFields, 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 b37db8e530..cad145dc6f 100644 --- a/core/app/[locale]/(default)/account/settings/page.tsx +++ b/core/app/[locale]/(default)/account/settings/page.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/jsx-no-bind */ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getTranslations, setRequestLocale } from 'next-intl/server'; @@ -6,7 +7,8 @@ import { AccountSettingsSection } from '@/vibes/soul/sections/account-settings'; import { changePassword } from './_actions/change-password'; import { updateCustomer } from './_actions/update-customer'; -import { getCustomerSettingsQuery } from './page-data'; +import { updateNewsletterSubscription } from './_actions/update-newsletter-subscription'; +import { getAccountSettingsQuery } from './page-data'; interface Props { params: Promise<{ locale: string }>; @@ -29,24 +31,41 @@ export default async function Settings({ params }: Props) { const t = await getTranslations('Account.Settings'); - const customerSettings = await getCustomerSettingsQuery(); + const accountSettings = await getAccountSettingsQuery(); - if (!customerSettings) { + if (!accountSettings) { notFound(); } + const newsletterSubscriptionEnabled = accountSettings.newsletterSettings?.showNewsletterSignup; + const isAccountSubscribed = accountSettings.customerInfo.isSubscribedToNewsletter; + + const updateNewsletterSubscriptionActionWithCustomerInfo = updateNewsletterSubscription.bind( + null, + { + customerInfo: accountSettings.customerInfo, + }, + ); + return ( ); } diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider.tsx b/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider.tsx index 763ce72ade..8388d43327 100644 --- a/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider.tsx +++ b/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider.tsx @@ -31,7 +31,7 @@ export function WishlistAnalyticsProvider( ); } -export function WishlistAnalyticsProviderResolved({ +function WishlistAnalyticsProviderResolved({ children, data, }: PropsWithChildren<{ data: Streamable }>) { 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-data.ts b/core/app/[locale]/(default)/blog/page-data.ts index d60cc5db1f..d51cf024cd 100644 --- a/core/app/[locale]/(default)/blog/page-data.ts +++ b/core/app/[locale]/(default)/blog/page-data.ts @@ -62,7 +62,7 @@ const BlogPostsPageQuery = graphql( [PaginationFragment], ); -export interface BlogPostsFiltersInput { +interface BlogPostsFiltersInput { tag: string | null; } 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 new file mode 100644 index 0000000000..23113eef8b --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/apply-gift-certificate.ts @@ -0,0 +1,53 @@ +'use server'; + +import { revalidateTag } from 'next/cache'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql, VariablesOf } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; + +const ApplyCheckoutGiftCertificateMutation = graphql(` + mutation ApplyCheckoutGiftCertificateMutation( + $applyCheckoutGiftCertificateInput: ApplyCheckoutGiftCertificateInput! + ) { + checkout { + applyCheckoutGiftCertificate(input: $applyCheckoutGiftCertificateInput) { + checkout { + entityId + } + } + } + } +`); + +type Variables = VariablesOf; + +interface Props { + checkoutEntityId: Variables['applyCheckoutGiftCertificateInput']['checkoutEntityId']; + giftCertificateCode: Variables['applyCheckoutGiftCertificateInput']['data']['giftCertificateCode']; +} + +export const applyGiftCertificate = async ({ checkoutEntityId, giftCertificateCode }: Props) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const response = await client.fetch({ + document: ApplyCheckoutGiftCertificateMutation, + variables: { + applyCheckoutGiftCertificateInput: { + checkoutEntityId, + data: { + giftCertificateCode, + }, + }, + }, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + }); + + const checkout = response.data.checkout.applyCheckoutGiftCertificate?.checkout; + + revalidateTag(TAGS.checkout, { expire: 0 }); + + return checkout; +}; diff --git a/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts b/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts deleted file mode 100644 index f762f3d6d7..0000000000 --- a/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts +++ /dev/null @@ -1,27 +0,0 @@ -'use server'; - -import { SubmissionResult } from '@conform-to/react'; -import { parseWithZod } from '@conform-to/zod'; -import { getLocale, getTranslations } from 'next-intl/server'; -import { z } from 'zod'; - -import { redirect } from '~/i18n/routing'; -import { getCartId } from '~/lib/cart'; - -export const redirectToCheckout = async ( - _lastResult: SubmissionResult | null, - formData: FormData, -): Promise => { - const locale = await getLocale(); - const t = await getTranslations('Cart.Errors'); - - const submission = parseWithZod(formData, { schema: z.object({}) }); - - const cartId = await getCartId(); - - if (!cartId) { - return submission.reply({ formErrors: [t('cartNotFound')] }); - } - - return redirect({ href: '/checkout', locale }); -}; diff --git a/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts b/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts 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 new file mode 100644 index 0000000000..040d97a1fc --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/remove-gift-certificate.ts @@ -0,0 +1,53 @@ +'use server'; + +import { revalidateTag } from 'next/cache'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql, VariablesOf } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; + +const UnapplyCheckoutGiftCertificateMutation = graphql(` + mutation UnapplyCheckoutGiftCertificateMutation( + $unapplyCheckoutGiftCertificateInput: UnapplyCheckoutGiftCertificateInput! + ) { + checkout { + unapplyCheckoutGiftCertificate(input: $unapplyCheckoutGiftCertificateInput) { + checkout { + entityId + } + } + } + } +`); + +type Variables = VariablesOf; + +interface Props { + checkoutEntityId: Variables['unapplyCheckoutGiftCertificateInput']['checkoutEntityId']; + giftCertificateCode: Variables['unapplyCheckoutGiftCertificateInput']['data']['giftCertificateCode']; +} + +export const removeGiftCertificate = async ({ checkoutEntityId, giftCertificateCode }: Props) => { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const response = await client.fetch({ + document: UnapplyCheckoutGiftCertificateMutation, + variables: { + unapplyCheckoutGiftCertificateInput: { + checkoutEntityId, + data: { + giftCertificateCode, + }, + }, + }, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + }); + + const checkout = response.data.checkout.unapplyCheckoutGiftCertificate?.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-gift-certificate.ts b/core/app/[locale]/(default)/cart/_actions/update-gift-certificate.ts new file mode 100644 index 0000000000..b7f2c52ac3 --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/update-gift-certificate.ts @@ -0,0 +1,140 @@ +'use server'; + +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { getTranslations } from 'next-intl/server'; + +import { giftCertificateCodeActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; +import { getCartId } from '~/lib/cart'; + +import { getCart } from '../page-data'; + +import { applyGiftCertificate } from './apply-gift-certificate'; +import { removeGiftCertificate } from './remove-gift-certificate'; + +export const updateGiftCertificate = async ( + prevState: Awaited<{ + giftCertificateCodes: string[]; + lastResult: SubmissionResult | null; + }>, + formData: FormData, +): Promise<{ + giftCertificateCodes: string[]; + lastResult: SubmissionResult | null; +}> => { + const t = await getTranslations('Cart.GiftCertificate'); + const submission = parseWithZod(formData, { + schema: giftCertificateCodeActionFormDataSchema({ + required_error: t('invalidGiftCertificate'), + }), + }); + + const cartId = await getCartId(); + + if (!cartId) { + return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; + } + + const cart = await getCart({ cartId }); + const checkout = cart.site.checkout; + + if (!checkout) { + return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; + } + + const checkoutEntityId = checkout.entityId; + + if (!checkoutEntityId) { + return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; + } + + if (submission.status !== 'success') { + return { + ...prevState, + lastResult: submission.reply(), + }; + } + + switch (submission.value.intent) { + case 'apply': { + try { + await applyGiftCertificate({ + checkoutEntityId, + giftCertificateCode: submission.value.giftCertificateCode, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + ...prevState, + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => { + if (message.includes('Incorrect or mismatch:')) { + return t('invalidGiftCertificate'); + } + + return message; + }), + }), + }; + } + + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + const giftCertificateCode = submission.value.giftCertificateCode; + + return { + giftCertificateCodes: [...prevState.giftCertificateCodes, giftCertificateCode], + lastResult: submission.reply({ resetForm: true }), + }; + } + + case 'delete': { + try { + await removeGiftCertificate({ + checkoutEntityId, + giftCertificateCode: submission.value.giftCertificateCode, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + ...prevState, + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => message), + }), + }; + } + + if (error instanceof Error) { + return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; + } + + return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; + } + + const giftCertificateCode = submission.value.giftCertificateCode; + + return { + giftCertificateCodes: prevState.giftCertificateCodes.filter( + (item) => item !== giftCertificateCode, + ), + lastResult: submission.reply({ resetForm: true }), + }; + } + + default: { + return prevState; + } + } +}; diff --git a/core/app/[locale]/(default)/cart/_actions/update-quantity.ts b/core/app/[locale]/(default)/cart/_actions/update-quantity.ts index f72d60ac63..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'; @@ -29,7 +29,7 @@ export type CartSelectedOptionsInput = ReturnType< type Variables = VariablesOf; type UpdateCartLineItemInput = Variables['input']; -export interface UpdateProductQuantityParams extends CartLineItemInput { +interface UpdateProductQuantityParams extends CartLineItemInput { lineItemEntityId: UpdateCartLineItemInput['lineItemEntityId']; } @@ -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/_components/cart-analytics-provider.tsx b/core/app/[locale]/(default)/cart/_components/cart-analytics-provider.tsx index a9f7736d0c..2a1e89b7f3 100644 --- a/core/app/[locale]/(default)/cart/_components/cart-analytics-provider.tsx +++ b/core/app/[locale]/(default)/cart/_components/cart-analytics-provider.tsx @@ -32,7 +32,7 @@ export function CartAnalyticsProvider( ); } -export function CartAnalyticsProviderResolved({ +function CartAnalyticsProviderResolved({ children, data, }: PropsWithChildren<{ data: Streamable }>) { diff --git a/core/app/[locale]/(default)/cart/_components/cart-viewed.tsx b/core/app/[locale]/(default)/cart/_components/cart-viewed.tsx index b181920d3f..2b5aba2dd2 100644 --- a/core/app/[locale]/(default)/cart/_components/cart-viewed.tsx +++ b/core/app/[locale]/(default)/cart/_components/cart-viewed.tsx @@ -5,11 +5,16 @@ import { useEffect, useRef } from 'react'; import { FragmentOf } from '~/client/graphql'; import { useAnalytics } from '~/lib/analytics/react'; -import { DigitalItemFragment, PhysicalItemFragment } from '../page-data'; +import { + CartGiftCertificateFragment, + DigitalItemFragment, + PhysicalItemFragment, +} from '../page-data'; type PhysicalItem = FragmentOf; type DigitalItem = FragmentOf; -type LineItem = PhysicalItem | DigitalItem; +type GiftCertificateItem = FragmentOf; +type LineItem = PhysicalItem | DigitalItem | GiftCertificateItem; interface Props { subtotal?: number; @@ -32,6 +37,15 @@ export const CartViewed = ({ subtotal, currencyCode, lineItems }: Props) => { currency: currencyCode, value: subtotal ?? 0, items: lineItems.map((lineItem) => { + if (lineItem.__typename === 'CartGiftCertificate') { + return { + id: lineItem.entityId.toString(), + name: lineItem.name, + price: lineItem.amount.value, + quantity: 1, + }; + } + return { id: lineItem.productEntityId.toString(), name: lineItem.name, diff --git a/core/app/[locale]/(default)/cart/_components/checkout-preconnect.tsx b/core/app/[locale]/(default)/cart/_components/checkout-preconnect.tsx new file mode 100644 index 0000000000..0b39432674 --- /dev/null +++ b/core/app/[locale]/(default)/cart/_components/checkout-preconnect.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { preconnect } from 'react-dom'; + +export function CheckoutPreconnect({ url }: { url: string }) { + preconnect(url); + + return null; +} diff --git a/core/app/[locale]/(default)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts index 7c6733d455..c6e47636dc 100644 --- a/core/app/[locale]/(default)/cart/page-data.ts +++ b/core/app/[locale]/(default)/cart/page-data.ts @@ -8,6 +8,7 @@ import { TAGS } from '~/client/tags'; export const PhysicalItemFragment = graphql(` fragment PhysicalItemFragment on CartPhysicalItem { + __typename name brand sku @@ -18,19 +19,12 @@ export const PhysicalItemFragment = graphql(` quantity productEntityId variantEntityId - extendedListPrice { - currencyCode - value - } - extendedSalePrice { - currencyCode - value - } - originalPrice { + parentEntityId + listPrice { currencyCode value } - listPrice { + salePrice { currencyCode value } @@ -62,11 +56,18 @@ export const PhysicalItemFragment = graphql(` } } url + stockPosition { + backorderMessage + quantityOnHand + quantityBackordered + quantityOutOfStock + } } `); export const DigitalItemFragment = graphql(` fragment DigitalItemFragment on CartDigitalItem { + __typename name brand sku @@ -77,19 +78,12 @@ export const DigitalItemFragment = graphql(` quantity productEntityId variantEntityId - extendedListPrice { - currencyCode - value - } - extendedSalePrice { - currencyCode - value - } - originalPrice { + parentEntityId + listPrice { currencyCode value } - listPrice { + salePrice { currencyCode value } @@ -124,6 +118,33 @@ export const DigitalItemFragment = graphql(` } `); +export const CartGiftCertificateFragment = graphql(` + fragment CartGiftCertificateFragment on CartGiftCertificate { + __typename + entityId + name + message + isTaxable + sender { + name + email + } + recipient { + name + email + } + amount { + currencyCode + value + } + amountInDisplayCurrency { + currencyCode + value + } + theme + } +`); + const MoneyFieldsFragment = graphql(` fragment MoneyFieldsFragment on Money { currencyCode @@ -188,8 +209,23 @@ const GeographyFragment = graphql( const CartPageQuery = graphql( ` - query CartPageQuery($cartId: String) { + query CartPageQuery($cartId: String, $currencyCode: currencyCode) { site { + settings { + inventory { + defaultOutOfStockMessage + showOutOfStockMessage + showBackorderMessage + showQuantityOnBackorder + showQuantityOnHand + } + url { + checkoutUrl + } + giftCertificates(currencyCode: $currencyCode) { + isEnabled + } + } cart(entityId: $cartId) { entityId version @@ -204,6 +240,9 @@ const CartPageQuery = graphql( digitalItems { ...DigitalItemFragment } + giftCertificates { + ...CartGiftCertificateFragment + } totalQuantity } } @@ -227,6 +266,15 @@ const CartPageQuery = graphql( ...MoneyFieldsFragment } } + giftCertificates { + code + balance { + ...MoneyFieldsFragment + } + used { + ...MoneyFieldsFragment + } + } ...ShippingInfoFragment } } @@ -241,6 +289,7 @@ const CartPageQuery = graphql( MoneyFieldsFragment, ShippingInfoFragment, GeographyFragment, + CartGiftCertificateFragment, ], ); diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index ab507d86b0..d997b4f43e 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -5,27 +5,34 @@ import { Streamable } from '@/vibes/soul/lib/streamable'; import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart'; import { CartAnalyticsProvider } from '~/app/[locale]/(default)/cart/_components/cart-analytics-provider'; import { getCartId } from '~/lib/cart'; +import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMakeswiftPageMetadata } from '~/lib/makeswift'; import { Slot } from '~/lib/makeswift/slot'; import { exists } from '~/lib/utils'; -import { redirectToCheckout } from './_actions/redirect-to-checkout'; import { updateCouponCode } from './_actions/update-coupon-code'; +import { updateGiftCertificate } from './_actions/update-gift-certificate'; import { updateLineItem } from './_actions/update-line-item'; import { updateShippingInfo } from './_actions/update-shipping-info'; import { CartViewed } from './_components/cart-viewed'; +import { CheckoutPreconnect } from './_components/checkout-preconnect'; import { getCart, getShippingCountries } from './page-data'; interface Props { params: Promise<{ locale: string }>; } +const CHECKOUT_URL = process.env.TRAILING_SLASH !== 'false' ? '/checkout/' : '/checkout'; + 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, }; } @@ -38,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 { @@ -61,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(); @@ -80,57 +90,143 @@ export default async function Cart({ params }: Props) { return emptyState; } - const data = await getCart({ cartId }); + const currencyCode = await getPreferredCurrencyCode(); + const data = await getCart({ cartId, currencyCode }); const cart = data.site.cart; const checkout = data.site.checkout; + const giftCertificatesEnabled = data.site.settings?.giftCertificates?.isEnabled ?? false; if (!cart) { return emptyState; } - const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems]; + const lineItems = [ + ...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') { + return { + typename: item.__typename, + id: item.entityId, + title: item.name, + subtitle: `${t('GiftCertificate.to')}: ${item.recipient.name} (${item.recipient.email})${item.message ? `, ${t('GiftCertificate.message')}: ${item.message}` : ''}`, + quantity: 1, + price: format.number(item.amount.value, { + style: 'currency', + currency: item.amount.currencyCode, + }), + sender: item.sender, + recipient: item.recipient, + message: item.message, + href: undefined, + selectedOptions: [], + productEntityId: 0, + variantEntityId: 0, + }; + } + + 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, + }; + } + } - const formattedLineItems = lineItems.map((item) => ({ - id: item.entityId, - quantity: item.quantity, - price: format.number(item.listPrice.value, { - style: 'currency', - currency: item.listPrice.currencyCode, - }), - subtitle: item.selectedOptions - .map((option) => { - switch (option.__typename) { - case 'CartSelectedMultipleChoiceOption': - case 'CartSelectedCheckboxOption': - return `${option.name}: ${option.value}`; - - case 'CartSelectedNumberFieldOption': - return `${option.name}: ${option.number}`; - - case 'CartSelectedMultiLineTextFieldOption': - case 'CartSelectedTextFieldOption': - return `${option.name}: ${option.text}`; - - case 'CartSelectedDateFieldOption': - return `${option.name}: ${format.dateTime(new Date(option.date.utc))}`; - - default: - return ''; - } - }) - .join(', '), - title: item.name, - image: { src: item.image?.url || '', alt: item.name }, - href: new URL(item.url).pathname, - selectedOptions: item.selectedOptions, - productEntityId: item.productEntityId, - variantEntityId: item.variantEntityId, - })); + return { + typename: item.__typename, + id: item.entityId, + quantity: item.quantity, + price: format.number(item.listPrice.value, { + style: 'currency', + currency: item.listPrice.currencyCode, + }), + salePrice: format.number(item.salePrice.value, { + style: 'currency', + currency: item.salePrice.currencyCode, + }), + subtitle: item.selectedOptions + .map((option) => { + switch (option.__typename) { + case 'CartSelectedMultipleChoiceOption': + case 'CartSelectedCheckboxOption': + return `${option.name}: ${option.value}`; + + case 'CartSelectedNumberFieldOption': + return `${option.name}: ${option.number}`; + + case 'CartSelectedMultiLineTextFieldOption': + case 'CartSelectedTextFieldOption': + return `${option.name}: ${option.text}`; + + case 'CartSelectedDateFieldOption': + return `${option.name}: ${format.dateTime(new Date(option.date.utc))}`; + + default: + return ''; + } + }) + .join(', '), + title: item.name, + image: item.image?.url ? { src: item.image.url, alt: item.name } : undefined, + href: new URL(item.url).pathname, + selectedOptions: item.selectedOptions, + productEntityId: item.productEntityId, + variantEntityId: item.variantEntityId, + inventoryMessages, + }; + }); const totalCouponDiscount = checkout?.coupons.reduce((sum, coupon) => sum + coupon.discountedAmount.value, 0) ?? 0; + const giftCertificatesSummary = + checkout?.giftCertificates.reduce>((acc, c) => { + acc.push({ + code: c.code, + used: c.used.value, + }); + + return acc; + }, []) ?? []; + const shippingConsignment = checkout?.shippingConsignments?.find((consignment) => consignment.selectedShippingOption) || checkout?.shippingConsignments?.[0]; @@ -142,21 +238,35 @@ 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 = shippingConsignment?.address && !shippingConsignment.selectedShippingOption; + const checkoutUrl = data.site.settings?.url.checkoutUrl; + return ( <> getAnalyticsData(cartId))}> + {checkoutUrl ? : null} ({ + label: `${t('GiftCertificate.giftCertificate')} (${gc.code})`, + value: `-${format.number(gc.used, { + style: 'currency', + currency: cart.currencyCode, + })}`, + })), checkout?.taxTotal && { label: t('CheckoutSummary.tax'), value: format.number(checkout.taxTotal.value, { @@ -201,7 +318,7 @@ export default async function Cart({ params }: Props) { }, ].filter(exists), }} - checkoutAction={redirectToCheckout} + checkoutAction={CHECKOUT_URL} checkoutLabel={t('proceedToCheckout')} couponCode={{ action: updateCouponCode, @@ -217,9 +334,22 @@ export default async function Cart({ params }: Props) { subtitle: t('Empty.subtitle'), cta: { label: t('Empty.cta'), href: '/shop-all' }, }} + giftCertificate={ + giftCertificatesEnabled + ? { + action: updateGiftCertificate, + giftCertificateCodes: checkout?.giftCertificates.map((gc) => gc.code) ?? [], + ctaLabel: t('GiftCertificate.apply'), + label: t('GiftCertificate.giftCertificateCode'), + placeholder: tGiftCertificates('CheckBalance.inputPlaceholder'), + removeLabel: t('GiftCertificate.removeGiftCertificate'), + } + : undefined + } incrementLineItemLabel={t('increment')} key={`${cart.entityId}-${cart.version}`} lineItemAction={updateLineItem} + lineItemActionPendingLabel={t('cartUpdateInProgress')} shipping={{ action: updateShippingInfo, countries, diff --git a/core/app/[locale]/(default)/checkout/route.ts b/core/app/[locale]/(default)/checkout/route.ts index b99f40bd7e..f1e18dc9d2 100644 --- a/core/app/[locale]/(default)/checkout/route.ts +++ b/core/app/[locale]/(default)/checkout/route.ts @@ -1,6 +1,7 @@ import { BigCommerceAuthError } from '@bigcommerce/catalyst-client'; import { unstable_rethrow as rethrow } from 'next/navigation'; import { NextRequest, NextResponse } from 'next/server'; +import { getTranslations } from 'next-intl/server'; import { getSessionCustomerAccessToken } from '~/auth'; import { getChannelIdFromLocale } from '~/channels.config'; @@ -9,12 +10,34 @@ import { graphql } from '~/client/graphql'; import { redirect } from '~/i18n/routing'; import { getVisitIdCookie, getVisitorIdCookie } from '~/lib/analytics/bigcommerce'; import { getCartId } from '~/lib/cart'; +import { getConsentCookie } from '~/lib/consent-manager/cookies/server'; +import { serverToast } from '~/lib/server-toast'; const CheckoutRedirectMutation = graphql(` - mutation CheckoutRedirectMutation($cartId: String!, $visitId: UUID, $visitorId: UUID) { + mutation CheckoutRedirectMutation( + $cartId: String! + $visitId: String! + $visitorId: String! + $referer: URL! + $userAgent: String! + $analyticsConsent: Boolean! + $functionalConsent: Boolean! + $targetingConsent: Boolean! + ) { cart { createCartRedirectUrls( - input: { cartEntityId: $cartId, visitId: $visitId, visitorId: $visitorId } + input: { + cartEntityId: $cartId + analytics: { + initiator: { visitId: $visitId, visitorId: $visitorId } + request: { url: $referer, userAgent: $userAgent } + consent: { + analytics: $analyticsConsent + functional: $functionalConsent + targeting: $targetingConsent + } + } + } ) { errors { ... on NotFoundError { @@ -34,18 +57,31 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ loca const cartId = req.nextUrl.searchParams.get('cartId') ?? (await getCartId()); const customerAccessToken = await getSessionCustomerAccessToken(); const channelId = getChannelIdFromLocale(locale); + const t = await getTranslations('Cart.Errors'); if (!cartId) { + await serverToast.error(t('cartNotFound')); + return redirect({ href: '/cart', locale }); } const visitId = await getVisitIdCookie(); const visitorId = await getVisitorIdCookie(); + const consent = await getConsentCookie(); try { const { data } = await client.fetch({ document: CheckoutRedirectMutation, - variables: { cartId, visitId, visitorId }, + variables: { + cartId, + visitId: visitId ?? '', + visitorId: visitorId ?? '', + analyticsConsent: consent?.['c.measurement'] ?? false, + functionalConsent: consent?.['c.functionality'] ?? false, + targetingConsent: consent?.['c.marketing'] ?? false, + referer: req.headers.get('referer') ?? '', + userAgent: req.headers.get('user-agent') ?? '', + }, fetchOptions: { cache: 'no-store' }, customerAccessToken, channelId, @@ -55,6 +91,8 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ loca data.cart.createCartRedirectUrls.errors.length > 0 || !data.cart.createCartRedirectUrls.redirectUrls ) { + await serverToast.error(t('somethingWentWrong')); + return redirect({ href: '/cart', locale }); } diff --git a/core/app/[locale]/(default)/compare/_components/compare-analytics-provider.tsx b/core/app/[locale]/(default)/compare/_components/compare-analytics-provider.tsx index c03f2fdd53..90ad3e295b 100644 --- a/core/app/[locale]/(default)/compare/_components/compare-analytics-provider.tsx +++ b/core/app/[locale]/(default)/compare/_components/compare-analytics-provider.tsx @@ -31,7 +31,7 @@ export function CompareAnalyticsProvider( ); } -export function CompareAnalyticsProviderResolved({ +function CompareAnalyticsProviderResolved({ children, data, }: PropsWithChildren<{ data: Streamable }>) { 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/_actions/get-gift-certificate-by-code.ts b/core/app/[locale]/(default)/gift-certificates/balance/_actions/get-gift-certificate-by-code.ts new file mode 100644 index 0000000000..f7382dd3fc --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/balance/_actions/get-gift-certificate-by-code.ts @@ -0,0 +1,113 @@ +'use server'; + +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { getFormatter, getTranslations } from 'next-intl/server'; + +import { GiftCertificateData } from '@/vibes/soul/sections/gift-certificate-balance-section'; +import { giftCertificateCodeSchema } from '@/vibes/soul/sections/gift-certificate-balance-section/schema'; +import { client } from '~/client'; +import { graphql, ResultOf } from '~/client/graphql'; +import { ExistingResultType } from '~/client/util'; + +import { GiftCertificateFragment } from '../fragment'; + +interface State { + lastResult: SubmissionResult | null; + data: GiftCertificateData | null; + errorMessage?: string; +} + +const GetGiftCertificateByCodeQuery = graphql( + ` + query GetGiftCertificateByCode($code: String!) { + site { + giftCertificate(code: $code) { + ...GiftCertificateFragment + } + } + } + `, + [GiftCertificateFragment], +); + +function transformGiftCertificate( + giftCertificate: ResultOf, + format: ExistingResultType, +): GiftCertificateData | null { + if (!giftCertificate.amount.formattedV2 || !giftCertificate.balance.formattedV2) { + return null; + } + + return { + code: giftCertificate.code, + currencyCode: giftCertificate.currencyCode, + status: giftCertificate.status, + amount: giftCertificate.amount.formattedV2, + balance: giftCertificate.balance.formattedV2, + senderName: giftCertificate.sender.name, + recipientName: giftCertificate.recipient.name, + purchasedAt: format.dateTime(new Date(giftCertificate.purchasedAt.utc), { dateStyle: 'long' }), + expiresAt: giftCertificate.expiresAt?.utc + ? format.dateTime(new Date(giftCertificate.expiresAt.utc), { dateStyle: 'long' }) + : null, + }; +} + +export async function getGiftCertificateByCode( + prevState: Awaited, + formData: FormData, +): Promise { + const t = await getTranslations('GiftCertificates.CheckBalance'); + const format = await getFormatter(); + const schema = giftCertificateCodeSchema({ required_error: t('Errors.codeRequired') }); + const submission = parseWithZod(formData, { schema }); + + if (submission.status !== 'success') { + return { + ...prevState, + lastResult: submission.reply(), + }; + } + + try { + const { code } = schema.parse(submission.value); + const response = await client.fetch({ + document: GetGiftCertificateByCodeQuery, + fetchOptions: { cache: 'no-store' }, + variables: { code }, + }); + + if (!response.data.site.giftCertificate) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.invalidCode')] }), + errorMessage: t('Errors.invalidCode'), + }; + } + + const giftCertificate = transformGiftCertificate(response.data.site.giftCertificate, format); + + if (!giftCertificate) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.somethingWentWrong')] }), + errorMessage: t('Errors.somethingWentWrong'), + }; + } + + return { + lastResult: submission.reply(), + data: giftCertificate, + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('Errors.somethingWentWrong')] }), + errorMessage: t('Errors.somethingWentWrong'), + }; + } +} diff --git a/core/app/[locale]/(default)/gift-certificates/balance/fragment.ts b/core/app/[locale]/(default)/gift-certificates/balance/fragment.ts new file mode 100644 index 0000000000..862f70dcd5 --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/balance/fragment.ts @@ -0,0 +1,30 @@ +import { graphql } from '~/client/graphql'; + +export const GiftCertificateFragment = graphql(` + fragment GiftCertificateFragment on GiftCertificate { + code + currencyCode + status + theme + sender { + name + } + recipient { + name + } + amount { + value + formattedV2 + } + balance { + value + formattedV2 + } + purchasedAt { + utc + } + expiresAt { + utc + } + } +`); diff --git a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx new file mode 100644 index 0000000000..88c4c4fe21 --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx @@ -0,0 +1,71 @@ +import type { Metadata } from 'next'; +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'; + +import { getGiftCertificateByCode } from './_actions/get-gift-certificate-by-code'; + +interface Props { + params: Promise<{ locale: string }>; +} + +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: makeswiftMetadata?.title || t('title') || 'Gift certificates - Check balance', + ...(makeswiftMetadata?.description && { description: makeswiftMetadata.description }), + alternates: await getMetadataAlternates({ path: '/gift-certificates/balance', locale }), + }; +} + +export default async function GiftCertificates(props: Props) { + const { locale } = await props.params; + + setRequestLocale(locale); + + const t = await getTranslations('GiftCertificates'); + const currencyCode = await getPreferredCurrencyCode(); + const data = await getGiftCertificatesData(currencyCode); + + if (!data.giftCertificatesEnabled) { + return redirect({ href: '/', locale }); + } + + return ( + + ); +} diff --git a/core/app/[locale]/(default)/gift-certificates/page-data.ts b/core/app/[locale]/(default)/gift-certificates/page-data.ts new file mode 100644 index 0000000000..6905ee0b7f --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/page-data.ts @@ -0,0 +1,41 @@ +import { cache } from 'react'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; +import { CurrencyCode } from '~/components/header/fragment'; +import { StoreLogoFragment } from '~/components/store-logo/fragment'; +import { logoTransformer } from '~/data-transformers/logo-transformer'; + +const GiftCertificatesRootQuery = graphql( + ` + query GiftCertificatesRootQuery($currencyCode: currencyCode) { + site { + settings { + giftCertificates(currencyCode: $currencyCode) { + isEnabled + } + currency { + defaultCurrency + } + ...StoreLogoFragment + } + } + } + `, + [StoreLogoFragment], +); + +export const getGiftCertificatesData = cache(async (currencyCode?: CurrencyCode) => { + const response = await client.fetch({ + document: GiftCertificatesRootQuery, + variables: { currencyCode }, + fetchOptions: { next: { revalidate } }, + }); + + return { + giftCertificatesEnabled: response.data.site.settings?.giftCertificates?.isEnabled ?? false, + defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, + logo: response.data.site.settings ? logoTransformer(response.data.site.settings) : '', + }; +}); diff --git a/core/app/[locale]/(default)/gift-certificates/page.tsx b/core/app/[locale]/(default)/gift-certificates/page.tsx new file mode 100644 index 0000000000..9f95690e45 --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/page.tsx @@ -0,0 +1,60 @@ +import type { Metadata } from 'next'; +import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; + +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'; + +interface Props { + params: Promise<{ locale: string }>; +} + +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: makeswiftMetadata?.title || t('title') || 'Gift certificates', + ...(makeswiftMetadata?.description && { description: makeswiftMetadata.description }), + alternates: await getMetadataAlternates({ path: '/gift-certificates', locale }), + }; +} + +export default async function GiftCertificates(props: Props) { + const { locale } = await props.params; + + setRequestLocale(locale); + + const t = await getTranslations('GiftCertificates'); + const format = await getFormatter(); + const currencyCode = await getPreferredCurrencyCode(); + const data = await getGiftCertificatesData(currencyCode); + + if (!data.giftCertificatesEnabled) { + return redirect({ href: '/', locale }); + } + + const exampleBalance = format.number(25.0, { + style: 'currency', + currency: currencyCode ?? data.defaultCurrency, + }); + + return ( + + ); +} 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 new file mode 100644 index 0000000000..49047ba143 --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx @@ -0,0 +1,190 @@ +'use server'; + +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { getFormatter, getTranslations } from 'next-intl/server'; +import { ReactNode } from 'react'; +import { z } from 'zod'; + +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'; +import { Link } from '~/components/link'; +import { addToOrCreateCart } from '~/lib/cart'; +import { MissingCartError } from '~/lib/cart/error'; +import { getPreferredCurrencyCode } from '~/lib/currency'; + +import { GiftCertificateSettingsFragment } from '../fragment'; + +interface State { + lastResult: SubmissionResult | null; + successMessage?: ReactNode; +} + +const GiftCertificateSettingsQuery = graphql( + ` + query GiftCertificateSettings($currencyCode: currencyCode) { + site { + settings { + giftCertificates(currencyCode: $currencyCode) { + ...GiftCertificateSettingsFragment + } + } + } + } + `, + [GiftCertificateSettingsFragment], +); + +const schema = ( + giftCertificateSettings: ResultOf | undefined, + t: ExistingResultType>, +) => { + return z + .object({ + senderName: z.string(), + senderEmail: z.string().email(), + recipientName: z.string(), + recipientEmail: z.string().email(), + message: z.string().optional(), + amount: z.number({ + required_error: t('Form.Errors.amountRequired'), + invalid_type_error: t('Form.Errors.amountInvalid'), + }), + }) + .superRefine((data, ctx) => { + if (!giftCertificateSettings) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t('Form.Errors.unexpectedSettingsError'), + }); + + return; + } + + if ( + 'minimumAmount' in giftCertificateSettings && + 'maximumAmount' in giftCertificateSettings + ) { + const min = giftCertificateSettings.minimumAmount.value; + const max = giftCertificateSettings.maximumAmount.value; + + if (data.amount < min || data.amount > max) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['amount'], + message: t('Form.Errors.amountOutOfRange', { + minAmount: String(min), + maxAmount: String(max), + }), + }); + + return; + } + } + + if ('amounts' in giftCertificateSettings) { + const validAmounts = giftCertificateSettings.amounts.map((amt) => amt.value); + + if (!validAmounts.includes(data.amount)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['amount'], + message: t('Form.Errors.amountInvalid'), + }); + } + } + }); +}; + +export async function addGiftCertificateToCart( + _args: DynamicFormActionArgs, + _prevState: State, + formData: FormData, +): Promise { + const t = await getTranslations('GiftCertificates.Purchase'); + const format = await getFormatter(); + const currencyCode = await getPreferredCurrencyCode(); + const settingsResp = await client.fetch({ + document: GiftCertificateSettingsQuery, + variables: { currencyCode }, + }); + + const submission = parseWithZod(formData, { + schema: schema(settingsResp.data.site.settings?.giftCertificates ?? undefined, t), + }); + + if (submission.status !== 'success') { + return { lastResult: submission.reply() }; + } + + const amountFormatted = format.number(submission.value.amount, { + style: 'currency', + currency: currencyCode, + }); + + try { + await addToOrCreateCart({ + giftCertificates: [ + { + name: `${amountFormatted} Gift Certificate`, + sender: { + name: submission.value.senderName, + email: submission.value.senderEmail, + }, + recipient: { + name: submission.value.recipientName, + email: submission.value.recipientEmail, + }, + message: submission.value.message ?? undefined, + theme: 'GENERAL', + amount: submission.value.amount, + quantity: 1, + }, + ], + }); + + return { + lastResult: submission.reply(), + successMessage: t.rich('successMessage', { + cartLink: (chunks) => ( + + {chunks} + + ), + }), + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof BigCommerceGQLError) { + return { + lastResult: submission.reply({ + formErrors: error.errors.map(({ message }) => { + return message; + }), + }), + }; + } + + if (error instanceof MissingCartError) { + return { + lastResult: submission.reply({ formErrors: [t('missingCart')] }), + }; + } + + if (error instanceof Error) { + return { + lastResult: submission.reply({ formErrors: [error.message] }), + }; + } + + return { + lastResult: submission.reply({ formErrors: [t('unknownError')] }), + }; + } +} diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/fragment.ts b/core/app/[locale]/(default)/gift-certificates/purchase/fragment.ts new file mode 100644 index 0000000000..b81e4b2970 --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/purchase/fragment.ts @@ -0,0 +1,29 @@ +import { graphql } from '~/client/graphql'; + +export const GiftCertificateSettingsFragment = graphql(` + fragment GiftCertificateSettingsFragment on GiftCertificateSettings { + __typename + isEnabled + currencyCode + expiry { + unit + value + } + ... on FixedAmountGiftCertificateSettings { + amounts { + value + formattedV2 + } + } + ... on CustomAmountGiftCertificateSettings { + minimumAmount { + value + formattedV2 + } + maximumAmount { + value + formattedV2 + } + } + } +`); diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts b/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts new file mode 100644 index 0000000000..609584722a --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts @@ -0,0 +1,45 @@ +import { cache } from 'react'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; +import { CurrencyCode } from '~/components/header/fragment'; +import { StoreLogoFragment } from '~/components/store-logo/fragment'; +import { logoTransformer } from '~/data-transformers/logo-transformer'; + +import { GiftCertificateSettingsFragment } from './fragment'; + +const GiftCertificatePurchaseSettingsQuery = graphql( + ` + query GiftCertificatePurchaseSettingsQuery($currencyCode: currencyCode) { + site { + settings { + giftCertificates(currencyCode: $currencyCode) { + ...GiftCertificateSettingsFragment + } + currency { + defaultCurrency + } + storeName + ...StoreLogoFragment + } + } + } + `, + [GiftCertificateSettingsFragment, StoreLogoFragment], +); + +export const getGiftCertificatePurchaseData = cache(async (currencyCode?: CurrencyCode) => { + const response = await client.fetch({ + document: GiftCertificatePurchaseSettingsQuery, + variables: { currencyCode }, + fetchOptions: { next: { revalidate } }, + }); + + return { + giftCertificateSettings: response.data.site.settings?.giftCertificates ?? null, + logo: response.data.site.settings ? logoTransformer(response.data.site.settings) : '', + storeName: response.data.site.settings?.storeName ?? undefined, + defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, + }; +}); diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx new file mode 100644 index 0000000000..29e4158c75 --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx @@ -0,0 +1,194 @@ +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'; +import { GiftCertificatePurchaseSection } from '@/vibes/soul/sections/gift-certificate-purchase-section'; +import { GiftCertificateSettingsFragment } from '~/app/[locale]/(default)/gift-certificates/purchase/fragment'; +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'; + +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>, +): Array> { + const baseFields: Array> = [ + [ + { + type: 'text', + name: 'senderName', + label: t('Purchase.Form.senderNameLabel'), + required: true, + }, + { + type: 'email', + name: 'senderEmail', + label: t('Purchase.Form.senderEmailLabel'), + required: true, + }, + ], + [ + { + type: 'text', + name: 'recipientName', + label: t('Purchase.Form.recipientNameLabel'), + required: true, + }, + { + type: 'email', + name: 'recipientEmail', + label: t('Purchase.Form.recipientEmailLabel'), + required: true, + }, + ], + { + type: 'textarea', + name: 'message', + label: t('Purchase.Form.messageLabel'), + required: false, + }, + { + type: 'checkbox', + name: 'nonRefundable', + label: t('Purchase.Form.nonRefundableCheckboxLabel'), + required: true, + }, + ]; + + if (expiresAt) { + baseFields.push({ + type: 'checkbox', + name: 'expirationConsent', + label: t('Purchase.Form.expiryCheckboxLabel', { expiryDate: expiresAt }), + required: true, + }); + } + + const amountFields: Array> = + giftCertificateSettings.__typename === 'CustomAmountGiftCertificateSettings' + ? [ + { + type: 'text', + name: 'amount', + label: t('Purchase.Form.customAmountLabel', { + minAmount: String(giftCertificateSettings.minimumAmount.value), + maxAmount: String(giftCertificateSettings.maximumAmount.value), + }), + pattern: '^[0-9]*\\.?[0-9]+$', + required: true, + }, + ] + : [ + { + type: 'select', + name: 'amount', + label: t('Purchase.Form.amountLabel'), + defaultValue: '0', + options: [ + { + label: t('Purchase.Form.selectAmountPlaceholder'), + value: '0', + }, + ...giftCertificateSettings.amounts.map((amount) => ({ + label: amount.formattedV2 ?? '', + value: String(amount.value), + })), + ], + required: true, + }, + ]; + + return [...amountFields, ...baseFields]; +} + +function getExpiryDate( + expiry: ResultOf['expiry'], +): number | undefined { + if (!expiry?.unit || !expiry.value) { + return undefined; + } + + switch (expiry.unit) { + case 'DAYS': + return Date.now() + expiry.value * 24 * 60 * 60 * 1000; + + case 'WEEKS': + return Date.now() + expiry.value * 7 * 24 * 60 * 60 * 1000; + + case 'MONTHS': + return Date.now() + expiry.value * 30 * 24 * 60 * 60 * 1000; + + case 'YEARS': + return Date.now() + expiry.value * 365 * 24 * 60 * 60 * 1000; + + default: + return undefined; + } +} + +export default async function GiftCertificatePurchasePage({ params }: Props) { + const { locale } = await params; + + const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); + const format = await getFormatter(); + const currencyCode = await getPreferredCurrencyCode(); + const data = await getGiftCertificatePurchaseData(currencyCode); + + if (!data.giftCertificateSettings?.isEnabled) { + return redirect({ href: '/', locale }); + } + + const expiryDate = getExpiryDate(data.giftCertificateSettings.expiry); + const expiresAt = expiryDate ? format.dateTime(expiryDate, { dateStyle: 'long' }) : undefined; + const fields = getFields(data.giftCertificateSettings, expiresAt, t); + + return ( + + ); +} diff --git a/core/app/[locale]/(default)/page-data.ts b/core/app/[locale]/(default)/page-data.ts index 0b14484ce5..ab78d520a6 100644 --- a/core/app/[locale]/(default)/page-data.ts +++ b/core/app/[locale]/(default)/page-data.ts @@ -20,16 +20,27 @@ export const LayoutQuery = graphql( [HeaderFragment, FooterFragment], ); +const GiftCertificatesEnabledFragment = graphql(` + fragment GiftCertificatesEnabledFragment on Settings { + giftCertificates(currencyCode: $currencyCode) { + isEnabled + } + } +`); + export const GetLinksAndSectionsQuery = graphql( ` - query GetLinksAndSectionsQuery { + query GetLinksAndSectionsQuery($currencyCode: currencyCode) { site { + settings { + ...GiftCertificatesEnabledFragment + } ...HeaderLinksFragment ...FooterSectionsFragment } } `, - [HeaderLinksFragment, FooterSectionsFragment], + [HeaderLinksFragment, FooterSectionsFragment, GiftCertificatesEnabledFragment], ); const HomePageQuery = graphql( @@ -50,6 +61,16 @@ const HomePageQuery = graphql( } } } + settings { + inventory { + defaultOutOfStockMessage + showOutOfStockMessage + showBackorderMessage + } + newsletter { + showNewsletterSignup + } + } } } `, 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/submit-review.ts b/core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts new file mode 100644 index 0000000000..e37c9aa5d9 --- /dev/null +++ b/core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts @@ -0,0 +1,92 @@ +'use server'; + +import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { getTranslations } from 'next-intl/server'; + +import { schema } from '@/vibes/soul/sections/reviews/schema'; +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; + +const AddProductReviewMutation = graphql(` + mutation AddProductReviewMutation($input: AddProductReviewInput!) { + catalog { + addProductReview(input: $input) { + __typename + errors { + __typename + ... on Error { + message + } + } + } + } + } +`); + +export async function submitReview( + prevState: { lastResult: SubmissionResult | null; successMessage?: string }, + payload: FormData, +) { + const t = await getTranslations('Product.Reviews.Form'); + const customerAccessToken = await getSessionCustomerAccessToken(); + const submission = parseWithZod(payload, { schema }); + + if (submission.status !== 'success') { + return { ...prevState, lastResult: submission.reply() }; + } + + const { productEntityId, ...input } = submission.value; + + try { + const response = await client.fetch({ + document: AddProductReviewMutation, + customerAccessToken, + fetchOptions: { cache: 'no-store' }, + variables: { + input: { + review: { + ...input, + }, + productEntityId, + }, + }, + }); + + const result = response.data.catalog.addProductReview; + + if (result.errors.length > 0) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: result.errors.map(({ message }) => message) }), + }; + } + + return { + ...prevState, + lastResult: submission.reply(), + successMessage: t('successMessage'), + }; + } catch (error) { + if (error instanceof BigCommerceGQLError) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: error.errors.map(({ message }) => message) }), + }; + } + + if (error instanceof Error) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [error.message] }), + }; + } + + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }), + }; + } +} 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-analytics-provider.tsx b/core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider.tsx index 4daf4d56cd..1d788281cd 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider.tsx @@ -30,7 +30,7 @@ export function ProductAnalyticsProvider( ); } -export function ProductAnalyticsProviderResolved({ +function ProductAnalyticsProviderResolved({ children, data, }: PropsWithChildren<{ data: Streamable }>) { 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 955839cb15..6429de74be 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx @@ -5,12 +5,16 @@ import { cache } from 'react'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { Reviews as ReviewsSection } from '@/vibes/soul/sections/reviews'; +import { auth } from '~/auth'; import { client } from '~/client'; import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; +import { submitReview } from '../_actions/submit-review'; +import { getStreamableProduct } from '../page-data'; + import { ProductReviewSchemaFragment } from './product-review-schema/fragment'; import { ProductReviewSchema } from './product-review-schema/product-review-schema'; @@ -31,6 +35,7 @@ const ReviewsQuery = graphql( product(entityId: $entityId) { reviewSummary { averageRating + numberOfReviews } reviews(first: $first, after: $after, before: $before, last: $last) { pageInfo { @@ -72,9 +77,19 @@ const getReviews = cache(async (productId: number, paginationArgs: object) => { interface Props { productId: number; searchParams: Promise; + streamableImages: Streamable<{ + images: Array<{ src: string; alt: string }>; + pageInfo?: { hasNextPage: boolean; endCursor: string | null }; + }>; + streamableProduct: Streamable>>; } -export const Reviews = async ({ productId, searchParams }: Props) => { +export const Reviews = async ({ + productId, + searchParams, + streamableProduct, + streamableImages, +}: Props) => { const t = await getTranslations('Product.Reviews'); const streamableReviewsData = Streamable.from(async () => { @@ -129,16 +144,57 @@ export const Reviews = async ({ productId, searchParams }: Props) => { }); }); + const streamableProductName = Streamable.from(async () => { + const product = await streamableProduct; + + return { name: product?.name ?? '' }; + }); + + const streamableUser = Streamable.from(async () => { + const session = await auth(); + const firstName = session?.user?.firstName ?? ''; + const lastName = session?.user?.lastName ?? ''; + + if (!firstName || !lastName) { + return { email: session?.user?.email ?? '', name: '' }; + } + + const lastInitial = lastName.charAt(0).toUpperCase(); + const obfuscatedName = `${firstName} ${lastInitial}.`; + + 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 e69c818417..c61334e8bc 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -5,6 +5,7 @@ import { PricingFragment } from '~/client/fragments/pricing'; import { graphql, VariablesOf } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; import { FeaturedProductsCarouselFragment } from '~/components/featured-products-carousel/fragment'; +import { ProductVariantsInventoryFragment } from '~/components/product-variants-inventory/fragment'; import { ProductSchemaFragment } from './_components/product-schema/fragment'; import { ProductViewedFragment } from './_components/product-viewed/fragment'; @@ -111,7 +112,6 @@ export const ProductOptionsFragment = graphql( entityId displayName isRequired - isVariantOption ...MultipleChoiceFieldFragment ...CheckboxFieldFragment ...NumberFieldFragment @@ -138,6 +138,7 @@ const ProductPageMetadataQuery = graphql(` site { product(entityId: $entityId) { name + path defaultImage { altText url: urlTemplate(lossy: true) @@ -147,6 +148,7 @@ const ProductPageMetadataQuery = graphql(` metaDescription metaKeywords } + path plainTextDescription(characterLimit: 1200) } } @@ -170,6 +172,14 @@ const ProductQuery = graphql( ` query ProductQuery($entityId: Int!) { site { + settings { + reviews { + enabled + } + display { + showProductRating + } + } product(entityId: $entityId) { entityId name @@ -180,6 +190,7 @@ const ProductQuery = graphql( } reviewSummary { averageRating + numberOfReviews } description ...ProductOptionsFragment @@ -198,9 +209,60 @@ export const getProduct = cache(async (entityId: number, customerAccessToken?: s fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, }); - return data.site.product; + return data.site; }); +const StreamableProductVariantInventoryBySkuQuery = graphql(` + query ProductVariantBySkuQuery($productId: Int!, $sku: String!) { + site { + product(entityId: $productId) { + variants(skus: [$sku]) { + edges { + node { + id + entityId + sku + inventory { + aggregated { + availableToSell + warningLevel + availableOnHand + availableForBackorder + unlimitedBackorder + } + byLocation { + edges { + node { + locationEntityId + backorderMessage + } + } + } + isInStock + } + } + } + } + } + } + } +`); + +type VariantInventoryVariables = VariablesOf; + +export const getStreamableProductVariantInventory = cache( + async (variables: VariantInventoryVariables, customerAccessToken?: string) => { + const { data } = await client.fetch({ + document: StreamableProductVariantInventoryBySkuQuery, + variables, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 60 } }, + }); + + return data.site.product?.variants; + }, +); + const StreamableProductQuery = graphql( ` query StreamableProductQuery( @@ -214,7 +276,12 @@ const StreamableProductQuery = graphql( optionValueIds: $optionValueIds useDefaultOptionSelections: $useDefaultOptionSelections ) { - images { + entityId + images(first: 12) { + pageInfo { + hasNextPage + endCursor + } edges { node { altText @@ -245,12 +312,6 @@ const StreamableProductQuery = graphql( minPurchaseQuantity maxPurchaseQuantity warranty - inventory { - isInStock - } - availabilityV2 { - status - } ...ProductViewedFragment ...ProductSchemaFragment } @@ -275,6 +336,49 @@ export const getStreamableProduct = cache( }, ); +const StreamableProductInventoryQuery = graphql( + ` + query StreamableProductInventoryQuery($entityId: Int!) { + site { + product(entityId: $entityId) { + sku + inventory { + hasVariantInventory + isInStock + aggregated { + availableToSell + warningLevel + availableOnHand + availableForBackorder + unlimitedBackorder + } + } + availabilityV2 { + status + } + ...ProductVariantsInventoryFragment + } + } + } + `, + [ProductVariantsInventoryFragment], +); + +type ProductInventoryVariables = VariablesOf; + +export const getStreamableProductInventory = cache( + async (variables: ProductInventoryVariables, customerAccessToken?: string) => { + const { data } = await client.fetch({ + document: StreamableProductInventoryQuery, + variables, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 60 } }, + }); + + return data.site.product; + }, +); + // Fields that require currencyCode as a query variable // Separated from the rest to cache separately const ProductPricingAndRelatedProductsQuery = graphql( @@ -318,3 +422,31 @@ export const getProductPricingAndRelatedProducts = cache( return data.site.product; }, ); + +const InventorySettingsQuery = graphql(` + query InventorySettingsQuery { + site { + settings { + inventory { + defaultOutOfStockMessage + showOutOfStockMessage + stockLevelDisplay + showBackorderAvailabilityPrompt + backorderAvailabilityPrompt + showQuantityOnBackorder + showBackorderMessage + } + } + } + } +`); + +export const getStreamableInventorySettingsQuery = cache(async (customerAccessToken?: string) => { + const { data } = await client.fetch({ + document: InventorySettingsQuery, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + }); + + return data.site.settings?.inventory; +}); diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index bcf907f643..dd0268116d 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -6,14 +6,18 @@ import { SearchParams } from 'nuqs/server'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { FeaturedProductCarousel } from '@/vibes/soul/sections/featured-product-carousel'; -import { getSessionCustomerAccessToken } from '~/auth'; +import { auth, getSessionCustomerAccessToken } from '~/auth'; 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'; import { ProductViewed } from './_components/product-viewed'; @@ -24,7 +28,10 @@ import { getProduct, getProductPageMetadata, getProductPricingAndRelatedProducts, + getStreamableInventorySettingsQuery, getStreamableProduct, + getStreamableProductInventory, + getStreamableProductVariantInventory, } from './page-data'; interface Props { @@ -33,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); @@ -44,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 }] } }), }; } @@ -76,7 +80,10 @@ export default async function Product({ params, searchParams }: Props) { const productId = Number(slug); - const baseProduct = await getProduct(productId, customerAccessToken); + const { product: baseProduct, settings } = await getProduct(productId, customerAccessToken); + + const reviewsEnabled = Boolean(settings?.reviews.enabled && !settings.display.showProductRating); + const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); if (!baseProduct) { return notFound(); @@ -111,6 +118,41 @@ export default async function Product({ params, searchParams }: Props) { const streamableProductSku = Streamable.from(async () => (await streamableProduct).sku); + 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; + } + + const variables = { + productId, + sku: product.sku, + }; + + const variants = await getStreamableProductVariantInventory(variables, customerAccessToken); + + if (!variants) { + return undefined; + } + + return removeEdgesAndNodes(variants).find((v) => v.sku === product.sku); + }); + const streamableProductPricingAndRelatedProducts = Streamable.from(async () => { const options = await searchParams; @@ -155,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'); @@ -179,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; @@ -196,6 +241,196 @@ export default async function Product({ params, searchParams }: Props) { return false; }); + const streamableInventorySettings = Streamable.from(async () => { + return await getStreamableInventorySettingsQuery(customerAccessToken); + }); + + const getBackorderAvailabilityPrompt = ({ + showBackorderAvailabilityPrompt, + backorderAvailabilityPrompt, + availableForBackorder, + unlimitedBackorder, + }: { + showBackorderAvailabilityPrompt: boolean; + backorderAvailabilityPrompt: string | null; + availableForBackorder?: number | null; + unlimitedBackorder?: boolean; + }) => { + if (!showBackorderAvailabilityPrompt || !backorderAvailabilityPrompt) { + return null; + } + + const hasBackorderAvailablity = !!availableForBackorder || unlimitedBackorder; + + if (!hasBackorderAvailablity) { + return null; + } + + return backorderAvailabilityPrompt; + }; + + const streamableStockDisplayData = Streamable.from(async () => { + const [product, variant, inventorySetting] = await Streamable.all([ + streamableProductInventory, + streamableProductVariantInventory, + streamableInventorySettings, + ]); + + if (!inventorySetting) { + return null; + } + + let inventory; + + if (product.inventory.hasVariantInventory) { + inventory = variant?.inventory; + } else { + inventory = product.inventory; + } + + if (!inventory) { + return null; + } + + const { + showOutOfStockMessage, + stockLevelDisplay, + defaultOutOfStockMessage, + showBackorderAvailabilityPrompt, + showBackorderMessage, + showQuantityOnBackorder, + backorderAvailabilityPrompt, + } = inventorySetting; + + if (!inventory.isInStock) { + return showOutOfStockMessage + ? { stockLevelMessage: defaultOutOfStockMessage, backorderAvailabilityPrompt: null } + : null; + } + + const { + availableToSell, + warningLevel, + availableOnHand, + availableForBackorder, + unlimitedBackorder, + } = inventory.aggregated ?? {}; + + if (stockLevelDisplay === 'DONT_SHOW') { + return null; + } + + const showsBackorderInfo = + showBackorderAvailabilityPrompt || showBackorderMessage || showQuantityOnBackorder; + + // if no backorder info is to be displayed, then availableToSell is the stock quantity to be used + const stockQuantity = showsBackorderInfo ? availableOnHand : availableToSell; + + if (!showsBackorderInfo && !stockQuantity) { + return null; + } + + if (stockLevelDisplay === 'SHOW_WHEN_LOW') { + if (!warningLevel) { + return null; + } + + if (stockQuantity && stockQuantity > warningLevel) { + return null; + } + } + + const availabilityMessage = getBackorderAvailabilityPrompt({ + showBackorderAvailabilityPrompt, + backorderAvailabilityPrompt, + availableForBackorder, + unlimitedBackorder, + }); + + if (!availabilityMessage && stockQuantity === undefined) { + return null; + } + + return { + stockLevelMessage: t('ProductDetails.currentStock', { + quantity: stockQuantity ?? 0, + }), + backorderAvailabilityPrompt: availabilityMessage, + }; + }); + + const streamableBackorderDisplayData = Streamable.from(async () => { + const [product, variant, inventorySetting] = await Streamable.all([ + streamableProductInventory, + streamableProductVariantInventory, + streamableInventorySettings, + ]); + + let inventory; + + if (!product.inventory.hasVariantInventory) { + inventory = product.inventory; + } else { + inventory = variant?.inventory; + } + + if (!inventory?.aggregated || !inventorySetting) { + return { + availableOnHand: 0, + availableForBackorder: 0, + unlimitedBackorder: false, + showQuantityOnBackorder: false, + backorderMessage: null, + }; + } + + const inventoryData = { + availableOnHand: inventory.aggregated.availableOnHand, + availableForBackorder: inventory.aggregated.availableForBackorder ?? 0, + unlimitedBackorder: inventory.aggregated.unlimitedBackorder, + }; + + const { showQuantityOnBackorder, showBackorderMessage } = inventorySetting; + + const hasBackorderAvailablity = + inventoryData.availableForBackorder > 0 || inventoryData.unlimitedBackorder; + + if (!hasBackorderAvailablity || !showBackorderMessage) { + return { + ...inventoryData, + showQuantityOnBackorder: showQuantityOnBackorder && hasBackorderAvailablity, + backorderMessage: null, + }; + } + + let variantLocations; + + if (product.inventory.hasVariantInventory) { + variantLocations = variant?.inventory?.byLocation; + } else { + const variants = removeEdgesAndNodes(product.variants); + const baseVariant = variants.find((v) => v.sku === product.sku); + + variantLocations = baseVariant?.inventory?.byLocation; + } + + if (!variantLocations) { + return { + ...inventoryData, + showQuantityOnBackorder, + backorderMessage: null, + }; + } + + const inventoryByLocation = removeEdgesAndNodes(variantLocations).at(0); + + return { + ...inventoryData, + showQuantityOnBackorder, + backorderMessage: inventoryByLocation?.backorderMessage || null, + }; + }); + const streameableAccordions = Streamable.from(async () => { const product = await streamableProduct; @@ -295,6 +530,21 @@ export default async function Product({ params, searchParams }: Props) { }; }); + const streamableUser = Streamable.from(async () => { + const session = await auth(); + const firstName = session?.user?.firstName ?? ''; + const lastName = session?.user?.lastName ?? ''; + + if (!firstName || !lastName) { + return { email: session?.user?.email ?? '', name: '' }; + } + + const lastInitial = lastName.charAt(0).toUpperCase(); + const obfuscatedName = `${firstName} ${lastInitial}.`; + + return { email: session?.user?.email ?? '', name: obfuscatedName }; + }); + return ( <> @@ -314,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(), @@ -322,15 +573,22 @@ export default async function Product({ params, searchParams }: Props) { href: baseProduct.path, images: streamableImages, price: streamablePrices, + reviewsEnabled, + showRating, + numberOfReviews: baseProduct.reviewSummary.numberOfReviews, subtitle: baseProduct.brand?.name, rating: baseProduct.reviewSummary.averageRating, accordions: streameableAccordions, minQuantity: streamableMinQuantity, maxQuantity: streamableMaxQuantity, + stockDisplayData: streamableStockDisplayData, + backorderDisplayData: streamableBackorderDisplayData, }} productId={baseProduct.entityId} quantityLabel={t('ProductDetails.quantity')} + reviewFormAction={submitReview} thumbnailLabel={t('ProductDetails.thumbnail')} + user={streamableUser} /> @@ -345,7 +603,16 @@ export default async function Product({ params, searchParams }: Props) { title={t('RelatedProducts.title')} /> - + {showRating && ( +
+ +
+ )} ( - 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, }; } @@ -79,7 +80,6 @@ export async function submitContactForm( document: SubmitContactUsMutation, variables: { input, - // ...(recaptchaToken && { reCaptchaV2: { token: recaptchaToken } }), }, fetchOptions: { cache: 'no-store' }, }); @@ -89,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) { @@ -101,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-data.ts b/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts index 236c7d9188..191e5f56ac 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts @@ -24,14 +24,6 @@ const ContactPageQuery = graphql( } } } - site { - settings { - reCaptcha { - isEnabledOnStorefront - siteKey - } - } - } } `, [BreadcrumbsWebPageFragment], diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx index 6a9eda0fd7..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,12 +27,7 @@ interface Props { interface ContactPage extends WebPage { entityId: number; - path: string; contactFields: string[]; - reCaptchaSettings: { - isEnabledOnStorefront: boolean; - siteKey: string; - } | null; } const fieldMapping = { @@ -45,7 +42,6 @@ type ContactField = keyof typeof fieldMapping; const getWebPage = cache(async (id: string): Promise => { const data = await getWebpageData({ id: decodeURIComponent(id) }); - const reCaptchaSettings = data.site.settings?.reCaptcha ?? null; const webpage = data.node?.__typename === 'ContactPage' ? data.node : null; if (!webpage) { @@ -62,7 +58,6 @@ const getWebPage = cache(async (id: string): Promise => { content: webpage.htmlBody, contactFields: webpage.contactFields, seo: webpage.seo, - reCaptchaSettings, }; }); @@ -159,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 }), + }), }; } @@ -178,9 +179,6 @@ export default async function ContactPage({ params, searchParams }: Props) { const t = await getTranslations('WebPages.ContactUs.Form'); - // TODO: Use reCaptcha - // const recaptchaSettings = await bypassReCaptcha(data.site.settings?.reCaptcha); - if (success === 'true') { return ( => { 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 055bdbf405..8abcd61d25 100644 --- a/core/app/[locale]/layout.tsx +++ b/core/app/[locale]/layout.tsx @@ -1,8 +1,8 @@ +import { getSiteVersion } from '@makeswift/runtime/next/server'; import { Analytics } from '@vercel/analytics/react'; import { SpeedInsights } from '@vercel/speed-insights/next'; import { clsx } from 'clsx'; import type { Metadata } from 'next'; -import { draftMode } from 'next/headers'; import { notFound } from 'next/navigation'; import { NextIntlClientProvider } from 'next-intl'; import { setRequestLocale } from 'next-intl/server'; @@ -20,13 +20,14 @@ import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; import { WebAnalyticsFragment } from '~/components/analytics/fragment'; import { AnalyticsProvider } from '~/components/analytics/provider'; +import { ConsentManager } from '~/components/consent-manager'; +import { ScriptsFragment } from '~/components/consent-manager/scripts-fragment'; import { ContainerQueryPolyfill } from '~/components/polyfills/container-query'; -import { ScriptManagerScripts, ScriptsFragment } from '~/components/scripts'; +import { scriptsTransformer } from '~/data-transformers/scripts-transformer'; import { routing } from '~/i18n/routing'; import { SiteTheme } from '~/lib/makeswift/components/site-theme'; import { MakeswiftProvider } from '~/lib/makeswift/provider'; - -import { getToastNotification } from '../../lib/server-toast'; +import { getToastNotification } from '~/lib/server-toast'; import '~/lib/makeswift/components'; @@ -35,6 +36,13 @@ const RootLayoutMetadataQuery = graphql( query RootLayoutMetadataQuery { site { settings { + url { + vanityUrl + } + privacy { + cookieConsentEnabled + privacyPolicyUrl + } storeName seo { pageTitle @@ -69,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, @@ -107,8 +129,9 @@ interface Props extends PropsWithChildren { export default async function RootLayout({ params, children }: Props) { const { locale } = await params; - const { data } = await fetchRootLayoutMetadata(); + const rootData = await fetchRootLayoutMetadata(); const toastNotificationCookieData = await getToastNotification(); + const siteVersion = await getSiteVersion(); if (!routing.locales.includes(locale)) { notFound(); @@ -118,33 +141,43 @@ export default async function RootLayout({ params, children }: Props) { // https://next-intl-docs.vercel.app/docs/getting-started/app-router#add-setRequestLocale-to-all-layouts-and-pages setRequestLocale(locale); + const scripts = scriptsTransformer(rootData.data.site.content.scripts); + const isCookieConsentEnabled = + rootData.data.site.settings?.privacy?.cookieConsentEnabled ?? false; + const privacyPolicyUrl = rootData.data.site.settings?.privacy?.privacyPolicyUrl; + return ( - + f.variable))} lang={locale}> - - - - - {toastNotificationCookieData && ( - - )} - {children} - - - + + + + + {toastNotificationCookieData && ( + + )} + {children} + + + + - diff --git a/core/app/[locale]/maintenance/page.tsx b/core/app/[locale]/maintenance/page.tsx index 6e179e4eb8..d5bc75e4f5 100644 --- a/core/app/[locale]/maintenance/page.tsx +++ b/core/app/[locale]/maintenance/page.tsx @@ -41,7 +41,9 @@ export async function generateMetadata({ params }: Props): Promise { } const Container = ({ children }: { children: ReactNode }) => ( -
{children}
+
+ {children} +
); export default async function Maintenance({ params }: Props) { @@ -60,7 +62,7 @@ export default async function Maintenance({ params }: Props) { if (!storeSettings) { return ( - + ); } @@ -71,7 +73,7 @@ export default async function Maintenance({ params }: Props) { return ( = { cartId: n }); cookieJar.set(`${cookiePrefix}${anonymousCookieName}`, jwt, { - secure: true, + secure: useSecureCookies, sameSite: 'lax', // We set the maxAge to 7 days as a good default for anonymous sessions. // This can be adjusted based on your application's needs. - maxAge: 60 * 60 * 7, // 7 days + maxAge: 60 * 60 * 24 * 7, // 7 days httpOnly: true, }); }; @@ -54,13 +54,20 @@ export const getAnonymousSession = async () => { throw new Error('AUTH_SECRET is not set'); } - const session = await decode({ - secret, - salt: `${cookiePrefix}${anonymousCookieName}`, - token: jwt.value, - }); + try { + const session = await decode({ + secret, + salt: `${cookiePrefix}${anonymousCookieName}`, + token: jwt.value, + }); - return session; + return session; + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to decode anonymous session cookie', err); + + return null; + } }; export const clearAnonymousSession = async () => { @@ -70,7 +77,7 @@ export const clearAnonymousSession = async () => { cookieJar.delete({ name: `${cookiePrefix}${anonymousCookieName}`, - secure: true, + secure: useSecureCookies, sameSite: 'lax', httpOnly: true, }); diff --git a/core/auth/index.ts b/core/auth/index.ts index a72efc4a87..96e5927337 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -136,7 +136,8 @@ async function loginWithPassword(credentials: unknown): Promise { await clearAnonymousSession(); return { - name: `${result.customer.firstName} ${result.customer.lastName}`, + firstName: result.customer.firstName, + lastName: result.customer.lastName, email: result.customer.email, customerAccessToken: result.customerAccessToken.value, cartId: result.cart?.entityId, @@ -179,7 +180,8 @@ async function loginWithJwt(credentials: unknown): Promise { await clearAnonymousSession(); return { - name: `${result.customer.firstName} ${result.customer.lastName}`, + firstName: result.customer.firstName, + lastName: result.customer.lastName, email: result.customer.email, customerAccessToken: result.customerAccessToken.value, impersonatorId, @@ -239,6 +241,24 @@ const config = { }; } + // user can actually be undefined + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (user?.firstName !== undefined) { + token.user = { + ...token.user, + firstName: user.firstName, + }; + } + + // user can actually be undefined + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (user?.lastName !== undefined) { + token.user = { + ...token.user, + lastName: user.lastName, + }; + } + if (trigger === 'update') { const parsedSession = SessionUpdate.safeParse(session); @@ -265,6 +285,14 @@ const config = { session.b2bToken = token.b2bToken; } + if (token.user?.firstName !== undefined) { + session.user.firstName = token.user.firstName; + } + + if (token.user?.lastName !== undefined) { + session.user.lastName = token.user.lastName; + } + return session; }, }, diff --git a/core/auth/types.ts b/core/auth/types.ts index a5e8508f89..2d27c45304 100644 --- a/core/auth/types.ts +++ b/core/auth/types.ts @@ -7,7 +7,8 @@ declare module 'next-auth' { } interface User { - name?: string | null; + firstName?: string | null; + lastName?: string | null; email?: string | null; cartId?: string | null; customerAccessToken?: string; diff --git a/core/client/index.ts b/core/client/index.ts index 47f52b6c49..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 '../userAgent'; +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/analytics/provider.tsx b/core/components/analytics/provider.tsx index c12608b431..f52b93e8c5 100644 --- a/core/components/analytics/provider.tsx +++ b/core/components/analytics/provider.tsx @@ -1,30 +1,44 @@ 'use client'; -import { PropsWithChildren } from 'react'; +import { useConsentManager } from '@c15t/nextjs/client'; +import { PropsWithChildren, useEffect, useRef } from 'react'; import { FragmentOf } from '~/client/graphql'; import { Analytics } from '~/lib/analytics'; import { GoogleAnalyticsProvider } from '~/lib/analytics/providers/google-analytics'; import { AnalyticsProvider as AnalyticsProviderLib } from '~/lib/analytics/react'; +import { getConsentCookie } from '~/lib/consent-manager/cookies/client'; import { WebAnalyticsFragment } from './fragment'; interface Props { channelId: number; + isCookieConsentEnabled: boolean; settings?: FragmentOf | null; } -const getAnalytics = ( - channelId: number, - settings?: FragmentOf | null, -) => { +const getConsent = () => { + const consentCookie = getConsentCookie(); + + if (!consentCookie) { + return null; + } + + return { + functionality: consentCookie['c.functionality'], + marketing: consentCookie['c.marketing'], + measurement: consentCookie['c.measurement'], + necessary: consentCookie['c.necessary'], + }; +}; + +const getAnalytics = ({ channelId, isCookieConsentEnabled, settings }: Props): Analytics | null => { if (settings?.webAnalytics?.ga4?.tagId && channelId) { const googleAnalytics = new GoogleAnalyticsProvider({ gaId: settings.webAnalytics.ga4.tagId, - // TODO: Need to implement consent mode - // https://github.com/bigcommerce/catalyst/issues/2066 - consentModeEnabled: false, + consentModeEnabled: isCookieConsentEnabled, developerId: 'dMjk3Nj', + getConsent, }); return new Analytics({ @@ -36,8 +50,41 @@ const getAnalytics = ( return null; }; -export function AnalyticsProvider({ children, settings, channelId }: PropsWithChildren) { - const analytics = getAnalytics(channelId, settings); +export function AnalyticsProvider({ + channelId, + isCookieConsentEnabled, + settings, + children, +}: PropsWithChildren) { + const { consents } = useConsentManager(); + const prevConsentsRef = useRef | null>(null); + + const analytics = getAnalytics({ + channelId, + isCookieConsentEnabled, + settings, + }); + + // Update consent when user changes preferences + useEffect(() => { + if (!isCookieConsentEnabled || !analytics) { + return; + } + + const currentConsents = consents; + const prevConsents = prevConsentsRef.current; + + // Check if consents have changed + if (prevConsents && JSON.stringify(currentConsents) !== JSON.stringify(prevConsents)) { + const consentState = getConsent(); + + if (consentState) { + analytics.consent.consentUpdated(consentState); + } + } + + prevConsentsRef.current = currentConsents; + }, [isCookieConsentEnabled, analytics, consents]); return {children}; } diff --git a/core/components/consent-manager/consent-manager-dialog.tsx b/core/components/consent-manager/consent-manager-dialog.tsx new file mode 100644 index 0000000000..d68a9e83e8 --- /dev/null +++ b/core/components/consent-manager/consent-manager-dialog.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { + ConsentManagerDialog as C15TConsentManagerDialog, + ConsentManagerWidget as C15TConsentManagerWidget, + ConsentManagerDialogProps, + ConsentManagerWidgetProps, + useConsentManager, +} from '@c15t/nextjs/client'; +import { useTranslations } from 'next-intl'; +import { useCallback } from 'react'; + +import { Checkbox } from '@/vibes/soul/form/checkbox'; +import { Button } from '@/vibes/soul/primitives/button'; + +function ConsentManagerDialogHeaderTitle() { + const t = useTranslations('Components.ConsentManager.Dialog'); + + return ( + +
{t('title')}
+
+ ); +} + +function ConsentManagerDialogHeaderDescription() { + const t = useTranslations('Components.ConsentManager.Dialog'); + + return ( + +
{t('description')}
+
+ ); +} + +export function ConsentManagerDialog(props: ConsentManagerDialogProps) { + return ( + + + + + + + + + + + + ); +} + +function ConsentManagerWidgetRejectButton() { + const t = useTranslations('Components.ConsentManager.Common'); + + return ( + + + + ); +} + +function ConsentManagerWidgetAcceptAllButton() { + const t = useTranslations('Components.ConsentManager.Common'); + + return ( + + + + ); +} + +function ConsentManagerWidgetSaveButton() { + const t = useTranslations('Components.ConsentManager.Common'); + + return ( + + + + ); +} + +function ConsentManagerAccordionItems() { + const { selectedConsents, setSelectedConsent, getDisplayedConsents } = useConsentManager(); + const t = useTranslations('Components.ConsentManager.ConsentTypes'); + const handleConsentChange = useCallback( + ( + name: 'necessary' | 'functionality' | 'marketing' | 'measurement' | 'experience', + checked: boolean, + ) => { + setSelectedConsent(name, checked); + }, + [setSelectedConsent], + ); + + return getDisplayedConsents().map((consent) => ( + + + + + {t(`${consent.name}.title`)} + + + handleConsentChange(consent.name, checked)} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()} + onKeyUp={(e: React.KeyboardEvent) => e.stopPropagation()} + /> + + + {t(`${consent.name}.description`)} + + + )); +} + +function ConsentManagerWidget(props: ConsentManagerWidgetProps) { + return ( + + + + + + + + + + + + + ); +} diff --git a/core/components/consent-manager/consent-providers.tsx b/core/components/consent-manager/consent-providers.tsx new file mode 100644 index 0000000000..eec6fd115e --- /dev/null +++ b/core/components/consent-manager/consent-providers.tsx @@ -0,0 +1,35 @@ +import { ConsentManagerProvider as C15TConsentManagerProvider } from '@c15t/nextjs'; +import { ClientSideOptionsProvider } from '@c15t/nextjs/client'; +import type { ComponentProps, PropsWithChildren } from 'react'; + +import { CONSENT_COOKIE_NAME } from '~/lib/consent-manager/cookies/constants'; + +export type C15tScripts = NonNullable['scripts']>; + +interface ConsentManagerProviderProps extends PropsWithChildren { + scripts: C15tScripts; + isCookieConsentEnabled: boolean; + privacyPolicyUrl?: string | null; +} + +export function ConsentManagerProvider({ + children, + scripts, + isCookieConsentEnabled, +}: ConsentManagerProviderProps) { + return ( + + {children} + + ); +} diff --git a/core/components/consent-manager/cookie-banner.tsx b/core/components/consent-manager/cookie-banner.tsx new file mode 100644 index 0000000000..b858cdf814 --- /dev/null +++ b/core/components/consent-manager/cookie-banner.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { CookieBanner as C15TCookieBanner, CookieBannerProps } from '@c15t/nextjs/client'; +import { useTranslations } from 'next-intl'; +import { PropsWithChildren } from 'react'; + +import { Button } from '@/vibes/soul/primitives/button'; + +import { Link } from '../link'; + +function CookieBannerTitle() { + const t = useTranslations('Components.ConsentManager.CookieBanner'); + + return ( + +
{t('title')}
+
+ ); +} + +function CookieBannerDescription({ privacyPolicyUrl }: { privacyPolicyUrl?: string | null }) { + const t = useTranslations('Components.ConsentManager.CookieBanner'); + + return ( + +
+ {t('description')} + {typeof privacyPolicyUrl === 'string' && ( + <> + {' '} + + {t('privacyPolicy')} + + + )} +
+
+ ); +} + +function CookieBannerFooter({ children }: PropsWithChildren) { + return ( + +
{children}
+
+ ); +} + +function CookieBannerRejectButton() { + const t = useTranslations('Components.ConsentManager.Common'); + + return ( + + + + ); +} + +function CookieBannerAcceptButton() { + const t = useTranslations('Components.ConsentManager.Common'); + + return ( + + + + ); +} + +function CookieBannerCustomizeButton() { + const t = useTranslations('Components.ConsentManager.Common'); + + return ( + + + + ); +} + +export function CookieBanner({ + theme, + noStyle, + disableAnimation, + scrollLock, + trapFocus, + privacyPolicyUrl, +}: CookieBannerProps & { privacyPolicyUrl?: string | null }) { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/core/components/consent-manager/index.tsx b/core/components/consent-manager/index.tsx new file mode 100644 index 0000000000..a6b6d21f03 --- /dev/null +++ b/core/components/consent-manager/index.tsx @@ -0,0 +1,26 @@ +import type { PropsWithChildren } from 'react'; + +import { ConsentManagerDialog } from './consent-manager-dialog'; +import { type C15tScripts, ConsentManagerProvider } from './consent-providers'; +import { CookieBanner } from './cookie-banner'; + +interface ConsentManagerProps extends PropsWithChildren { + scripts: C15tScripts; + isCookieConsentEnabled: boolean; + privacyPolicyUrl?: string | null; +} + +export function ConsentManager({ + children, + scripts, + isCookieConsentEnabled, + privacyPolicyUrl, +}: ConsentManagerProps) { + return ( + + + + {children} + + ); +} diff --git a/core/components/consent-manager/scripts-fragment.ts b/core/components/consent-manager/scripts-fragment.ts new file mode 100644 index 0000000000..ec68c57f0e --- /dev/null +++ b/core/components/consent-manager/scripts-fragment.ts @@ -0,0 +1,26 @@ +import { graphql } from '~/client/graphql'; + +export const ScriptsFragment = graphql(` + fragment ScriptsFragment on Content { + scripts(first: 50, filters: { visibilities: [ALL_PAGES, STOREFRONT] }) { + edges { + node { + __typename + integrityHashes { + hash + } + entityId + consentCategory + visibility + ... on InlineScript { + scriptTag + } + ... on SrcScript { + src + } + location + } + } + } + } +`); diff --git a/core/components/footer/fragment.ts b/core/components/footer/fragment.ts index d37e34b9bf..fd7b4fda39 100644 --- a/core/components/footer/fragment.ts +++ b/core/components/footer/fragment.ts @@ -30,6 +30,12 @@ export const FooterFragment = graphql(` export const FooterSectionsFragment = graphql(` fragment FooterSectionsFragment on Site { + settings { + giftCertificates(currencyCode: $currencyCode) { + currencyCode + isEnabled + } + } content { pages(filters: { parentEntityIds: [0] }) { edges { diff --git a/core/components/footer/index.tsx b/core/components/footer/index.tsx index 3a2c76134d..cf9d490541 100644 --- a/core/components/footer/index.tsx +++ b/core/components/footer/index.tsx @@ -15,7 +15,9 @@ import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { readFragment } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; +import { CurrencyCode } from '~/components/header/fragment'; import { logoTransformer } from '~/data-transformers/logo-transformer'; +import { getPreferredCurrencyCode } from '~/lib/currency'; import { SiteFooter as FooterSection } from '~/lib/makeswift/components/site-footer'; import { FooterFragment, FooterSectionsFragment } from './fragment'; @@ -44,18 +46,21 @@ const socialIcons: Record = { YouTube: { icon: }, }; -const getFooterSections = cache(async (customerAccessToken?: string) => { - const { data: response } = await client.fetch({ - document: GetLinksAndSectionsQuery, - customerAccessToken, - // Since this query is needed on every page, it's a good idea not to validate the customer access token. - // The 'cache' function also caches errors, so we might get caught in a redirect loop if the cache saves an invalid token error response. - validateCustomerAccessToken: false, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); - - return readFragment(FooterSectionsFragment, response).site; -}); +const getFooterSections = cache( + async (customerAccessToken?: string, currencyCode?: CurrencyCode) => { + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + customerAccessToken, + variables: { currencyCode }, + // Since this query is needed on every page, it's a good idea not to validate the customer access token. + // The 'cache' function also caches errors, so we might get caught in a redirect loop if the cache saves an invalid token error response. + validateCustomerAccessToken: false, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + }); + + return readFragment(FooterSectionsFragment, response).site; + }, +); const getFooterData = cache(async () => { const { data: response } = await client.fetch({ @@ -68,7 +73,6 @@ const getFooterData = cache(async () => { export const Footer = async () => { const t = await getTranslations('Components.Footer'); - const data = await getFooterData(); const logo = data.settings ? logoTransformer(data.settings) : ''; @@ -91,8 +95,8 @@ export const Footer = async () => { const streamableSections = Streamable.from(async () => { const customerAccessToken = await getSessionCustomerAccessToken(); - - const sectionsData = await getFooterSections(customerAccessToken); + const currencyCode = await getPreferredCurrencyCode(); + const sectionsData = await getFooterSections(customerAccessToken, currencyCode); return [ { @@ -111,10 +115,20 @@ export const Footer = async () => { }, { title: t('navigate'), - links: removeEdgesAndNodes(sectionsData.content.pages).map((page) => ({ - label: page.name, - href: page.__typename === 'ExternalLinkPage' ? page.link : page.path, - })), + links: [ + ...(sectionsData.settings?.giftCertificates?.isEnabled + ? [ + { + label: t('giftCertificates'), + href: '/gift-certificates', + }, + ] + : []), + ...removeEdgesAndNodes(sectionsData.content.pages).map((page) => ({ + label: page.name, + href: page.__typename === 'ExternalLinkPage' ? page.link : page.path, + })), + ], }, ]; }); diff --git a/core/components/header/_actions/switch-currency.ts b/core/components/header/_actions/switch-currency.ts index 3ec2babddb..b15b87d72f 100644 --- a/core/components/header/_actions/switch-currency.ts +++ b/core/components/header/_actions/switch-currency.ts @@ -34,7 +34,7 @@ const UpdateCartCurrencyMutation = graphql(` } `); -export const updateCartCurrency = async (cartId: string, currencyCode: CurrencyCode) => { +const updateCartCurrency = async (cartId: string, currencyCode: CurrencyCode) => { const result = await client.fetch({ document: UpdateCartCurrencyMutation, variables: { input: { data: { currencyCode }, cartEntityId: cartId } }, @@ -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/header/fragment.ts b/core/components/header/fragment.ts index e1fa4c01e3..77e187df88 100644 --- a/core/components/header/fragment.ts +++ b/core/components/header/fragment.ts @@ -46,7 +46,7 @@ export const HeaderLinksFragment = graphql(` } `); -export type Currency = NonNullable< +type Currency = NonNullable< NonNullable>['currencies']['edges'] >[number]['node']; export type CurrencyCode = Currency['code']; diff --git a/core/components/header/index.tsx b/core/components/header/index.tsx index 17d7be90be..123881a80d 100644 --- a/core/components/header/index.tsx +++ b/core/components/header/index.tsx @@ -16,7 +16,7 @@ import { SiteHeader as HeaderSection } from '~/lib/makeswift/components/site-hea import { search } from './_actions/search'; import { switchCurrency } from './_actions/switch-currency'; -import { HeaderFragment, HeaderLinksFragment } from './fragment'; +import { CurrencyCode, HeaderFragment, HeaderLinksFragment } from './fragment'; const GetCartCountQuery = graphql(` query GetCartCountQuery($cartId: String) { @@ -47,17 +47,18 @@ const getCartCount = cache(async (cartId: string, customerAccessToken?: string) return response.data.site.cart?.lineItems.totalQuantity ?? null; }); -const getHeaderLinks = cache(async (customerAccessToken?: string) => { +const getHeaderLinks = cache(async (customerAccessToken?: string, currencyCode?: CurrencyCode) => { const { data: response } = await client.fetch({ document: GetLinksAndSectionsQuery, customerAccessToken, + variables: { currencyCode }, // Since this query is needed on every page, it's a good idea not to validate the customer access token. // The 'cache' function also caches errors, so we might get caught in a redirect loop if the cache saves an invalid token error response. validateCustomerAccessToken: false, fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, }); - return readFragment(HeaderLinksFragment, response).site.categoryTree; + return readFragment(HeaderLinksFragment, response).site; }); const getHeaderData = cache(async () => { @@ -94,9 +95,13 @@ export const Header = async () => { : []; const streamableLinks = Streamable.from(async () => { - const customerAccessToken = await getSessionCustomerAccessToken(); - - const categoryTree = await getHeaderLinks(customerAccessToken); + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); + // const customerAccessToken = await getSessionCustomerAccessToken(); + // const currencyCode = await getPreferredCurrencyCode(); + const categoryTree = (await getHeaderLinks(customerAccessToken, currencyCode)).categoryTree; /** To prevent the navigation menu from overflowing, we limit the number of categories to 6. To show a full list of categories, modify the `slice` method to remove the limit. @@ -118,6 +123,17 @@ export const Header = async () => { })); }); + const streamableGiftCertificatesEnabled = Streamable.from(async () => { + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); + const giftCertificateSettings = (await getHeaderLinks(customerAccessToken, currencyCode)) + .settings?.giftCertificates; + + return giftCertificateSettings?.isEnabled ?? false; + }); + const streamableCartCount = Streamable.from(async () => { const cartId = await getCartId(); const customerAccessToken = await getSessionCustomerAccessToken(); @@ -144,6 +160,9 @@ export const Header = async () => { accountLabel: t('Icons.account'), cartHref: '/cart', cartLabel: t('Icons.cart'), + giftCertificatesLabel: t('Icons.giftCertificates'), + giftCertificatesHref: '/gift-certificates', + giftCertificatesEnabled: streamableGiftCertificatesEnabled, searchHref: '/search', searchParamName: 'term', searchAction: search, diff --git a/core/components/modal/index.tsx b/core/components/modal/index.tsx index 229001ad14..7a10a573ea 100644 --- a/core/components/modal/index.tsx +++ b/core/components/modal/index.tsx @@ -33,7 +33,7 @@ export interface ModalFormState { export type ModalFormAction = Action; -export interface ModalFormProps { +interface ModalFormProps { action: ModalFormAction; onSuccess?: (state: ModalFormState) => void; onError?: (state: ModalFormState) => void; diff --git a/core/components/product-card/fragment.ts b/core/components/product-card/fragment.ts index 4fa501eee3..4c5d18e7af 100644 --- a/core/components/product-card/fragment.ts +++ b/core/components/product-card/fragment.ts @@ -15,10 +15,37 @@ export const ProductCardFragment = graphql( name path } + inventory { + hasVariantInventory + isInStock + aggregated { + availableForBackorder + unlimitedBackorder + availableOnHand + } + } reviewSummary { numberOfReviews averageRating } + variants(first: 1) { + edges { + node { + entityId + sku + inventory { + byLocation { + edges { + node { + locationEntityId + backorderMessage + } + } + } + } + } + } + } ...PricingFragment } `, diff --git a/core/components/product-variants-inventory/fragment.ts b/core/components/product-variants-inventory/fragment.ts new file mode 100644 index 0000000000..d514b812a0 --- /dev/null +++ b/core/components/product-variants-inventory/fragment.ts @@ -0,0 +1,24 @@ +import { graphql } from '~/client/graphql'; + +export const ProductVariantsInventoryFragment = graphql(` + fragment ProductVariantsInventoryFragment on Product { + variants { + edges { + node { + entityId + sku + inventory { + byLocation { + edges { + node { + locationEntityId + backorderMessage + } + } + } + } + } + } + } + } +`); diff --git a/core/components/scripts/README.md b/core/components/scripts/README.md deleted file mode 100644 index e81aab94c1..0000000000 --- a/core/components/scripts/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Script Components - -This directory contains components for rendering BigCommerce scripts using Next.js Script component. - -## Components - -### ScriptManagerScripts -A single component that renders scripts with configurable strategy. Location filtering is now handled at the GraphQL level, simplifying the component logic. - -## Usage - -```tsx -import { ScriptManagerScripts } from '~/components/scripts'; - -// In your layout component: - - - - - {children} - - -``` - -## Architecture - -The component accepts: -- `scripts`: Pre-filtered GraphQL scripts data (headerScripts or footerScripts) -- `strategy`: 'afterInteractive', 'lazyOnload', or other Next.js Script strategies - -## Script Processing - -The component handles both types of BigCommerce scripts: - -### External Scripts (SrcScript) -```tsx -