Repository: bigcommerce/catalyst Branch: canary Commit: 24cc310e16f8 Files: 702 Total size: 2.8 MB Directory structure: gitextract_0h9khkyg/ ├── .changeset/ │ ├── cold-foxes-lie.md │ ├── config.json │ ├── correlation-id-header.md │ ├── fix-hidden-fields-d35665be.md │ ├── fix-html-lang-locale.md │ ├── translations-patch-d3abeec7.md │ └── translations-patch-e3d3b994.md ├── .claude/ │ └── skills/ │ ├── release-catalyst/ │ │ └── SKILL.md │ ├── release-catalyst-patch/ │ │ └── SKILL.md │ └── sync-makeswift/ │ └── SKILL.md ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── config.yml │ │ ├── 🐞📝-bug-report-makeswift.md │ │ └── 🐞📝-bug-report.md │ ├── dependabot.yml │ ├── pull_request_template.md │ ├── scripts/ │ │ ├── __tests__/ │ │ │ ├── audit-unlighthouse.test.mts │ │ │ ├── bundle-size.test.mts │ │ │ ├── compare-unlighthouse.test.mts │ │ │ ├── post-bundle-comment.test.mts │ │ │ ├── post-unlighthouse-commit-comment.test.mts │ │ │ └── post-unlighthouse-pr-comment.test.mts │ │ ├── audit-unlighthouse.mts │ │ ├── bundle-size.mts │ │ ├── compare-unlighthouse.mts │ │ ├── post-bundle-comment.js │ │ ├── post-unlighthouse-commit-comment.js │ │ ├── post-unlighthouse-pr-comment.js │ │ └── prevent-invalid-changesets.js │ └── workflows/ │ ├── basic.yml │ ├── bundle-size.yml │ ├── changesets-release.yml │ ├── deploy.yml │ ├── e2e.yml │ ├── native-hosting.yml │ ├── prevent-invalid-changesets.yml │ ├── regression-tests.yml │ └── translations-changeset.yml ├── .gitignore ├── .nvmrc ├── .vscode/ │ ├── launch.example.json │ └── settings.example.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── core/ │ ├── .eslintignore │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── AGENTS.md │ ├── CHANGELOG.md │ ├── README.md │ ├── app/ │ │ ├── [locale]/ │ │ │ ├── (default)/ │ │ │ │ ├── (auth)/ │ │ │ │ │ ├── change-password/ │ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ │ └── change-password.ts │ │ │ │ │ │ ├── page-data.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── login/ │ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ │ └── login.ts │ │ │ │ │ │ ├── forgot-password/ │ │ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ │ │ └── reset-password.ts │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── token/ │ │ │ │ │ │ └── [token]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── logout/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── register/ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ ├── prefixes.ts │ │ │ │ │ │ └── register-customer.ts │ │ │ │ │ ├── page-data.ts │ │ │ │ │ └── page.tsx │ │ │ │ ├── (faceted)/ │ │ │ │ │ ├── brand/ │ │ │ │ │ │ └── [slug]/ │ │ │ │ │ │ ├── page-data.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── category/ │ │ │ │ │ │ └── [slug]/ │ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ │ └── category-viewed.tsx │ │ │ │ │ │ ├── page-data.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── fetch-compare-products.ts │ │ │ │ │ ├── fetch-faceted-search.ts │ │ │ │ │ └── search/ │ │ │ │ │ ├── page-data.ts │ │ │ │ │ └── page.tsx │ │ │ │ ├── [...rest]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── _components/ │ │ │ │ │ └── slideshow/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── account/ │ │ │ │ │ ├── addresses/ │ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ │ ├── address-action.ts │ │ │ │ │ │ │ ├── create-address.ts │ │ │ │ │ │ │ ├── delete-address.ts │ │ │ │ │ │ │ └── update-address.ts │ │ │ │ │ │ ├── page-data.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── orders/ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ ├── page-data.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── fragment.ts │ │ │ │ │ │ ├── page-data.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── settings/ │ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ │ ├── change-password.ts │ │ │ │ │ │ │ ├── update-customer.ts │ │ │ │ │ │ │ └── update-newsletter-subscription.ts │ │ │ │ │ │ ├── page-data.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── wishlists/ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ │ └── add-to-cart.tsx │ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ │ ├── visibility-switch.tsx │ │ │ │ │ │ │ ├── wishlist-actions.tsx │ │ │ │ │ │ │ └── wishlist-analytics-provider.tsx │ │ │ │ │ │ ├── page-data.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ ├── change-wishlist-visibility.ts │ │ │ │ │ │ ├── delete-wishlist.ts │ │ │ │ │ │ ├── mutation.ts │ │ │ │ │ │ ├── new-wishlist.ts │ │ │ │ │ │ ├── remove-wishlist-item.ts │ │ │ │ │ │ ├── rename-wishlist.ts │ │ │ │ │ │ └── schema.ts │ │ │ │ │ ├── _components/ │ │ │ │ │ │ ├── new-wishlist-button.tsx │ │ │ │ │ │ └── wishlist-actions-menu.tsx │ │ │ │ │ ├── modals.tsx │ │ │ │ │ ├── page-data.ts │ │ │ │ │ └── page.tsx │ │ │ │ ├── blog/ │ │ │ │ │ ├── [blogId]/ │ │ │ │ │ │ ├── page-data.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page-data.ts │ │ │ │ │ └── page.tsx │ │ │ │ ├── cart/ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ ├── add-shipping-cost.ts │ │ │ │ │ │ ├── add-shipping-info.ts │ │ │ │ │ │ ├── apply-coupon-code.ts │ │ │ │ │ │ ├── apply-gift-certificate.ts │ │ │ │ │ │ ├── remove-coupon-code.ts │ │ │ │ │ │ ├── remove-gift-certificate.ts │ │ │ │ │ │ ├── remove-item.ts │ │ │ │ │ │ ├── update-coupon-code.ts │ │ │ │ │ │ ├── update-gift-certificate.ts │ │ │ │ │ │ ├── update-line-item.ts │ │ │ │ │ │ ├── update-quantity.ts │ │ │ │ │ │ └── update-shipping-info.ts │ │ │ │ │ ├── _components/ │ │ │ │ │ │ ├── cart-analytics-provider.tsx │ │ │ │ │ │ ├── cart-viewed.tsx │ │ │ │ │ │ └── checkout-preconnect.tsx │ │ │ │ │ ├── loading.tsx │ │ │ │ │ ├── page-data.ts │ │ │ │ │ └── page.tsx │ │ │ │ ├── checkout/ │ │ │ │ │ └── route.ts │ │ │ │ ├── compare/ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ └── add-to-cart.tsx │ │ │ │ │ ├── _components/ │ │ │ │ │ │ └── compare-analytics-provider.tsx │ │ │ │ │ ├── page-data.ts │ │ │ │ │ └── page.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── gift-certificates/ │ │ │ │ │ ├── balance/ │ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ │ └── get-gift-certificate-by-code.ts │ │ │ │ │ │ ├── fragment.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page-data.ts │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── purchase/ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ └── add-to-cart.tsx │ │ │ │ │ ├── fragment.ts │ │ │ │ │ ├── page-data.ts │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page-data.ts │ │ │ │ ├── page.tsx │ │ │ │ ├── product/ │ │ │ │ │ └── [slug]/ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ ├── add-to-cart.tsx │ │ │ │ │ │ ├── get-more-images.ts │ │ │ │ │ │ ├── submit-review.ts │ │ │ │ │ │ └── wishlist-action.ts │ │ │ │ │ ├── _components/ │ │ │ │ │ │ ├── product-analytics-provider.tsx │ │ │ │ │ │ ├── product-review-schema/ │ │ │ │ │ │ │ ├── fragment.ts │ │ │ │ │ │ │ └── product-review-schema.tsx │ │ │ │ │ │ ├── product-schema/ │ │ │ │ │ │ │ ├── fragment.ts │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── product-viewed/ │ │ │ │ │ │ │ ├── fragment.ts │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── reviews.tsx │ │ │ │ │ │ ├── search-params-router-refresh.tsx │ │ │ │ │ │ └── wishlist-button/ │ │ │ │ │ │ ├── add-to-new-wishlist-modal.tsx │ │ │ │ │ │ ├── dropdown.tsx │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── page-data.ts │ │ │ │ │ └── page.tsx │ │ │ │ ├── webpages/ │ │ │ │ │ └── [id]/ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ └── web-page.tsx │ │ │ │ │ ├── contact/ │ │ │ │ │ │ ├── _actions/ │ │ │ │ │ │ │ └── submit-contact-form.ts │ │ │ │ │ │ ├── page-data.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── normal/ │ │ │ │ │ ├── page-data.ts │ │ │ │ │ └── page.tsx │ │ │ │ └── wishlist/ │ │ │ │ └── [token]/ │ │ │ │ ├── page-data.ts │ │ │ │ └── page.tsx │ │ │ ├── error.tsx │ │ │ ├── layout.tsx │ │ │ ├── maintenance/ │ │ │ │ └── page.tsx │ │ │ └── not-found.tsx │ │ ├── admin/ │ │ │ └── route.ts │ │ ├── api/ │ │ │ └── auth/ │ │ │ └── [...nextauth]/ │ │ │ └── route.ts │ │ ├── fonts.ts │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ ├── notifications.tsx │ │ ├── providers.tsx │ │ ├── robots.txt/ │ │ │ └── route.ts │ │ ├── sitemap.xml/ │ │ │ └── route.ts │ │ └── xmlsitemap.php/ │ │ └── route.ts │ ├── auth/ │ │ ├── anonymous-session.ts │ │ ├── customer-login-api.ts │ │ ├── index.ts │ │ └── types.ts │ ├── build-config/ │ │ ├── reader.ts │ │ ├── schema.ts │ │ └── writer.ts │ ├── channels.config.ts │ ├── client/ │ │ ├── correlation-id.ts │ │ ├── fragments/ │ │ │ ├── pagination.ts │ │ │ └── pricing.ts │ │ ├── graphql.ts │ │ ├── index.ts │ │ ├── revalidate-target.ts │ │ ├── tags.ts │ │ └── util/ │ │ └── index.ts │ ├── components/ │ │ ├── analytics/ │ │ │ ├── events.tsx │ │ │ ├── fragment.ts │ │ │ └── provider.tsx │ │ ├── breadcrumbs/ │ │ │ └── fragment.ts │ │ ├── consent-manager/ │ │ │ ├── consent-manager-dialog.tsx │ │ │ ├── consent-providers.tsx │ │ │ ├── cookie-banner.tsx │ │ │ ├── index.tsx │ │ │ └── scripts-fragment.ts │ │ ├── featured-products-carousel/ │ │ │ └── fragment.ts │ │ ├── featured-products-list/ │ │ │ └── fragment.ts │ │ ├── footer/ │ │ │ ├── fragment.ts │ │ │ ├── index.tsx │ │ │ └── payment-icons/ │ │ │ ├── amazon.tsx │ │ │ ├── american-express.tsx │ │ │ ├── apple-pay.tsx │ │ │ ├── mastercard.tsx │ │ │ ├── paypal.tsx │ │ │ └── visa.tsx │ │ ├── force-refresh/ │ │ │ └── index.tsx │ │ ├── header/ │ │ │ ├── _actions/ │ │ │ │ ├── fragment.ts │ │ │ │ ├── search.ts │ │ │ │ └── switch-currency.ts │ │ │ ├── fragment.ts │ │ │ ├── index.tsx │ │ │ └── schema.ts │ │ ├── image/ │ │ │ └── index.tsx │ │ ├── link/ │ │ │ └── index.tsx │ │ ├── modal/ │ │ │ ├── index.tsx │ │ │ └── modal-form-provider.tsx │ │ ├── polyfills/ │ │ │ └── container-query/ │ │ │ └── index.tsx │ │ ├── product-card/ │ │ │ └── fragment.ts │ │ ├── product-variants-inventory/ │ │ │ └── fragment.ts │ │ ├── store-logo/ │ │ │ └── fragment.ts │ │ ├── subscribe/ │ │ │ ├── _actions/ │ │ │ │ └── subscribe.ts │ │ │ └── index.tsx │ │ └── wishlist/ │ │ ├── error.ts │ │ ├── fragment.ts │ │ ├── modals/ │ │ │ ├── change-visibility.tsx │ │ │ ├── delete.tsx │ │ │ ├── new.tsx │ │ │ ├── rename.tsx │ │ │ └── share.tsx │ │ └── share-button.tsx │ ├── data-transformers/ │ │ ├── breadcrumbs-transformer.ts │ │ ├── facets-transformer.ts │ │ ├── form-field-transformer/ │ │ │ ├── fragment.ts │ │ │ ├── index.ts │ │ │ └── utils.ts │ │ ├── logo-transformer.ts │ │ ├── order-details-transformer.ts │ │ ├── orders-transformer.ts │ │ ├── page-info-transformer.ts │ │ ├── prices-transformer.ts │ │ ├── product-card-transformer.ts │ │ ├── product-options-transformer.ts │ │ ├── scripts-transformer.ts │ │ ├── search-results-transformer.ts │ │ └── wishlists-transformer.ts │ ├── global.ts │ ├── globals.css │ ├── i18n/ │ │ ├── locales.ts │ │ ├── request.ts │ │ ├── routing.ts │ │ └── utils.ts │ ├── instrumentation.ts │ ├── lib/ │ │ ├── analytics/ │ │ │ ├── analytics.d.ts │ │ │ ├── bigcommerce/ │ │ │ │ ├── data-events.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── providers/ │ │ │ │ └── google-analytics/ │ │ │ │ └── index.ts │ │ │ ├── react/ │ │ │ │ └── index.tsx │ │ │ └── types.ts │ │ ├── cart/ │ │ │ ├── add-cart-line-item.ts │ │ │ ├── create-cart.ts │ │ │ ├── error.ts │ │ │ ├── index.ts │ │ │ └── validate-cart.ts │ │ ├── cdn-image-loader.ts │ │ ├── client-cookies.ts │ │ ├── consent-manager/ │ │ │ ├── cookies/ │ │ │ │ ├── client.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── parse-compact-format.ts │ │ │ │ └── server.ts │ │ │ └── schema.ts │ │ ├── content-security-policy.ts │ │ ├── currency.ts │ │ ├── force-refresh.ts │ │ ├── kv/ │ │ │ ├── adapters/ │ │ │ │ ├── memory.ts │ │ │ │ ├── upstash.ts │ │ │ │ └── vercel-runtime-cache.ts │ │ │ ├── index.ts │ │ │ ├── keys.ts │ │ │ └── types.ts │ │ ├── otel/ │ │ │ └── tracer.ts │ │ ├── recaptcha/ │ │ │ └── constants.ts │ │ ├── recaptcha.ts │ │ ├── search.tsx │ │ ├── seo/ │ │ │ └── canonical.ts │ │ ├── server-toast.ts │ │ ├── store-assets.ts │ │ ├── user-agent.ts │ │ └── utils.ts │ ├── messages/ │ │ ├── da.json │ │ ├── de.json │ │ ├── en.json │ │ ├── es-419.json │ │ ├── es-AR.json │ │ ├── es-CL.json │ │ ├── es-CO.json │ │ ├── es-LA.json │ │ ├── es-MX.json │ │ ├── es-PE.json │ │ ├── es.json │ │ ├── fr.json │ │ ├── it.json │ │ ├── ja.json │ │ ├── nl.json │ │ ├── no.json │ │ ├── pl.json │ │ ├── pt-BR.json │ │ ├── pt.json │ │ └── sv.json │ ├── next.config.ts │ ├── package.json │ ├── playwright.config.ts │ ├── postcss.config.js │ ├── prettier.config.js │ ├── proxies/ │ │ ├── compose-proxies.ts │ │ ├── with-analytics-cookies.ts │ │ ├── with-auth.ts │ │ ├── with-channel-id.ts │ │ ├── with-intl.ts │ │ └── with-routes.ts │ ├── proxy.ts │ ├── scripts/ │ │ └── generate.cjs │ ├── tailwind.config.js │ ├── tests/ │ │ ├── README.md │ │ ├── environment.ts │ │ ├── fixtures/ │ │ │ ├── blog/ │ │ │ │ └── index.ts │ │ │ ├── browser.ts │ │ │ ├── catalog/ │ │ │ │ └── index.ts │ │ │ ├── currency/ │ │ │ │ └── index.ts │ │ │ ├── customer/ │ │ │ │ ├── index.ts │ │ │ │ └── session.ts │ │ │ ├── fixture.ts │ │ │ ├── index.ts │ │ │ ├── order/ │ │ │ │ └── index.ts │ │ │ ├── page.ts │ │ │ ├── promotion/ │ │ │ │ └── index.ts │ │ │ ├── redirects/ │ │ │ │ └── index.ts │ │ │ ├── settings/ │ │ │ │ └── index.ts │ │ │ ├── subscribe/ │ │ │ │ └── index.ts │ │ │ ├── utils/ │ │ │ │ └── api/ │ │ │ │ ├── blog/ │ │ │ │ │ ├── http.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── catalog/ │ │ │ │ │ ├── http.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── client.ts │ │ │ │ ├── currencies/ │ │ │ │ │ ├── http.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── customers/ │ │ │ │ │ ├── http.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── errors/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── response-error.ts │ │ │ │ ├── index.ts │ │ │ │ ├── orders/ │ │ │ │ │ ├── http.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── promotions/ │ │ │ │ │ ├── http.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── redirects/ │ │ │ │ │ ├── http.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── settings/ │ │ │ │ │ ├── http.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── subscribe/ │ │ │ │ │ ├── http.ts │ │ │ │ │ └── index.ts │ │ │ │ └── webpages/ │ │ │ │ ├── http.ts │ │ │ │ └── index.ts │ │ │ └── webpage/ │ │ │ └── index.ts │ │ ├── lib/ │ │ │ ├── formatter/ │ │ │ │ └── index.ts │ │ │ └── i18n/ │ │ │ └── index.ts │ │ ├── routes.ts │ │ ├── tags.ts │ │ ├── ui/ │ │ │ ├── components/ │ │ │ │ ├── accordion.spec.ts │ │ │ │ ├── breadcrumbs.spec.ts │ │ │ │ ├── carousel.spec.ts │ │ │ │ ├── checkbox.spec.ts │ │ │ │ ├── counter.spec.ts │ │ │ │ ├── radio-group.spec.ts │ │ │ │ ├── search.spec.ts │ │ │ │ ├── select.spec.ts │ │ │ │ ├── slideshow.spec.ts │ │ │ │ └── swatch.spec.ts │ │ │ └── e2e/ │ │ │ ├── account/ │ │ │ │ ├── account-settings.spec.ts │ │ │ │ ├── account.spec.ts │ │ │ │ ├── addresses.spec.ts │ │ │ │ ├── order-details.spec.ts │ │ │ │ ├── orders.spec.ts │ │ │ │ ├── wishlist-details.spec.ts │ │ │ │ ├── wishlists.mobile.spec.ts │ │ │ │ └── wishlists.spec.ts │ │ │ ├── analytics-session.spec.ts │ │ │ ├── auth/ │ │ │ │ ├── anonymous-session.spec.ts │ │ │ │ ├── forgot-password.spec.ts │ │ │ │ ├── login.spec.ts │ │ │ │ ├── logout.spec.ts │ │ │ │ └── register.spec.ts │ │ │ ├── blog.spec.ts │ │ │ ├── cart.spec.ts │ │ │ ├── checkout.spec.ts │ │ │ ├── compare.spec.ts │ │ │ ├── coupon.spec.ts │ │ │ ├── facets.spec.ts │ │ │ ├── home.spec.ts │ │ │ ├── not-found.spec.ts │ │ │ ├── product.spec.ts │ │ │ ├── proxy/ │ │ │ │ └── redirects.spec.ts │ │ │ ├── reviews.spec.ts │ │ │ ├── search.spec.ts │ │ │ ├── shipping.spec.ts │ │ │ ├── subscribe.spec.ts │ │ │ └── webpages.spec.ts │ │ └── visual-regression/ │ │ └── components/ │ │ ├── accordion.spec.ts │ │ ├── badge.spec.ts │ │ ├── blog-post-card.spec.ts │ │ ├── breadcrumbs.spec.ts │ │ ├── button.spec.ts │ │ ├── carousel.spec.ts │ │ ├── checkbox.spec.ts │ │ ├── counter.spec.ts │ │ ├── datepicker.spec.ts │ │ ├── footer.spec.ts │ │ ├── form.spec.ts │ │ ├── gallery.spec.ts │ │ ├── header.spec.ts │ │ ├── input.spec.ts │ │ ├── label.spec.ts │ │ ├── picklist.spec.ts │ │ ├── radio-group.spec.ts │ │ ├── rating.spec.ts │ │ ├── rectangle-list.spec.ts │ │ ├── select.spec.ts │ │ ├── slideshow.spec.ts │ │ ├── swatch.spec.ts │ │ ├── tags.spec.ts │ │ └── textarea.spec.ts │ ├── tsconfig.json │ ├── user-agent.ts │ └── vibes/ │ └── soul/ │ ├── form/ │ │ ├── button-radio-group/ │ │ │ └── index.tsx │ │ ├── card-radio-group/ │ │ │ └── index.tsx │ │ ├── checkbox/ │ │ │ └── index.tsx │ │ ├── checkbox-group/ │ │ │ └── index.tsx │ │ ├── date-picker/ │ │ │ └── index.tsx │ │ ├── dynamic-form/ │ │ │ ├── index.tsx │ │ │ ├── schema.ts │ │ │ └── utils.ts │ │ ├── field-error/ │ │ │ └── index.tsx │ │ ├── form-status/ │ │ │ └── index.tsx │ │ ├── input/ │ │ │ └── index.tsx │ │ ├── label/ │ │ │ └── index.tsx │ │ ├── number-input/ │ │ │ └── index.tsx │ │ ├── radio-group/ │ │ │ └── index.tsx │ │ ├── range-input/ │ │ │ └── index.tsx │ │ ├── rating-radio-group/ │ │ │ └── index.tsx │ │ ├── select/ │ │ │ └── index.tsx │ │ ├── swatch-radio-group/ │ │ │ └── index.tsx │ │ ├── switch/ │ │ │ └── index.tsx │ │ ├── textarea/ │ │ │ └── index.tsx │ │ └── toggle-group/ │ │ └── index.tsx │ ├── lib/ │ │ └── streamable.tsx │ ├── primitives/ │ │ ├── accordion/ │ │ │ └── index.tsx │ │ ├── alert/ │ │ │ └── index.tsx │ │ ├── animated-underline/ │ │ │ └── index.tsx │ │ ├── badge/ │ │ │ └── index.tsx │ │ ├── banner/ │ │ │ └── index.tsx │ │ ├── blog-post-card/ │ │ │ └── index.tsx │ │ ├── button/ │ │ │ └── index.tsx │ │ ├── button-link/ │ │ │ └── index.tsx │ │ ├── calendar/ │ │ │ └── index.tsx │ │ ├── carousel/ │ │ │ └── index.tsx │ │ ├── chip/ │ │ │ └── index.tsx │ │ ├── compare-card/ │ │ │ ├── add-to-cart-form.tsx │ │ │ ├── index.tsx │ │ │ └── schema.ts │ │ ├── compare-drawer/ │ │ │ ├── index.tsx │ │ │ └── loader.tsx │ │ ├── cursor-pagination/ │ │ │ └── index.tsx │ │ ├── dropdown-menu/ │ │ │ └── index.tsx │ │ ├── favorite/ │ │ │ ├── heart.tsx │ │ │ ├── index.tsx │ │ │ └── styles.css │ │ ├── gift-certificate-card/ │ │ │ ├── gift-certificate-card-logo.tsx │ │ │ └── index.tsx │ │ ├── inline-email-form/ │ │ │ ├── index.tsx │ │ │ └── schema.ts │ │ ├── logo/ │ │ │ └── index.tsx │ │ ├── modal/ │ │ │ └── index.tsx │ │ ├── navigation/ │ │ │ └── index.tsx │ │ ├── price-label/ │ │ │ └── index.tsx │ │ ├── product-card/ │ │ │ ├── compare.tsx │ │ │ └── index.tsx │ │ ├── rating/ │ │ │ └── index.tsx │ │ ├── reveal/ │ │ │ └── index.tsx │ │ ├── side-panel/ │ │ │ └── index.tsx │ │ ├── skeleton/ │ │ │ └── index.tsx │ │ ├── spinner/ │ │ │ └── index.tsx │ │ ├── toaster/ │ │ │ └── index.tsx │ │ ├── tooltip/ │ │ │ └── index.tsx │ │ └── wishlist-item-card/ │ │ ├── index.tsx │ │ ├── remove-wishlist-item.tsx │ │ └── wishlist-item-add-to-cart.tsx │ └── sections/ │ ├── account-settings/ │ │ ├── change-password-form.tsx │ │ ├── index.tsx │ │ ├── newsletter-subscription-form.tsx │ │ ├── schema.ts │ │ └── update-account-form.tsx │ ├── address-list-section/ │ │ ├── index.tsx │ │ └── schema.ts │ ├── blog-post-content/ │ │ └── index.tsx │ ├── blog-post-list/ │ │ └── index.tsx │ ├── breadcrumbs/ │ │ └── index.tsx │ ├── cart/ │ │ ├── client.tsx │ │ ├── coupon-code-form/ │ │ │ ├── coupon-chip.tsx │ │ │ └── index.tsx │ │ ├── gift-certificate-code-form/ │ │ │ ├── gift-certificate-chip.tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── schema.ts │ │ └── shipping-form/ │ │ └── index.tsx │ ├── compare-section/ │ │ └── index.tsx │ ├── dynamic-form-section/ │ │ └── index.tsx │ ├── error/ │ │ └── index.tsx │ ├── featured-blog-post-list/ │ │ └── index.tsx │ ├── featured-product-carousel/ │ │ └── index.tsx │ ├── featured-product-list/ │ │ └── index.tsx │ ├── footer/ │ │ └── index.tsx │ ├── forgot-password-section/ │ │ ├── forgot-password-form.tsx │ │ ├── index.tsx │ │ └── schema.ts │ ├── gift-certificate-balance-section/ │ │ ├── index.tsx │ │ └── schema.ts │ ├── gift-certificate-purchase-section/ │ │ └── index.tsx │ ├── gift-certificates-section/ │ │ └── index.tsx │ ├── header-section/ │ │ └── index.tsx │ ├── maintenance/ │ │ └── index.tsx │ ├── not-found/ │ │ └── index.tsx │ ├── order-details-section/ │ │ └── index.tsx │ ├── order-list/ │ │ └── index.tsx │ ├── product-carousel/ │ │ └── index.tsx │ ├── product-detail/ │ │ ├── actions/ │ │ │ └── revalidate-cart.ts │ │ ├── index.tsx │ │ ├── product-detail-form.tsx │ │ ├── product-gallery.tsx │ │ ├── rating-link.tsx │ │ └── schema.ts │ ├── product-list/ │ │ └── index.tsx │ ├── products-list-section/ │ │ ├── filter-parsers.ts │ │ ├── filters-panel.tsx │ │ ├── index.tsx │ │ └── sorting.tsx │ ├── reset-password-section/ │ │ ├── index.tsx │ │ ├── reset-password-form.tsx │ │ └── schema.ts │ ├── reviews/ │ │ ├── index.tsx │ │ ├── review-form.tsx │ │ └── schema.ts │ ├── section-layout/ │ │ └── index.tsx │ ├── sidebar-menu/ │ │ ├── index.tsx │ │ ├── sidebar-menu-link.tsx │ │ └── sidebar-menu-select.tsx │ ├── sign-in-section/ │ │ ├── index.tsx │ │ ├── schema.ts │ │ └── sign-in-form.tsx │ ├── slideshow/ │ │ └── index.tsx │ ├── sticky-sidebar-layout/ │ │ └── index.tsx │ ├── subscribe/ │ │ └── index.tsx │ ├── wishlist-details/ │ │ └── index.tsx │ ├── wishlist-list/ │ │ └── index.tsx │ ├── wishlist-list-item/ │ │ └── index.tsx │ └── wishlists-section/ │ └── index.tsx ├── graphql.config.json ├── package.json ├── packages/ │ ├── catalyst/ │ │ ├── .eslintrc.cjs │ │ ├── README.md │ │ ├── commander.d.ts │ │ ├── package.json │ │ ├── prettier.config.cjs │ │ ├── src/ │ │ │ └── cli/ │ │ │ ├── commands/ │ │ │ │ ├── build.spec.ts │ │ │ │ ├── build.ts │ │ │ │ ├── deploy.spec.ts │ │ │ │ ├── deploy.ts │ │ │ │ ├── dev.spec.ts │ │ │ │ ├── dev.ts │ │ │ │ ├── project.spec.ts │ │ │ │ ├── project.ts │ │ │ │ ├── start.spec.ts │ │ │ │ ├── start.ts │ │ │ │ ├── telemetry.ts │ │ │ │ ├── version.spec.ts │ │ │ │ └── version.ts │ │ │ ├── hooks/ │ │ │ │ └── telemetry.ts │ │ │ ├── index.spec.ts │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── get-module-cli-path.ts │ │ │ │ ├── logger.ts │ │ │ │ ├── mk-temp-dir.spec.ts │ │ │ │ ├── mk-temp-dir.ts │ │ │ │ ├── project-config.spec.ts │ │ │ │ ├── project-config.ts │ │ │ │ ├── project.ts │ │ │ │ ├── telemetry.ts │ │ │ │ ├── wrangler-config.spec.ts │ │ │ │ └── wrangler-config.ts │ │ │ └── program.ts │ │ ├── templates/ │ │ │ └── open-next.config.ts │ │ ├── tests/ │ │ │ └── mocks/ │ │ │ ├── handlers.ts │ │ │ ├── node.ts │ │ │ └── spinner.ts │ │ ├── tsconfig.json │ │ ├── tsup.config.ts │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── client/ │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── prettier.config.js │ │ ├── src/ │ │ │ ├── api-error.ts │ │ │ ├── client.ts │ │ │ ├── gql-auth-error.ts │ │ │ ├── gql-error.ts │ │ │ ├── index.ts │ │ │ ├── invalid-cat-error.ts │ │ │ ├── lib/ │ │ │ │ └── error.ts │ │ │ ├── missing-cat-error.ts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── getOperationName.ts │ │ │ ├── normalizeQuery.ts │ │ │ ├── removeEdgesAndNodes.ts │ │ │ └── userAgent.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── create-catalyst/ │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── bin/ │ │ │ ├── index.cjs │ │ │ └── supported-node-versions.cjs │ │ ├── jest.config.cjs │ │ ├── package.json │ │ ├── prettier.config.cjs │ │ ├── src/ │ │ │ ├── commands/ │ │ │ │ ├── create.ts │ │ │ │ ├── init.ts │ │ │ │ ├── integration.ts │ │ │ │ └── telemetry.ts │ │ │ ├── hooks/ │ │ │ │ └── telemetry.ts │ │ │ ├── index.ts │ │ │ ├── prompts/ │ │ │ │ └── multi-select/ │ │ │ │ ├── helpers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── multi-select.ts │ │ │ │ └── types.ts │ │ │ └── utils/ │ │ │ ├── auth.ts │ │ │ ├── checkout-ref.ts │ │ │ ├── cli-api.ts │ │ │ ├── clone-catalyst.ts │ │ │ ├── config.ts │ │ │ ├── has-github-ssh.ts │ │ │ ├── https.ts │ │ │ ├── install-dependencies.ts │ │ │ ├── is-exec-exception.ts │ │ │ ├── localization.ts │ │ │ ├── login.ts │ │ │ ├── node-version.spec.ts │ │ │ ├── parse.ts │ │ │ ├── reset-branch-to-ref.ts │ │ │ ├── spinner.spec.ts │ │ │ ├── spinner.ts │ │ │ ├── telemetry/ │ │ │ │ ├── index.ts │ │ │ │ └── telemetry.ts │ │ │ ├── user-agent.ts │ │ │ └── write-env.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ └── eslint-config-catalyst/ │ ├── CHANGELOG.md │ ├── base.js │ ├── next.js │ ├── package.json │ ├── prettier.js │ └── react.js ├── pnpm-workspace.yaml ├── turbo.json └── unlighthouse.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .changeset/cold-foxes-lie.md ================================================ --- "@bigcommerce/catalyst-core": patch --- Fix cart summary Discounts row not showing manual discounts applied via the Management Checkout API ================================================ FILE: .changeset/config.json ================================================ { "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", "changelog": [ "@changesets/changelog-github", { "repo": "bigcommerce/catalyst" } ], "commit": false, "linked": [], "access": "public", "privatePackages": { "version": true, "tag": true }, "baseBranch": "canary", "updateInternalDependencies": "patch", "ignore": ["@bigcommerce/catalyst"] } ================================================ FILE: .changeset/correlation-id-header.md ================================================ --- "@bigcommerce/catalyst-core": patch --- Add X-Correlation-ID header to all GraphQL requests. Each page render gets a stable UUID that persists across all fetches within the same render, enabling easier request tracing in server logs. ================================================ FILE: .changeset/fix-hidden-fields-d35665be.md ================================================ --- "@bigcommerce/catalyst-core": patch --- Fix DynamicForm not rendering hidden field types, which caused `pageEntityId` to be `NaN` on contact form submission. ================================================ FILE: .changeset/fix-html-lang-locale.md ================================================ --- "@bigcommerce/catalyst-core": minor --- Restore locale-aware `lang` attribute on the root `` tag. The previous root layout hardcoded `lang="en"` for all locales; ownership of ``/`` now lives in `app/[locale]/layout.tsx` so `lang={locale}` reflects the active locale. The root `app/layout.tsx` is now a passthrough, and `app/not-found.tsx` is self-sufficient (renders its own ``/``) to preserve the branded 404 for non-localized requests. ================================================ FILE: .changeset/translations-patch-d3abeec7.md ================================================ --- "@bigcommerce/catalyst-core": patch --- Update translations. ================================================ FILE: .changeset/translations-patch-e3d3b994.md ================================================ --- "@bigcommerce/catalyst-core": patch --- Update translations. ================================================ FILE: .claude/skills/release-catalyst/SKILL.md ================================================ --- name: release-catalyst description: > Cut a new release of Catalyst (`@bigcommerce/catalyst-core` and `@bigcommerce/catalyst-makeswift`). Use when the user says "/release-catalyst", "cut a release", "release catalyst", or asks to publish new versions of the Catalyst packages. This skill orchestrates the full two-stage release process: merging the Version Packages PR on canary, syncing integrations/makeswift, and pushing @latest tags. --- # Release Catalyst Execute stages in order. Pause for user input where indicated. ## Stage 1: Cut release from `canary` ### 1a. Find and merge the Version Packages PR ```bash gh pr list --search "Version Packages (canary)" --state open --json number,title,reviews,mergeable ``` - If **no open PR** exists, inform the user that there are no pending changesets on `canary` and stop. - If the PR is **approved and checks are passing**, merge it: `gh pr merge --squash` - If the PR is **not approved or checks are not passing**, tell the user and wait. - Bot-opened PRs often don't trigger CI. If checks aren't running, push an empty commit to trigger them: ```bash git checkout --track origin/changeset-release/canary git commit --allow-empty -m "chore: trigger CI" git push origin changeset-release/canary git checkout canary && git branch -D changeset-release/canary ``` - **Stop here.** Wait for the user to confirm checks pass and the PR is approved before merging. ### 1b. Verify the release After the PR merges: ```bash git fetch origin --tags ``` Determine the new `@bigcommerce/catalyst-core` version from the PR body (look for `## @bigcommerce/catalyst-core@X.Y.Z`). Then verify: ```bash gh release view @bigcommerce/catalyst-core@ --json tagName,name,isDraft,isPrerelease ``` If the release and tag don't exist yet, wait briefly and retry — the Changesets action may still be running. Record the **version number** and **bump type** (patch/minor/major) for use in Stage 2. ## Stage 2: Sync and release `integrations/makeswift` ### 2a. Sync branches Invoke the `/sync-makeswift` skill, with one addition: during the sync (after merge, before pushing), also add a changeset for `@bigcommerce/catalyst-makeswift`: **Determine bump type**: Match the bump type from Stage 1 (e.g., if core went `1.4.2` → `1.5.0`, that's a `minor`). **Create changeset file** (`.changeset/sync-canary-.md`, where `` uses hyphens instead of dots — e.g., `1.6.0` → `sync-canary-1-6-0.md`). Changeset filenames only allow lowercase letters and hyphens; dots are invalid. ```markdown --- "@bigcommerce/catalyst-makeswift": --- Pulls in changes from the `@bigcommerce/catalyst-core@` release. For more information about what was included in the `@bigcommerce/catalyst-core@` release, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob//core/CHANGELOG.md#). ``` Where: - `` is the merge commit SHA on canary (from the Version Packages merge) - `` is the version with dots removed (e.g., `1.5.0` → `150`) Include this changeset in the merge commit (amend if needed) alongside the normal sync work. ### 2b. Merge the Version Packages (`integrations/makeswift`) PR After the sync lands, the Changesets action will open a "Version Packages (`integrations/makeswift`)" PR. ```bash gh pr list --search "Version Packages (integrations/makeswift)" --state open --json number,title ``` Same flow as Stage 1a: - If checks aren't running (bot PR), push an empty commit to trigger CI, then **drop it before merging** by resetting to the parent and force-pushing. - Once approved and green, merge with `gh pr merge --squash`. - Note: squash merging is normally disallowed on `integrations/makeswift` to preserve merge bases for sync PRs. The user may need to temporarily enable squash merging in the branch protection rules for this step, then re-disable it after. ### 2c. Verify the makeswift release ```bash git fetch origin --tags gh release view @bigcommerce/catalyst-makeswift@ --json tagName,name,isDraft,isPrerelease ``` ## Stage 3: Push `@latest` tags Update both `@latest` tags to point to the new releases: ```bash git fetch origin --tags git tag @bigcommerce/catalyst-core@latest @bigcommerce/catalyst-core@ -f git tag @bigcommerce/catalyst-makeswift@latest @bigcommerce/catalyst-makeswift@ -f git push origin @bigcommerce/catalyst-core@latest -f git push origin @bigcommerce/catalyst-makeswift@latest -f ``` Confirm both tags were pushed successfully. ## Stage 4: Cleanup ```bash git checkout canary git pull ``` Delete any leftover local branches (`changeset-release/*`, `sync-integrations-makeswift`, `integrations/makeswift`). Report the final state: both package versions released, tags updated, branches cleaned up. ================================================ FILE: .claude/skills/release-catalyst-patch/SKILL.md ================================================ --- name: release-catalyst-patch description: > Release a single Catalyst package patch in isolation, without bundling it with other queued changesets in the open Version Packages PR. Use when the user says "/release-catalyst-patch", "isolate a patch release", "publish only one package", or wants to ship a single package's changeset ahead of the normal release cadence. Performs the full flow locally (`changeset version`, build, `changeset publish`, push, GitHub release) so the remaining queued changesets stay untouched in the Version Packages PR. --- # Release Catalyst Patch (single-package isolation) The Changesets GitHub Action picks one mode per push to `canary`: if any unconsumed changesets exist, it opens/refreshes the Version Packages PR and **does not** publish. That's why we publish locally — we keep the other changesets in `.changeset/` so the Version Packages PR still tracks them, but we ship just the one we want now. Execute stages in order. Pause for user input where indicated. **Never execute the `changeset publish` command yourself** — provide it to the user to run. ## Stage 0: Confirm scope Identify the changeset to release and the target package + version. ```bash ls .changeset/ git log --oneline -10 # find the PR/commit that added the changeset cat .changeset/.md # confirm the package + bump type npm view version dist-tags # confirm current published state ``` Confirm with the user: - Which `.changeset/*.md` file is being released - The target package and the resulting version (e.g., `1.0.2` → `1.0.3`) - That **all other** changesets in `.changeset/` should remain queued for the next regular release If the package has notes in the [Package-specific notes](#package-specific-notes) section below, review them before continuing. ## Stage 1: Branch + quarantine Work on a release branch so the workflow doesn't trigger mid-flow. ```bash git switch canary && git pull git switch -c release/- ``` Move the **other** changesets **outside** the repo. A subdirectory inside `.changeset/` gets parsed as a malformed changeset by the CLI and crashes `changeset version`: ```bash mkdir -p /tmp/changeset-hold mv .changeset/.md .changeset/.md ... /tmp/changeset-hold/ ls .changeset/ # should leave only config.json + the one changeset to release ``` ## Stage 2: Run `changeset version` Requires `GITHUB_TOKEN` because the repo uses `@changesets/changelog-github`. The `gh` CLI token works. ```bash pnpm install --frozen-lockfile GITHUB_TOKEN=$(gh auth token) pnpm changeset version ``` Restore the held changesets immediately: ```bash mv /tmp/changeset-hold/*.md .changeset/ rmdir /tmp/changeset-hold ``` Verify the diff: ```bash git status git diff packages//package.json packages//CHANGELOG.md ``` Expect: bumped `package.json`, new CHANGELOG entry, deleted only the one changeset. The other changesets should be back in `.changeset/` and untouched. ## Stage 3: Build Run the root build. Turborepo handles env passthrough from `.env.local` via the pipeline config, so package builds get the variables they need without manual sourcing. ```bash pnpm build ``` If a package has build-time secrets that must be present, see the [Package-specific notes](#package-specific-notes) section for verification steps. ## Stage 4: Commit ```bash git add -A git commit -m "Version Packages (\`canary\`)" ``` Match the message format the changesets bot uses, since this is a manual stand-in for it. ## Stage 5: Hand the publish command to the user **Do not run `changeset publish` from the agent.** Publishing to npm is a destructive, externally-visible action that should be performed by the user. Have the user confirm they're logged in, then run the publish themselves. The CLI will prompt them interactively for the npm OTP. ```bash npm whoami # expect their npm username pnpm exec changeset publish # prompts for OTP interactively ``` `changeset publish` is per-package version-aware: it only publishes packages whose local `package.json` is ahead of npm. Since only one package was bumped, only it ships. It also creates a local git tag like `@bigcommerce/@`. Wait for the user to confirm the publish completed before continuing. ## Stage 6: Verify npm state ```bash npm view version npm view dist-tags ``` `latest` should point to the new version. If it doesn't (rare — only happens if `publishConfig.tag` is set), advise the user to fix with: ```bash npm dist-tag add @ latest ``` ## Stage 7: Fast-forward canary and push This requires a direct push to the protected default branch. **Pause and ask for explicit user authorization** before pushing — the user's "never push directly to main/master/production" rule guards against this even though the changesets bot does the same thing during normal releases. ```bash git switch canary git fetch origin canary git log --oneline origin/canary..canary # should be 1 ahead git log --oneline canary..origin/canary # should be 0 behind git merge --ff-only release/- ``` If you're 1 ahead and 0 behind, default `git push` rejects non-fast-forward updates, which gives the same safety as `--ff-only` on the push side. Apple's git build (≤2.50.x) does not accept `--ff-only` as a push flag — `git push origin canary` is correct here. If a hook blocks the agent push, hand these commands to the user: ```bash git push origin canary git push origin "@bigcommerce/@" ``` ## Stage 8: Create the GitHub release manually CI runs `changeset publish` after the canary push, finds the version already on npm, and no-ops. Because the action only creates GitHub releases for packages it actually publishes, **no release is created automatically** in this flow. Make it manually. Extract the new CHANGELOG section (everything after the `## ` heading up to the next `## ` heading) into a notes file. Then: ```bash gh release create "@bigcommerce/@" \ --repo bigcommerce/catalyst \ --title "@bigcommerce/@" \ --notes-file /tmp/release-notes.md ``` Match the body format of prior releases — just the `### Patch Changes` / `### Minor Changes` heading and the bullets, no version heading at the top. Compare against an existing release: ```bash gh release view "@bigcommerce/catalyst-core@" --repo bigcommerce/catalyst --json body ``` ## Stage 9: Final validation ```bash git fetch origin canary git log --oneline origin/canary -3 # version commit on canary git ls-remote --tags origin "@bigcommerce/@" # tag on origin gh release view "@bigcommerce/@" --repo bigcommerce/catalyst --json tagName,isDraft,isPrerelease gh run list --workflow=changesets-release.yml --limit 1 # CI run succeeded gh pr list --search "Version Packages (canary)" --state open --json number,headRefName,updatedAt # Version Packages PR refreshed git fetch origin changeset-release/canary git show --stat origin/changeset-release/canary | head -20 # confirm only the other changesets remain npm view version dist-tags ``` Report: - Published version + dist-tag - Canary commit SHA - Tag pushed - GitHub release URL - Confirmation that the Version Packages PR now contains **only** the other changesets — the released one has been dropped from its scope. ## Stage 10: Cleanup ```bash git branch -d release/- ``` ## Package-specific notes ### `@bigcommerce/create-catalyst` The CLI build (`tsup` via the package's `tsup.config.ts`) inlines `CLI_SEGMENT_WRITE_KEY` at build time, falling back to the placeholder `'not-a-valid-segment-write-key'` if the env var is missing. After Stage 3, verify the real key was embedded: ```bash grep -c "not-a-valid-segment-write-key" packages/create-catalyst/dist/index.js # expect: 0 ``` If `1`, the env var wasn't loaded. Confirm `CLI_SEGMENT_WRITE_KEY` exists in `.env.local`, and that the turbo pipeline for `build` declares it under `env` or `passThroughEnv` in `turbo.json`. Re-run `pnpm build` after fixing. ================================================ FILE: .claude/skills/sync-makeswift/SKILL.md ================================================ --- name: sync-makeswift description: > Sync the `integrations/makeswift` branch with `canary` in the Catalyst monorepo. Use when the user says "/sync-makeswift", "sync makeswift", "sync integrations/makeswift", or asks to bring `integrations/makeswift` up to date with `canary`. --- # Sync `integrations/makeswift` with `canary` Execute the following phases in order. Pause for user input where indicated. ## Phase 1: Prepare and merge ```bash git fetch origin git checkout -B sync-integrations-makeswift origin/integrations/makeswift git merge origin/canary ``` If the merge completes cleanly, skip to changeset cleanup. Otherwise, resolve conflicts. ### Conflict resolution rules - `core/package.json`: the `name` field MUST stay `@bigcommerce/catalyst-makeswift`. The `version` field MUST stay at the latest published `@bigcommerce/catalyst-makeswift` version (check what's on `origin/integrations/makeswift`, not `canary`). - `core/CHANGELOG.md`: the latest release entry MUST match the latest published `@bigcommerce/catalyst-makeswift` version. - `pnpm-lock.yaml`: accept canary's version (`git checkout --theirs pnpm-lock.yaml`), then regenerate with `pnpm install --no-frozen-lockfile`. - For all other conflicts, prefer canary's structure/patterns while preserving makeswift-specific additions (imports, components, config). After resolving all conflicts, stage everything and verify no unresolved conflicts remain: ```bash git add git diff --name-only --diff-filter=U # should return empty ``` ### Changeset cleanup Remove any `.changeset/*.md` files that do NOT target `@bigcommerce/catalyst-makeswift`. Read each changeset file and delete any that reference `@bigcommerce/catalyst-core` or other packages. Amend the removals into the merge commit. ### Commit the merge ```bash git commit --no-edit ``` If changesets were removed after the initial commit, amend them in (`git commit --amend --no-edit`) rather than creating a separate commit. ## Phase 2: Push and open PR ```bash git push origin sync-integrations-makeswift ``` Open a PR into `integrations/makeswift` (not `canary`): - Title: `sync \`integrations/makeswift\` with \`canary\`` - Body: summarize what came from canary, list conflict resolutions, and include this notice: > **Do not squash or rebase-and-merge this PR.** Use a true merge commit or rebase locally to preserve the merge base between `canary` and `integrations/makeswift`. **Stop here.** Tell the user the PR is ready for review and wait for them to confirm approval before continuing. ## Phase 3: Rebase and push (after PR approval) ```bash git fetch origin git checkout -B integrations/makeswift origin/integrations/makeswift git rebase sync-integrations-makeswift git push origin integrations/makeswift --force-with-lease ``` This closes the PR automatically. Confirm with the user that the push succeeded and the PR closed. ## Phase 4: Cleanup Switch back to `canary` and delete the local branches that are no longer needed: ```bash git checkout canary git pull git branch -D sync-integrations-makeswift integrations/makeswift ``` ================================================ FILE: .github/CODEOWNERS ================================================ * @bigcommerce/team-trac ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Have questions? url: https://github.com/bigcommerce/catalyst/blob/canary/README.md about: Explore the Catalyst Docs. - name: Need help with Catalyst? url: https://github.com/bigcommerce/catalyst/discussions/new?category=q-a about: If you can't get something to work the way you expect, join us in Discussions to browse existing topics or to share a new post. - name: Feature Request url: https://github.com/bigcommerce/catalyst/discussions/categories/feature-requests about: Join us in Discussions to share your idea on improving Catalyst. Thanks for your contribution! - name: Official BigCommerce Support url: https://support.bigcommerce.com/contact about: To report a platform bug, outage or greater platform issue, contact our Technical Support team. If you're a partner, please do so via your Partner Portal. - name: 💙 Join the BigCommerceDevs Community url: https://developer.bigcommerce.com/community about: Connect with other devs building on BigCommerce! ================================================ FILE: .github/ISSUE_TEMPLATE/🐞📝-bug-report-makeswift.md ================================================ --- name: "\U0001F41E\U0001F4DD Makeswift Bug report" about: You're running into a reproducible error while developing with Catalyst and Makeswift. title: '[x] is not working when I [y]' labels: '' assignees: '' --- We really appreciate the help making Catalyst and Makeswift better. Every issue helps! **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error Please link to a repo that can be used to reproduce this issue, if possible. It'll help fix the bug faster. **Previously working?** Was this functionality previously working? If so, please link to a commit or PR that caused it to stop working. **Any Errors?** Were there any errors that surfaced when merging the above PR? **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/🐞📝-bug-report.md ================================================ --- name: "\U0001F41E\U0001F4DD Bug report" about: You're running into a reproducible error while developing with Catalyst. title: "[x] is not working when I [y]" labels: '' assignees: '' --- We really appreciate the help making Catalyst better. Every issue helps! **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error Please link to a repo that can be used to reproduce this issue, if possible. It'll help fix the bug faster. **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Additional context** Add any other context about the problem here. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: 'npm' directory: '/' schedule: interval: 'weekly' day: 'monday' open-pull-requests-limit: 1 groups: npm-dependencies: patterns: - "*" ignore: - dependency-name: '@types/node' update-types: ['version-update:semver-major'] - dependency-name: 'eslint' update-types: ['version-update:semver-major'] # Disabling tailwind due to browser compatibility constraints. - dependency-name: 'tailwindcss' update-types: ['version-update:semver-major'] ================================================ FILE: .github/pull_request_template.md ================================================ ## What/Why? ## Testing ## Migration ================================================ FILE: .github/scripts/__tests__/audit-unlighthouse.test.mts ================================================ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { buildReport } from '../audit-unlighthouse.mts'; import type { CiResult } from '../audit-unlighthouse.mts'; // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const DEFAULT_METRICS: CiResult['summary']['metrics'] = { 'largest-contentful-paint': { displayValue: '2.5 s' }, 'cumulative-layout-shift': { displayValue: '0.01' }, 'first-contentful-paint': { displayValue: '1.2 s' }, 'total-blocking-time': { displayValue: '100 ms' }, 'max-potential-fid': { displayValue: '200 ms' }, interactive: { displayValue: '3.5 s' }, }; function makeCiResult(overrides: { score?: number; performance?: number; accessibility?: number; 'best-practices'?: number; seo?: number; metrics?: CiResult['summary']['metrics']; } = {}): CiResult { return { summary: { score: overrides.score ?? 0.85, categories: { performance: { score: overrides.performance ?? 0.80 }, accessibility: { score: overrides.accessibility ?? 0.92 }, 'best-practices': { score: overrides['best-practices'] ?? 1.0 }, seo: { score: overrides.seo ?? 0.90 }, }, metrics: overrides.metrics ?? { ...DEFAULT_METRICS }, }, }; } const BASE = makeCiResult(); // --------------------------------------------------------------------------- // Report heading // --------------------------------------------------------------------------- describe('report heading', () => { it('contains the audit heading', () => { const markdown = buildReport(BASE, BASE); assert.ok(markdown.includes('## Unlighthouse Audit'), 'Missing main heading'); }); it('appends branch label when branch is given', () => { const markdown = buildReport(BASE, BASE, 'canary'); assert.ok( markdown.includes('## Unlighthouse Audit — `canary`'), 'Missing branch label in heading', ); }); it('handles branch names with slashes', () => { const markdown = buildReport(BASE, BASE, 'integrations/makeswift'); assert.ok(markdown.includes('`integrations/makeswift`')); }); it('omits branch label when none provided', () => { const markdown = buildReport(BASE, BASE); assert.ok(!markdown.includes(' — '), 'Should not contain a branch label separator'); }); it('contains the description text', () => { const markdown = buildReport(BASE, BASE); assert.ok(markdown.includes('Unlighthouse scores for the latest commit on this branch.')); }); }); // --------------------------------------------------------------------------- // Summary Score section // --------------------------------------------------------------------------- describe('Summary Score section', () => { it('contains the Summary Score heading', () => { const markdown = buildReport(BASE, BASE); assert.ok(markdown.includes('### Summary Score')); }); it('contains the aggregate score note', () => { const markdown = buildReport(BASE, BASE); assert.ok(markdown.includes('Aggregate score across all categories as reported by Unlighthouse.')); }); it('renders scores as integers on a 1-100 scale', () => { const desktop = makeCiResult({ score: 0.85 }); const mobile = makeCiResult({ score: 0.72 }); const markdown = buildReport(desktop, mobile); assert.ok(markdown.includes('| Score | 85 | 72 |')); }); it('rounds fractional scores correctly', () => { const desktop = makeCiResult({ score: 0.856 }); // rounds to 86 const markdown = buildReport(desktop, BASE); assert.ok(markdown.includes('86'), 'Score 0.856 should round to 86'); }); it('contains the two-column header', () => { const markdown = buildReport(BASE, BASE); assert.ok(markdown.includes('| | Desktop | Mobile |')); }); }); // --------------------------------------------------------------------------- // Category Scores section // --------------------------------------------------------------------------- describe('Category Scores section', () => { it('contains the Category Scores heading', () => { const markdown = buildReport(BASE, BASE); assert.ok(markdown.includes('### Category Scores')); }); it('renders all four categories', () => { const markdown = buildReport(BASE, BASE); assert.ok(markdown.includes('Performance')); assert.ok(markdown.includes('Accessibility')); assert.ok(markdown.includes('Best Practices')); assert.ok(markdown.includes('SEO')); }); it('renders desktop and mobile scores independently', () => { const desktop = makeCiResult({ performance: 0.80 }); const mobile = makeCiResult({ performance: 0.93 }); const markdown = buildReport(desktop, mobile); assert.ok( markdown.includes('| Performance | 80 | 93 |'), 'Performance row should show desktop then mobile score', ); }); it('shows all four categories independently', () => { const desktop = makeCiResult({ seo: 0.88, accessibility: 0.75 }); const mobile = makeCiResult({ seo: 0.91, accessibility: 0.82 }); const markdown = buildReport(desktop, mobile); assert.ok(markdown.includes('| SEO | 88 | 91 |')); assert.ok(markdown.includes('| Accessibility | 75 | 82 |')); }); }); // --------------------------------------------------------------------------- // Core Web Vitals section // --------------------------------------------------------------------------- describe('Core Web Vitals section', () => { it('contains the Core Web Vitals heading', () => { const markdown = buildReport(BASE, BASE); assert.ok(markdown.includes('### Core Web Vitals')); }); it('renders all six metrics', () => { const markdown = buildReport(BASE, BASE); assert.ok(markdown.includes('LCP')); assert.ok(markdown.includes('CLS')); assert.ok(markdown.includes('FCP')); assert.ok(markdown.includes('TBT')); assert.ok(markdown.includes('Max Potential FID')); assert.ok(markdown.includes('Time to Interactive')); }); it('passes displayValue through unchanged', () => { const desktop = makeCiResult({ metrics: { ...DEFAULT_METRICS, 'largest-contentful-paint': { displayValue: '4.8 s' } }, }); const markdown = buildReport(desktop, BASE); assert.ok(markdown.includes('4.8 s')); }); it('shows — for a metric missing from a result', () => { const desktopMissingMetric = makeCiResult({ metrics: {} }); const markdown = buildReport(desktopMissingMetric, BASE); assert.ok(markdown.includes('—'), 'Missing metric should show —'); }); it('shows desktop and mobile displayValues per metric row', () => { const desktop = makeCiResult({ metrics: { ...DEFAULT_METRICS, 'total-blocking-time': { displayValue: '80 ms' } } }); const mobile = makeCiResult({ metrics: { ...DEFAULT_METRICS, 'total-blocking-time': { displayValue: '320 ms' } } }); const markdown = buildReport(desktop, mobile); assert.ok(markdown.includes('| TBT | 80 ms | 320 ms |')); }); }); ================================================ FILE: .github/scripts/__tests__/bundle-size.test.mts ================================================ 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'); }); }); ================================================ FILE: .github/scripts/__tests__/compare-unlighthouse.test.mts ================================================ 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 |')); }); }); ================================================ FILE: .github/scripts/__tests__/post-bundle-comment.test.mts ================================================ 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); }); }); ================================================ FILE: .github/scripts/__tests__/post-unlighthouse-commit-comment.test.mts ================================================ import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const postReport = require('../post-unlighthouse-commit-comment.js') as (args: { github: ReturnType['github']; context: ReturnType; reportPath?: string; }) => Promise; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- interface Calls { createCommitComment: object[]; } function makeGithub() { const calls: Calls = { createCommitComment: [] }; const github = { rest: { repos: { createCommitComment: async (args: object) => { calls.createCommitComment.push(args); }, }, }, }; return { github, calls }; } function makeContext({ owner = 'test-owner', repo = 'test-repo', sha = 'abc123', runId = 99, }: { owner?: string; repo?: string; sha?: string; runId?: number; } = {}) { return { repo: { owner, repo }, runId, payload: { deployment: { sha }, }, }; } let reportPath: string; beforeEach(() => { const tmpDir = join(tmpdir(), `post-unlighthouse-report-test-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); reportPath = join(tmpDir, 'report.md'); writeFileSync(reportPath, '## Unlighthouse Audit — `canary`\n\nSome results.'); }); // --------------------------------------------------------------------------- // Early exits // --------------------------------------------------------------------------- describe('early exits', () => { it('does nothing when deployment sha is missing', async () => { const { github, calls } = makeGithub(); const context = { ...makeContext(), payload: {} }; await postReport({ github, context, reportPath }); assert.equal(calls.createCommitComment.length, 0); }); }); // --------------------------------------------------------------------------- // Commit comment creation // --------------------------------------------------------------------------- describe('commit comment creation', () => { it('creates a commit comment with the deployment sha', async () => { const { github, calls } = makeGithub(); await postReport({ github, context: makeContext({ sha: 'deadbeef' }), reportPath }); assert.equal(calls.createCommitComment.length, 1); assert.equal( (calls.createCommitComment[0] as { commit_sha: string }).commit_sha, 'deadbeef', ); }); it('passes the correct owner and repo', async () => { const { github, calls } = makeGithub(); await postReport({ github, context: makeContext({ owner: 'my-org', repo: 'my-repo' }), reportPath, }); assert.equal((calls.createCommitComment[0] as { owner: string }).owner, 'my-org'); assert.equal((calls.createCommitComment[0] as { repo: string }).repo, 'my-repo'); }); }); // --------------------------------------------------------------------------- // Comment body // --------------------------------------------------------------------------- describe('comment body', () => { it('starts with the canary marker', async () => { const { github, calls } = makeGithub(); await postReport({ github, context: makeContext(), reportPath }); const body = (calls.createCommitComment[0] as { body: string }).body; assert.ok( body.startsWith('\n'), `Body should start with marker, got: ${body.slice(0, 60)}`, ); }); it('includes the report file content', async () => { const { github, calls } = makeGithub(); await postReport({ github, context: makeContext(), reportPath }); const body = (calls.createCommitComment[0] as { body: string }).body; assert.ok(body.includes('## Unlighthouse Audit')); assert.ok(body.includes('Some results.')); }); it('includes the workflow run link', async () => { const { github, calls } = makeGithub(); await postReport({ github, context: makeContext({ owner: 'my-org', repo: 'my-repo', runId: 12345 }), reportPath, }); const body = (calls.createCommitComment[0] as { body: string }).body; assert.ok( body.includes('https://github.com/my-org/my-repo/actions/runs/12345'), 'Body should contain the workflow run URL', ); }); it('run link is preceded by a newline', async () => { const { github, calls } = makeGithub(); await postReport({ github, context: makeContext(), reportPath }); const body = (calls.createCommitComment[0] as { body: string }).body; const linkIndex = body.indexOf('[Full Unlighthouse report'); assert.ok(linkIndex > 0, 'Run link should be present'); assert.equal(body[linkIndex - 1], '\n', 'Run link should be preceded by a newline'); }); }); ================================================ FILE: .github/scripts/__tests__/post-unlighthouse-pr-comment.test.mts ================================================ 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-pr-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', ); }); }); ================================================ FILE: .github/scripts/audit-unlighthouse.mts ================================================ #!/usr/bin/env node /* eslint-disable no-console, no-restricted-syntax, no-plusplus */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { resolve } from "node:path"; interface CiResult { summary: { score: number; categories: Record; metrics: Record; }; } function loadCiResult(filePath: string): CiResult { if (!existsSync(filePath)) { console.error(`Error: file not found: ${filePath}`); process.exit(1); } return JSON.parse(readFileSync(filePath, "utf-8")) as CiResult; } function score(value: number): string { return String(Math.round(value * 100)); } function row(label: string, desktop: string, mobile: string): string { return `| ${label} | ${desktop} | ${mobile} |`; } const CATEGORY_ORDER = ["performance", "accessibility", "best-practices", "seo"]; const CATEGORY_LABELS: Record = { performance: "Performance", accessibility: "Accessibility", "best-practices": "Best Practices", seo: "SEO", }; const METRIC_ORDER = [ "largest-contentful-paint", "cumulative-layout-shift", "first-contentful-paint", "total-blocking-time", "max-potential-fid", "interactive", ]; const METRIC_LABELS: Record = { "largest-contentful-paint": "LCP", "cumulative-layout-shift": "CLS", "first-contentful-paint": "FCP", "total-blocking-time": "TBT", "max-potential-fid": "Max Potential FID", interactive: "Time to Interactive", }; function buildReport( desktop: CiResult, mobile: CiResult, branch?: string, ): string { const branchLabel = branch ? ` — \`${branch}\`` : ""; const lines: string[] = []; lines.push(`## Unlighthouse Audit${branchLabel}`); lines.push("Unlighthouse scores for the latest commit on this branch."); lines.push(""); lines.push("### Summary Score"); lines.push( "_Aggregate score across all categories as reported by Unlighthouse._", ); lines.push(""); lines.push("| | Desktop | Mobile |"); lines.push("|:-|:--------|:-------|"); lines.push(row("Score", score(desktop.summary.score), score(mobile.summary.score))); lines.push(""); lines.push("### Category Scores"); lines.push(""); lines.push("| Category | Desktop | Mobile |"); lines.push("|:---------|:--------|:-------|"); for (const id of CATEGORY_ORDER) { lines.push( row( CATEGORY_LABELS[id] ?? id, score(desktop.summary.categories[id]?.score ?? 0), score(mobile.summary.categories[id]?.score ?? 0), ), ); } lines.push(""); lines.push("### Core Web Vitals"); lines.push(""); lines.push("| Metric | Desktop | Mobile |"); lines.push("|:-------|:--------|:-------|"); for (const id of METRIC_ORDER) { lines.push( row( METRIC_LABELS[id] ?? id, desktop.summary.metrics[id]?.displayValue ?? "—", mobile.summary.metrics[id]?.displayValue ?? "—", ), ); } lines.push(""); return lines.join("\n"); } export { buildReport }; export type { CiResult }; const isMain = process.argv[1] === fileURLToPath(import.meta.url); if (isMain) { const { values } = parseArgs({ options: { desktop: { type: "string" }, mobile: { type: "string" }, branch: { type: "string" }, output: { type: "string" }, }, }); if (!values.desktop || !values.mobile) { console.error( "Usage: report-unlighthouse.mts --desktop --mobile [--branch ] [--output ]", ); process.exit(1); } const desktop = loadCiResult(resolve(values.desktop)); const mobile = loadCiResult(resolve(values.mobile)); const markdown = buildReport(desktop, mobile, values.branch); const outputPath = values.output ? resolve(values.output) : null; if (outputPath) { writeFileSync(outputPath, markdown); console.error(`Unlighthouse report written to ${outputPath}`); } else { process.stdout.write(markdown); } } ================================================ FILE: .github/scripts/bundle-size.mts ================================================ #!/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); } } ================================================ FILE: .github/scripts/compare-unlighthouse.mts ================================================ #!/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}`); } } ================================================ FILE: .github/scripts/post-bundle-comment.js ================================================ 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, }); } }; ================================================ FILE: .github/scripts/post-unlighthouse-commit-comment.js ================================================ const fs = require('fs'); module.exports = async ({ github, context, reportPath = '/tmp/unlighthouse-report.md' }) => { const sha = context.payload.deployment?.sha; if (!sha) return; const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const marker = ``; const body = marker + '\n' + fs.readFileSync(reportPath, 'utf-8') + `\n[Full Unlighthouse report →](${runUrl})\n`; await github.rest.repos.createCommitComment({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: sha, body, }); }; ================================================ FILE: .github/scripts/post-unlighthouse-pr-comment.js ================================================ 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, }); } }; ================================================ FILE: .github/scripts/prevent-invalid-changesets.js ================================================ const fs = require("fs"); module.exports = async ({ core, exec }) => { try { await exec.exec("git", [ "fetch", "https://github.com/bigcommerce/catalyst.git", "integrations/makeswift", ]); const { stdout } = await exec.getExecOutput("git", [ "diff", "--name-only", `origin/integrations/makeswift...HEAD`, ]); const allFilenames = stdout.split("\n").filter((line) => line.trim()); const changesetFilenames = allFilenames.filter( (file) => file.startsWith(".changeset/") && file.endsWith(".md"), ); if (changesetFilenames.length === 0) { core.info("No changeset files found to validate"); return; } core.info(`Found ${changesetFilenames.length} changeset files to validate`); for (const filename of changesetFilenames) { core.info(`Checking ${filename}...`); // .changeset/*.md filenames should only contain alphanumeric characters, hyphens, and underscores if (!/^\.changeset\/[a-zA-Z0-9_-]+\.md$/.test(filename)) { core.setFailed(`Invalid filename pattern: ${filename}`); return; } // extra defense against path traversal attacks if ( filename.includes("..") || (filename.includes("/") && !filename.startsWith(".changeset/")) ) { core.setFailed(`Suspicious file path: ${filename}`); return; } if (!fs.existsSync(filename)) { core.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) const stats = fs.statSync(filename); if (stats.size > 102400) { core.error(`File too large`, { file: filename }); core.setFailed(`File ${filename} is too large`); return; } if (stats.isSymbolicLink()) { core.error(`Symlinks are not allowed`, { file: filename }); core.setFailed(`File ${filename} is a symlink`); return; } const content = fs.readFileSync(filename, "utf8"); // starts with "---", captures everything until the next "---" const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); if (!frontmatterMatch) { core.error(`Failed to extract frontmatter or file has no frontmatter`, { file: filename, }); core.setFailed(`File ${filename} has invalid or missing frontmatter`); return; } const frontmatter = frontmatterMatch[1]; // extract all packages starting with "@bigcommerce/ const packageMatches = frontmatter.match(/"@bigcommerce\/[^"]+"/g); if (packageMatches) { const invalidPackages = packageMatches.filter( (pkg) => pkg !== '"@bigcommerce/catalyst-makeswift"', ); if (invalidPackages.length > 0) { core.error( `Invalid package found in changeset file. Only @bigcommerce/catalyst-makeswift is allowed.`, { file: filename }, ); core.setFailed( `File ${filename} contains invalid packages: ${invalidPackages.join( ", ", )}`, ); return; } } } core.info("All changeset files validated successfully"); } catch (error) { core.setFailed(`Validation failed: ${error.message}`); } }; ================================================ FILE: .github/workflows/basic.yml ================================================ name: Basic on: push: branches: [canary, integrations/makeswift, integrations/b2b-makeswift] pull_request: types: [opened, synchronize] merge_group: types: [checks_requested] 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 }} jobs: lint-typecheck: name: Lint, Typecheck, and gql.tada runs-on: ubuntu-latest 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: Gql Tada run: pnpm run -r generate - name: Lint run: pnpm run lint -- --max-warnings=0 - name: Typecheck run: pnpm run typecheck cli-tests: name: CLI Tests strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} 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: Run Tests run: pnpm run test ================================================ FILE: .github/workflows/bundle-size.yml ================================================ 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 }} 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 }) ================================================ FILE: .github/workflows/changesets-release.yml ================================================ name: Changesets Release on: push: branches: - canary - integrations/makeswift concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: id-token: write contents: write packages: write pull-requests: write jobs: changesets-release: name: Changesets Release runs-on: ubuntu-latest steps: - name: Checkout Repo uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v3 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: ".nvmrc" cache: "pnpm" - name: Install Dependencies run: pnpm install --frozen-lockfile - name: Build Packages run: pnpm --filter "./packages/**" build env: CLI_SEGMENT_WRITE_KEY: ${{ secrets.CLI_SEGMENT_WRITE_KEY }} - name: Create Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 with: publish: pnpm exec changeset publish title: "Version Packages (`${{ github.ref_name }}`)" commit: "Version Packages (`${{ github.ref_name }}`)" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/deploy.yml ================================================ 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" ================================================ FILE: .github/workflows/e2e.yml ================================================ 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 }} 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 ================================================ FILE: .github/workflows/native-hosting.yml ================================================ name: Native Hosting on: push: branches: [canary] jobs: build-and-deploy: name: Build and Deploy runs-on: ubuntu-latest concurrency: group: ${{ github.workflow }} steps: - name: Checkout code uses: actions/checkout@v4 - uses: pnpm/action-setup@v3 - name: Use Node.js uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile - name: Install Catalyst CLI run: pnpm add @bigcommerce/catalyst@alpha @opennextjs/cloudflare@1.17.3 working-directory: core - name: Convert proxy.ts to middleware.ts working-directory: core run: | mv proxy.ts middleware.ts sed -i 's/export const proxy/export const middleware/' middleware.ts sed -i "s/export const config = {/export const config = {\n runtime: 'experimental-edge',/" middleware.ts - name: Build monorepo packages run: pnpm --filter "./packages/*" build - name: Generate GraphQL types run: pnpm run generate working-directory: core env: BIGCOMMERCE_STORE_HASH: ${{ vars.NATIVE_HOSTING_BIGCOMMERCE_STORE_HASH }} BIGCOMMERCE_STOREFRONT_TOKEN: ${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_STOREFRONT_TOKEN }} BIGCOMMERCE_CHANNEL_ID: ${{ vars.NATIVE_HOSTING_BIGCOMMERCE_CHANNEL_ID }} - name: Build run: pnpm exec catalyst build working-directory: core env: # CLI env vars CATALYST_PROJECT_UUID: ${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_PROJECT_UUID }} # App env vars (needed by Next.js build for GraphQL calls in next.config.ts) BIGCOMMERCE_STORE_HASH: ${{ vars.NATIVE_HOSTING_BIGCOMMERCE_STORE_HASH }} BIGCOMMERCE_STOREFRONT_TOKEN: ${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_STOREFRONT_TOKEN }} BIGCOMMERCE_CHANNEL_ID: ${{ vars.NATIVE_HOSTING_BIGCOMMERCE_CHANNEL_ID }} AUTH_SECRET: ${{ secrets.NATIVE_HOSTING_AUTH_SECRET }} - name: Deploy run: | pnpm exec catalyst deploy --prebuilt \ --secret BIGCOMMERCE_STORE_HASH=${{ vars.NATIVE_HOSTING_BIGCOMMERCE_STORE_HASH }} \ --secret BIGCOMMERCE_STOREFRONT_TOKEN=${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_STOREFRONT_TOKEN }} \ --secret BIGCOMMERCE_CHANNEL_ID=${{ vars.NATIVE_HOSTING_BIGCOMMERCE_CHANNEL_ID }} \ --secret AUTH_SECRET=${{ secrets.NATIVE_HOSTING_AUTH_SECRET }} working-directory: core env: CATALYST_STORE_HASH: ${{ vars.NATIVE_HOSTING_BIGCOMMERCE_STORE_HASH }} CATALYST_ACCESS_TOKEN: ${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_ACCESS_TOKEN }} CATALYST_PROJECT_UUID: ${{ secrets.NATIVE_HOSTING_BIGCOMMERCE_PROJECT_UUID }} ================================================ FILE: .github/workflows/prevent-invalid-changesets.yml ================================================ name: Prevent invalid packages for Changesets on: pull_request: branches: - integrations/makeswift permissions: contents: read pull-requests: read jobs: validate-changesets: runs-on: ubuntu-latest name: Validate Changeset Packages steps: - name: Checkout PR code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Validate changesets only target @bigcommerce/catalyst-makeswift uses: actions/github-script@v7 with: script: | const script = require('./.github/scripts/prevent-invalid-changesets.js') await script({ core, exec }) ================================================ FILE: .github/workflows/regression-tests.yml ================================================ name: Regression Tests on: deployment_status: states: ["success"] env: VERCEL_PROTECTION_BYPASS: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} jobs: detect-provider: name: Detect Deployment Provider runs-on: ubuntu-latest outputs: provider: ${{ steps.detect.outputs.provider }} production-url: ${{ steps.detect.outputs.production-url }} branch-label: ${{ steps.detect.outputs.branch-label }} is-preview: ${{ steps.detect.outputs.is-preview }} steps: - uses: actions/checkout@v4 - name: Detect provider and production URL id: detect run: | CREATOR="${{ github.event.deployment_status.creator.login }}" ENVIRONMENT="${{ github.event.deployment.environment }}" if [[ "$ENVIRONMENT" == "Preview" ]]; then echo "is-preview=true" >> $GITHUB_OUTPUT else echo "is-preview=false" >> $GITHUB_OUTPUT fi if [[ "$CREATOR" == "vercel[bot]" ]]; then echo "provider=vercel" >> $GITHUB_OUTPUT PKG_NAME=$(node -p "require('./core/package.json').name") if [[ "$ENVIRONMENT" == "Preview" ]]; then 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 echo "branch-label=" >> $GITHUB_OUTPUT else case "$PKG_NAME" in "@bigcommerce/catalyst-core") echo "branch-label=canary" >> $GITHUB_OUTPUT echo "production-url=${{ github.event.deployment_status.target_url }}" >> $GITHUB_OUTPUT ;; "@bigcommerce/catalyst-makeswift") echo "branch-label=integrations/makeswift" >> $GITHUB_OUTPUT echo "production-url=${{ github.event.deployment_status.target_url }}" >> $GITHUB_OUTPUT ;; *) echo "branch-label=" >> $GITHUB_OUTPUT echo "production-url=" >> $GITHUB_OUTPUT ;; esac fi elif [[ "$CREATOR" == "cloudflare-pages[bot]" ]]; then echo "provider=cloudflare" >> $GITHUB_OUTPUT echo "::warning::Cloudflare production URL not yet configured. Skipping comparison." echo "production-url=" >> $GITHUB_OUTPUT echo "branch-label=" >> $GITHUB_OUTPUT else echo "::warning::Unknown deployment provider: $CREATOR. Skipping audits." echo "provider=unknown" >> $GITHUB_OUTPUT echo "production-url=" >> $GITHUB_OUTPUT echo "branch-label=" >> $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 concurrency: group: regression-preview-${{ github.event.deployment.ref }}-${{ matrix.device }} cancel-in-progress: true strategy: matrix: device: [desktop, mobile] steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install Dependencies run: npm install @unlighthouse/cli puppeteer -g - 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.production-url != '' runs-on: ubuntu-latest concurrency: group: regression-production-${{ github.event.deployment.environment == 'Preview' && github.event.deployment.ref || github.event.deployment.sha }}-${{ matrix.device }} cancel-in-progress: ${{ github.event.deployment.environment == 'Preview' }} strategy: matrix: device: [desktop, mobile] 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 }} production audit if: failure() || success() uses: actions/upload-artifact@v4 with: 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-pr-comment.js') await postComment({ github, context, provider: '${{ needs.detect-provider.outputs.provider }}', reportPath: '/tmp/unlighthouse-report.md', metaPath: '/tmp/unlighthouse-meta.json', }) unlighthouse-report: name: Unlighthouse Report (${{ needs.detect-provider.outputs.provider }}) — ${{ needs.detect-provider.outputs.branch-label }} needs: [detect-provider, unlighthouse-audit-production] if: needs.detect-provider.outputs.is-preview == 'false' && needs.detect-provider.outputs.production-url != '' runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version-file: ".nvmrc" - name: Download production Unlighthouse artifacts uses: actions/download-artifact@v4 with: pattern: unlighthouse-production-*-report path: /tmp/unlighthouse-artifacts merge-multiple: false - name: Format report run: | node .github/scripts/audit-unlighthouse.mts \ --desktop /tmp/unlighthouse-artifacts/unlighthouse-production-desktop-report/ci-result.json \ --mobile /tmp/unlighthouse-artifacts/unlighthouse-production-mobile-report/ci-result.json \ --branch "${{ needs.detect-provider.outputs.branch-label }}" \ --output /tmp/unlighthouse-report.md cat /tmp/unlighthouse-report.md >> "$GITHUB_STEP_SUMMARY" - name: Post commit comment uses: actions/github-script@v7 with: script: | const postReport = require('./.github/scripts/post-unlighthouse-commit-comment.js') await postReport({ github, context, reportPath: '/tmp/unlighthouse-report.md' }) ================================================ FILE: .github/workflows/translations-changeset.yml ================================================ name: Create translations patch on: pull_request: types: - opened branches: - canary jobs: create-translations-patch: if: github.actor == 'bc-svc-local' runs-on: ubuntu-latest steps: - name: Checkout repo uses: actions/checkout@v4 with: fetch-depth: 2 - name: Use commit SHA for filename id: generate-sha run: | short_sha=$(echo "${GITHUB_SHA}" | cut -c1-8) echo "SHORT_SHA=$short_sha" >> $GITHUB_OUTPUT - name: Create a translations changeset env: SHORT_SHA: ${{ steps.generate-sha.outputs.SHORT_SHA }} run: | mkdir -p .changeset echo "--- \"@bigcommerce/catalyst-core\": patch --- Update translations." > .changeset/translations-patch-$SHORT_SHA.md - name: Commit changeset env: SHORT_SHA: ${{ steps.generate-sha.outputs.SHORT_SHA }} run: | git config --global user.name 'bc-svc-local' git config --global user.email 'bc-svc-local@users.noreply.github.com' git add .changeset/translations-patch-$SHORT_SHA.md git commit -m "chore(core): create translations patch" - name: Push changeset env: TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} HEAD:$GITHUB_HEAD_REF ================================================ FILE: .gitignore ================================================ node_modules dist .turbo .vscode/**/* !.vscode/settings.example.json !.vscode/launch.example.json .idea .vercel .catalyst .env .env*.local .env*.test test-results/ playwright-report/ playwright/.cache/ .tests bigcommerce.graphql bigcommerce-graphql.d.ts .DS_Store coverage/ .history .unlighthouse .bigcommerce .mcp.json ================================================ FILE: .nvmrc ================================================ 24 ================================================ FILE: .vscode/launch.example.json ================================================ { "version": "0.2.0", "configurations": [ { "name": "Catalyst: debug server-side", "type": "node-terminal", "request": "launch", "command": "pnpm run dev", "cwd": "${workspaceFolder}" }, { "name": "Catalyst: debug client-side", "type": "chrome", // Use "chrome" "firefox" or "msedge" as needed "request": "launch", "url": "http://localhost:3000", "webRoot": "${workspaceFolder}/core" }, { "name": "Catalyst: debug full stack", "type": "node", "request": "launch", "cwd": "${workspaceFolder}/core", "program": "${workspaceFolder}/core/node_modules/next/dist/bin/next", "args": ["dev"], "runtimeArgs": ["--inspect"], "skipFiles": ["/**"], "env": { "NODE_ENV": "development" }, "serverReadyAction": { "action": "openExternally", "killOnServerStop": true, "pattern": "- Local:.+(https?://.+)", "uriFormat": "%s" } } ] } ================================================ FILE: .vscode/settings.example.json ================================================ { "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "eslint.workingDirectories": [ { "pattern": "core" }, { "pattern": "packages/*/" } ] } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at engineering@bigcommerce.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Catalyst Thanks for showing interest in contributing! The following is a set of guidelines for contributing to Catalyst. These are just guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. ## Repository Structure Catalyst is a monorepo that contains the code for the Catalyst Next.js application inside of `core/`, and supporting packages such as the GraphQL API client and the `create-catalyst` CLI in `packages/`. The default branch for this repository is called `canary`. This is the primary development branch where active development takes place, including the introduction of new features, bug fixes, and other changes before they are released in stable versions. To contribute to the `canary` branch, you can create a new branch off of `canary` and submit a PR against that branch. ## 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). To contribute to the `integrations/makeswift` branch, you can create a new branch off of `integrations/makeswift` and submit a PR against that branch. ### Keeping `integrations/makeswift` in sync with `canary` Except for the additional code required to integrate with Makeswift, the `integrations/makeswift` branch is a mirror of the `canary` branch. This means that the `integrations/makeswift` branch should be kept in sync with the `canary` branch as much as possible. #### Prerequisites In order to complete the following steps, you will need to have met the following prerequisites: - You have a remote named `origin` pointing to the [`bigcommerce/catalyst` repository on GitHub](https://github.com/bigcommerce/catalyst). - You have rights to push to the `integrations/makeswift` branch on GitHub. #### Steps 1. Fetch latest from `origin` ```bash git fetch origin ``` 2. Create a branch to perform a merge from `canary` ```bash git checkout -B sync-integrations-makeswift origin/integrations/makeswift ``` > [!TIP] > The `-B` flag means "create branch or reset existing branch": > > - If the local branch doesn't exist, it creates it from `origin/integrations/makeswift` > - If the local branch exists, it resets it to match `origin/integrations/makeswift` 3. Merge `canary` and resolve merge conflicts, if necessary: ```bash git merge canary ``` > [!WARNING] > **Gotchas when merging canary into integrations/makeswift:** > > - The `name` field in `core/package.json` should remain `@bigcommerce/catalyst-makeswift` > - The `version` field in `core/package.json` should remain whatever the latest published `@bigcommerce/catalyst-makeswift` version was > - The latest release in `core/CHANGELOG.md` should remain whatever the latest published `@bigcommerce/catalyst-makeswift` version was 4. After resolving any merge conflicts, open a new PR in GitHub to merge your `sync-integrations-makeswift` into `integrations/makeswift`. This PR should be code reviewed and approved before the next steps. 5. Rebase `integrations/makeswift` to establish new merge base ```bash git checkout -B integrations/makeswift origin/integrations/makeswift git rebase sync-integrations-makeswift ``` 6. Push the changes up to GitHub: ```bash git push origin integrations/makeswift ``` This should close the PR in GitHub automatically. > [!IMPORTANT] > Do not squash or rebase-and-merge PRs into `integrations/makeswift`. Always use a true merge commit or rebase locally (as shown below). This is to preserve the merge commit and establish a new merge base between `canary` and `integrations/makeswift`. ## Cutting New Releases Catalyst uses [Changesets](https://github.com/changesets/changesets) to manage version bumps, changelogs, and publishing. Releases happen in **two stages**: 1. Cut a release from `canary` 2. Sync that release into `integrations/makeswift` and cut again This ensures `integrations/makeswift` remains a faithful mirror of `canary` while including its additional integration code. #### Stage 1: Cut a release from `canary` 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. #### Stage 2: Sync and Release `integrations/makeswift` 2. Follow steps 1-6 under "[Keeping `integrations/makeswift` in sync with `canary`](#keeping-integrationsmakeswift-in-sync-with-canary)", with one addition: **include a changeset for `@bigcommerce/catalyst-makeswift` in the sync merge commit** rather than opening a separate PR for it afterwards. - Match the bump type from Stage 1 (e.g., if `@bigcommerce/catalyst-core` went from `1.4.2` to `1.5.0`, use `minor`) - Create a changeset file in `.changeset/` (e.g., `.changeset/sync-canary-1-5-0.md`): ``` --- "@bigcommerce/catalyst-makeswift": minor --- Pulls in changes from the `@bigcommerce/catalyst-core@1.5.0` release. For more information, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob//core/CHANGELOG.md#150). ``` - Replace `` with the merge commit SHA of the Version Packages PR on `canary` so the link remains stable - Amend this changeset into the merge commit alongside any other sync changes (changeset cleanup, `core/package.json` and `core/CHANGELOG.md` fixes, etc.) 3. Merge the **Version Packages (`integrations/makeswift`)** PR: After the sync lands, Changesets will open a PR (similar to Stage 1) bumping `@bigcommerce/catalyst-makeswift`. Merge it following the same process. This cuts a new release of the Makeswift variant. 4. **Tags and Releases:** Confirm tags exist for both `@bigcommerce/catalyst-core` and `@bigcommerce/catalyst-makeswift`. Update `latest` tags to point to the new releases: ```bash git fetch origin --tags git tag @bigcommerce/catalyst-core@latest @bigcommerce/catalyst-core@ -f git tag @bigcommerce/catalyst-makeswift@latest @bigcommerce/catalyst-makeswift@ -f git push origin @bigcommerce/catalyst-core@latest -f git push origin @bigcommerce/catalyst-makeswift@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 - Consider reporting bugs, contributing to test coverage, or helping spread the word about Catalyst. ## Git Commit Messages - Use the present tense ("Add feature" not "Added feature") - Use the imperative mood ("Move cursor to..." not "Moves cursor to...") - Limit the first line to 72 characters or less - Reference pull requests and external links liberally Thank you again for your interest in contributing to Catalyst! ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 BigCommerce Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ Catalyst for Composable Commerce Image Banner

[![MIT License](https://img.shields.io/github/license/bigcommerce/catalyst)](LICENSE.md) [![Lighthouse Report](https://github.com/bigcommerce/catalyst/actions/workflows/lighthouse.yml/badge.svg)](https://github.com/bigcommerce/catalyst/actions/workflows/lighthouse.yml) [![Lint, Typecheck, gql.tada](https://github.com/bigcommerce/catalyst/actions/workflows/basic.yml/badge.svg)](https://github.com/bigcommerce/catalyst/actions/workflows/basic.yml) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bigcommerce/catalyst)
**Catalyst** is the composable, fully customizable headless commerce framework for [BigCommerce](https://www.bigcommerce.com/). Catalyst is built with [Next.js](https://nextjs.org/), uses our [React](https://react.dev/) storefront components, and is backed by the [GraphQL Storefront API](https://docs.bigcommerce.com/developer/docs/storefront/guides/graphql-storefront-api/overview). By choosing Catalyst, you'll have a fully-functional storefront within a few seconds, and spend zero time on wiring up APIs or building SEO, Accessibility, and Performance-optimized ecommerce components you've probably written many times before. You can instead go straight to work building your brand and making this your own. ## Demo - [Catalyst Demo](https://catalyst-demo.site) ![-----------------------------------------------------](https://storage.googleapis.com/bigcommerce-developers/images/catalyst_readme_hr.png)

🚀 catalyst.dev🤗 BigCommerce Developer Community💬 GitHub Discussions💡 Documentation

![-----------------------------------------------------](https://storage.googleapis.com/bigcommerce-developers/images/catalyst_readme_hr.png) ## Deploy via One-Click Catalyst App The easiest way to deploy your Catalyst Storefront is to use the [One-Click Catalyst App](http://login.bigcommerce.com/deep-links/app/53284) available in the BigCommerce App Marketplace. Check out the [Commerce One-Click Catalyst Documentation](https://docs.bigcommerce.com/developer/docs/storefront/catalyst/getting-started/workflows/one-click-catalyst) for more details. ## Getting Started **Requirements:** - A [BigCommerce account](https://www.bigcommerce.com/start-your-trial) - Node.js version 24 - Corepack-enabled `pnpm` ```bash corepack enable pnpm ``` 1. Install the latest version of Catalyst: ```bash pnpm create @bigcommerce/catalyst@latest ``` 2. Run the local development server: ```bash pnpm run dev ``` Learn more about Catalyst at [catalyst.dev](https://catalyst.dev). ## Resources - [Catalyst Documentation](https://docs.bigcommerce.com/developer/docs/storefront/catalyst/overview) - [GraphQL Storefront API Playground](https://docs.bigcommerce.com/developer/docs/storefront/guides/graphql-storefront-api/overview#accessing-the-graphql-storefront-playground) - [BigCommerce DevDocs](https://docs.bigcommerce.com/developer/docs/overview/quick-start) ================================================ FILE: SECURITY.md ================================================ # Reporting security issues BigCommerce is dedicated to the responsible disclosure of security vulnerabilities. If you have found a security vulnerability in an active open-source repository created and owned by BigCommerce, please report it to our [public bug bounty program](https://bugcrowd.com/bigcommerce). If you would prefer to submit via email, please send your report to [security@bigcommerce.com](mailto:security@bigcommerce.com). We ask that you **do not** open a public GitHub issue to report security concerns. _Note: Only submissions to our bounty program on BugCrowd will be eligible for bounties. Bounty eligibility and amounts are determined according to the program guidelines._ _Note: Bugs in 3rd-party modules and/or dependencies should be reported to the owners/maintainers or those modules and/or dependencies, BigCommerce has no control or authority over third party content._ Thank you in advance for collaborating with us to help protect us and our customers. ================================================ FILE: core/.eslintignore ================================================ # 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 ================================================ FILE: core/.eslintrc.cjs ================================================ // @ts-check require('@bigcommerce/eslint-config/patch'); /** @type {import('eslint').Linter.LegacyConfig} */ const config = { root: true, extends: [ '@bigcommerce/catalyst/base', '@bigcommerce/catalyst/react', '@bigcommerce/catalyst/next', '@bigcommerce/catalyst/prettier', ], rules: { '@typescript-eslint/naming-convention': 'off', '@next/next/no-html-link-for-pages': 'off', 'import/dynamic-import-chunkname': 'off', 'no-underscore-dangle': ['error', { allow: ['__typename'] }], '@typescript-eslint/prefer-nullish-coalescing': 'off', '@typescript-eslint/no-unsafe-enum-comparison': 'off', '@typescript-eslint/no-restricted-imports': [ 'error', { paths: [ { name: 'next/link', message: "Please import 'Link' from '~/components/Link' instead.", }, { name: 'next/image', importNames: ['default'], message: "Please import 'Image' from '~/components/image' instead. This component handles CDN and static image optimization.", }, { name: '~/i18n/routing', importNames: ['Link'], message: "Please import 'Link' from '~/components/Link' instead.", }, { name: 'next/router', importNames: ['useRouter'], message: 'Please import from `~/i18n/routing` instead.', }, { name: 'next/navigation', importNames: ['redirect', 'permanentRedirect', 'useRouter', 'usePathname'], message: 'Please import from `~/i18n/routing` instead.', }, { name: '@playwright/test', importNames: ['expect', 'test'], message: 'Please import from `~/tests/fixtures` instead.', }, ], }, ], 'check-file/folder-naming-convention': [ 'error', { '**': 'NEXT_JS_APP_ROUTER_CASE', }, ], }, overrides: [ { files: ['**/*.spec.ts', '**/*.test.ts'], rules: { '@typescript-eslint/no-restricted-imports': [ 'error', { paths: [ { name: 'next-intl/server', importNames: ['getTranslations', 'getFormatter'], message: 'Please import `getTranslations` from `~/tests/lib/i18n` and `getFormatter` from `~/tests/lib/formatter` instead.', }, ], }, ], }, }, ], }; module.exports = config; ================================================ FILE: core/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage /test-results/ /playwright-report/ /playwright/.cache/ /.tests # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts # generated client/generated # next-intl messages/*.d.json.ts # secrets .catalyst # Build config build-config.json # OpenNext .open-next .wrangler ================================================ FILE: core/AGENTS.md ================================================ # 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. ================================================ FILE: core/CHANGELOG.md ================================================ # Changelog ## 1.6.2 ### Patch Changes - [#2947](https://github.com/bigcommerce/catalyst/pull/2947) [`e198d89`](https://github.com/bigcommerce/catalyst/commit/e198d8966d589bd6707cdb1588986c9c092d73be) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add root-level not-found page so /404 renders a branded page instead of the default Vercel error screen - [#2945](https://github.com/bigcommerce/catalyst/pull/2945) [`4479964`](https://github.com/bigcommerce/catalyst/commit/447996400e6fcc6388937011e101d802308e6b33) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. ## 1.6.1 ### Patch Changes - [#2934](https://github.com/bigcommerce/catalyst/pull/2934) [`6a5b019`](https://github.com/bigcommerce/catalyst/commit/6a5b019083aa3e000e5989f6f13256b57c22c479) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Fix extra thick border on dropdown menu by changing `ring` (3px) to `ring-1` (1px) to match the Select component styling. ## 1.6.0 ### Minor Changes - [#2896](https://github.com/bigcommerce/catalyst/pull/2896) [`fc84210`](https://github.com/bigcommerce/catalyst/commit/fc84210ab8562ce24320d3d0e3284ed318a4cbce) Thanks [@jamesqquick](https://github.com/jamesqquick)! - Add reCAPTCHA v2 support to storefront forms. The reCAPTCHA widget is rendered on the registration, contact, and product review forms when enabled in the BigCommerce admin. All validation and error handling is performed server-side in the corresponding form actions. The token is read from the native `g-recaptcha-response` field that the widget injects into the form, eliminating the need for manual token extraction on the client. ## Migration steps ### Step 1: Install dependencies Add `react-google-recaptcha` and its type definitions: ```bash pnpm add react-google-recaptcha pnpm add -D @types/react-google-recaptcha ``` ### Step 2: Add the reCAPTCHA server library Create `core/lib/recaptcha/constants.ts`: ```ts export interface ReCaptchaSettings { isEnabledOnStorefront: boolean; siteKey: string; } export const RECAPTCHA_TOKEN_FORM_KEY = 'g-recaptcha-response'; ``` Create `core/lib/recaptcha.ts` with the server-side helpers for fetching reCAPTCHA settings, extracting the token from form data, and asserting the token is present. See the file in this release for the full implementation. ### Step 3: Add reCAPTCHA translation strings Update `core/messages/en.json` to add the `recaptchaRequired` message in each form namespace: ```diff "Auth": { "Register": { + "recaptchaRequired": "Please complete the reCAPTCHA verification.", ``` ```diff "Product": { "Reviews": { "Form": { + "recaptchaRequired": "Please complete the reCAPTCHA verification.", ``` ```diff "WebPages": { "ContactUs": { "Form": { + "recaptchaRequired": "Please complete the reCAPTCHA verification.", ``` ```diff "Form": { + "recaptchaRequired": "Please complete the reCAPTCHA verification.", ``` ### Step 4: Update GraphQL mutations to accept reCAPTCHA token Update `core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts`: ```diff + import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; ... const RegisterCustomerMutation = graphql(` - mutation RegisterCustomerMutation($input: RegisterCustomerInput!) { + mutation RegisterCustomerMutation( + $input: RegisterCustomerInput! + $reCaptchaV2: ReCaptchaV2Input + ) { customer { - registerCustomer(input: $input) { + registerCustomer(input: $input, reCaptchaV2: $reCaptchaV2) { ``` Update `core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts`: ```diff + import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; ... const AddProductReviewMutation = graphql(` - mutation AddProductReviewMutation($input: AddProductReviewInput!) { + mutation AddProductReviewMutation( + $input: AddProductReviewInput! + $reCaptchaV2: ReCaptchaV2Input + ) { catalog { - addProductReview(input: $input) { + addProductReview(input: $input, reCaptchaV2: $reCaptchaV2) { ``` Update `core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts`: ```diff + import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; ... const SubmitContactUsMutation = graphql(` - mutation SubmitContactUsMutation($input: SubmitContactUsInput!) { - submitContactUs(input: $input) { + mutation SubmitContactUsMutation($input: SubmitContactUsInput!, $reCaptchaV2: ReCaptchaV2Input) { + submitContactUs(input: $input, reCaptchaV2: $reCaptchaV2) { ``` ### Step 5: Add server-side reCAPTCHA validation to form actions In each of the three server actions above, add the validation block after the `parseWithZod` check and pass the token to the GraphQL mutation. For example in `register-customer.ts`: ```diff + const { siteKey, token } = await getRecaptchaFromForm(formData); + const recaptchaValidation = assertRecaptchaTokenPresent(siteKey, token, t('recaptchaRequired')); + + if (!recaptchaValidation.success) { + return { + lastResult: submission.reply({ formErrors: recaptchaValidation.formErrors }), + }; + } ... const response = await client.fetch({ document: RegisterCustomerMutation, variables: { input, + reCaptchaV2: + recaptchaValidation.token != null ? { token: recaptchaValidation.token } : undefined, }, ``` Apply the same pattern to `submit-review.ts` and `submit-contact-form.ts`. ### Step 6: Pass `recaptchaSiteKey` to form components Fetch the site key in each page and pass it down through the component tree. Update `core/app/[locale]/(default)/(auth)/register/page.tsx`: ```diff + import { getRecaptchaSiteKey } from '~/lib/recaptcha'; ... + const recaptchaSiteKey = await getRecaptchaSiteKey(); ... { + recaptchaSiteKey?: string; } ... + {recaptchaSiteKey ? : null} ``` Update `core/vibes/soul/sections/reviews/review-form.tsx`: ```diff + import RecaptchaWidget from 'react-google-recaptcha'; ... interface Props { + recaptchaSiteKey?: string; } ... + {recaptchaSiteKey ? ( +
+ +
+ ) : null} ``` ### Step 8: Thread `recaptchaSiteKey` through intermediate components Add the `recaptchaSiteKey?: string` prop and pass it through in: - `core/vibes/soul/sections/dynamic-form-section/index.tsx` - `core/vibes/soul/sections/product-detail/index.tsx` - `core/vibes/soul/sections/reviews/index.tsx` - `core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx` Each of these accepts the prop and forwards it to the form component that renders the widget. ### Patch Changes - [#2925](https://github.com/bigcommerce/catalyst/pull/2925) [`4e2f8f8`](https://github.com/bigcommerce/catalyst/commit/4e2f8f855ebcde561ae93b98f648fa05474aedf8) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. ## 1.5.0 ### Minor Changes - [#2905](https://github.com/bigcommerce/catalyst/pull/2905) [`6f788e9`](https://github.com/bigcommerce/catalyst/commit/6f788e95dbf6e21bd025a32173f5e2c8e81aee46) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Use dynamic imports for next/headers, next/navigation, and next-intl/server in the client module to avoid AsyncLocalStorage poisoning during next.config.ts resolution - [#2801](https://github.com/bigcommerce/catalyst/pull/2801) [`18cfdc8`](https://github.com/bigcommerce/catalyst/commit/18cfdc8ca018c33ea49b462ecb6f055a153cd4ab) Thanks [@Tharaae](https://github.com/Tharaae)! - Fetch product inventory data with a separate GQL query with no caching ## Migration The files to be rebased for this change to be applied are: - core/app/[locale]/(default)/product/[slug]/page-data.ts - core/app/[locale]/(default)/product/[slug]/page.tsx - [#2863](https://github.com/bigcommerce/catalyst/pull/2863) [`6a23c90`](https://github.com/bigcommerce/catalyst/commit/6a23c90714b2218db45f17cebe395b21753157e7) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add pagination support for the product gallery. When a product has more images than the initial page load, new images will load as batches once the user reaches the end of the existing thumbnails. Thumbnail images now will display in horizontal direction in all viewport sizes. ## Migration 1. Create the new server action file `core/app/[locale]/(default)/product/[slug]/_actions/get-more-images.ts` with a GraphQL query to fetch additional product images with pagination. 2. Update the product page data fetching in `core/app/[locale]/(default)/product/[slug]/page-data.ts` to include `pageInfo` (with `hasNextPage` and `endCursor`) from the images query. 3. Update `core/app/[locale]/(default)/product/[slug]/page.tsx` to pass the new pagination props (`pageInfo`, `productId`, `loadMoreAction`) to the `ProductDetail` component. 4. The `ProductGallery` component now accepts optional props for pagination: - `pageInfo?: { hasNextPage: boolean; endCursor: string | null }` - `productId?: number` - `loadMoreAction?: ProductGalleryLoadMoreAction` Due to the number of changes, it is recommended to use the PR as a reference for migration. - [#2758](https://github.com/bigcommerce/catalyst/pull/2758) [`d78bc85`](https://github.com/bigcommerce/catalyst/commit/d78bc85fa4a6ae39d2b99a347a3f9fc56725826a) Thanks [@Tharaae](https://github.com/Tharaae)! - Add the following messages to each line item on cart page based on store inventory settings: - Fully/partially out-of-stock message if enabled on the store and the line item is currently out of stock - Ready-to-ship quantity if enabled on the store - Backordered quantity if enabled on the store ## Migration For existing Catalyst stores, to get the newly added feature, simply rebase the existing code with the new release code. The files to be rebased for this change to be applied are: - core/app/[locale]/(default)/cart/page-data.ts - core/app/[locale]/(default)/cart/page.tsx - core/messages/en.json - core/vibes/soul/sections/cart/client.tsx - [#2907](https://github.com/bigcommerce/catalyst/pull/2907) [`35adccb`](https://github.com/bigcommerce/catalyst/commit/35adccb0429f462efaa5bfb4cabf4d51b4a62522) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Upgrade Next.js to v16 and align peer dependencies. To migrate your Catalyst storefront to Next.js 16: - Update `next` to `^16.0.0` in your `package.json` and install dependencies. - Replace any usage of `unstable_expireTag` with `revalidateTag` and `unstable_expirePath` with `revalidatePath` from `next/cache`. - Update `tsconfig.json` to use `"moduleResolution": "bundler"` and `"module": "nodenext"` as required by Next.js 16. - Address Next.js 16 deprecation lint errors (e.g. legacy `` elements, missing `rel="noopener noreferrer"` on external links). - Rename `middleware.ts` to `proxy.ts` and change `export const middleware` to `export const proxy` (Next.js 16 proxy pattern). - Ensure you are running Node.js 24+ (proxy runs on the Node.js runtime, not Edge). ### Patch Changes - [#2916](https://github.com/bigcommerce/catalyst/pull/2916) [`e3185b6`](https://github.com/bigcommerce/catalyst/commit/e3185b6f0612424af01b1515b440b6f43988da66) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Fix analytics visit count inflation by implementing a sliding window for the visit cookie TTL, guarding against prefetch/RSC requests creating spurious visits, and reordering middleware so analytics cookies survive locale redirects. - [#2852](https://github.com/bigcommerce/catalyst/pull/2852) [`a7395f1`](https://github.com/bigcommerce/catalyst/commit/a7395f1a6778fe93080e8fcb05dce423cbc3acc0) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Uses regular `dompurify` (DP) instead of `isomorphic-dompurify` (IDP), because IDP requires JSDOM. JSDOM doesn't work in edge-runtime environments even with nodejs compatibility. We only need it on the client anyways for the JSON-LD schema, so it doesn't need the isomorphic aspect of it. This also changes `core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx` to be a client-component to enable `dompurify to work correctly. ## Migration 1. Remove the old dependency and add the new: ```bash pnpm rm isomorphic-dompurify pnpm add dompurify -S ``` 2. Change the import in `core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx`: ```diff - import DOMPurify from 'isomorphic-dompurify'; +// eslint-disable-next-line import/no-named-as-default +import DOMPurify from 'dompurify'; ``` 3. Add the `'use client';` directive to the top of `core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx`. - [#2844](https://github.com/bigcommerce/catalyst/pull/2844) [`74dee6e`](https://github.com/bigcommerce/catalyst/commit/74dee6e6cafc57ea0e6eea94aafc4b38063352b1) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Update forms to translate the form field validation errors ## Migration Due to the amount of changes, it is recommended to just use the PR as a reference for migration. Detailed migration steps can be found on the PR here: https://github.com/bigcommerce/catalyst/pull/2844 - [#2901](https://github.com/bigcommerce/catalyst/pull/2901) [`8b5fee6`](https://github.com/bigcommerce/catalyst/commit/8b5fee6a1f396f000748d3e9bb65e44383148eb4) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix GiftCertificateCard not updating when selecting a new amount on the gift certificate purchase form - [#2858](https://github.com/bigcommerce/catalyst/pull/2858) [`0633612`](https://github.com/bigcommerce/catalyst/commit/06336122585db5021de9028ed88e2bc48c6faede) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Use state abbreviation instead of entityId for cart shipping form state values. The shipping API expects state abbreviations, and using entityId caused form submissions to fail. Additionally, certain US military states that share the same abbreviation (AE) are now filtered out to prevent duplicate key issues and ambiguous submissions. ## Migration steps ### Step 1: Add blacklist for states with duplicate abbreviations Certain US states share the same abbreviation (AE), which causes issues with the shipping API and React select dropdowns. Add a blacklist to filter these out. Update `core/app/[locale]/(default)/cart/page.tsx`: ```diff const countries = shippingCountries.map((country) => ({ value: country.code, 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) => ({ ``` ### Step 2: Use state abbreviation instead of entityId Update the state mapping to use `abbreviation` instead of `entityId`, and apply the blacklist filter for US states. Update `core/app/[locale]/(default)/cart/page.tsx`: ```diff 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, + })), })); ``` - [#2856](https://github.com/bigcommerce/catalyst/pull/2856) [`f5330c7`](https://github.com/bigcommerce/catalyst/commit/f5330c7248b2e3a32b2bfbb8e3bc6c11742a5d27) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add canonical URLs and hreflang alternates for SEO. Pages now set `alternates.canonical` and `alternates.languages` in `generateMetadata` via the new `getMetadataAlternates` helper in `core/lib/seo/canonical.ts`. The helper fetches the vanity URL via GraphQL (`site.settings.url.vanityUrl`) and is cached per request. The default locale uses no path prefix; other locales use `/{locale}/path`. The root locale layout sets `metadataBase` to the configured vanity URL so canonical URLs resolve correctly. On Vercel preview deployments (`VERCEL_ENV=preview`), `metadataBase` and canonical/hreflang URLs use `VERCEL_URL` instead of the production vanity URL to prevent preview environments from generating SEO metadata pointing to production. ## Migration steps ### Step 1: Root layout metadata base The root locale layout now sets `metadataBase` from the vanity URL fetched via GraphQL. On Vercel preview deployments, `VERCEL_URL` is used instead so preview environments don't point to production. `URL.canParse` guards against malformed URLs. Update `core/app/[locale]/layout.tsx`: ```diff + 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: { ``` ### Step 2: Canonical/hreflang base URL for preview environments The `getMetadataAlternates` function in `core/lib/seo/canonical.ts` now checks for a Vercel preview URL before falling back to the GraphQL vanity URL. `URL.canParse` guards against malformed URLs. Update `core/lib/seo/canonical.ts`: ```diff export async function getMetadataAlternates(options: CanonicalUrlOptions) { const { path, locale, includeAlternates = true } = options; - const baseUrl = await getVanityUrl(); + // Use preview deployment URL so canonical/hreflang URLs point at the preview, not production. + const previewUrl = + process.env.VERCEL_ENV === 'preview' ? `https://${process.env.VERCEL_URL}` : undefined; + const baseUrl = previewUrl && URL.canParse(previewUrl) ? previewUrl : await getVanityUrl(); ``` ### Step 3: GraphQL fragment updates Add the `path` field to brand, blog post, and product queries so metadata can build canonical URLs. Update `core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts`: ```diff site { brand(entityId: $entityId) { name + path seo { ``` Update `core/app/[locale]/(default)/blog/[blogId]/page-data.ts`: ```diff author htmlBody name + path publishedDate { ``` Update `core/app/[locale]/(default)/product/[slug]/page-data.ts` (in the metadata query): ```diff site { product(entityId: $entityId) { name + path defaultImage { ``` ### Step 4: Page metadata alternates Add the `getMetadataAlternates` import and set `alternates` in `generateMetadata` for each page. The function is async and must be awaited. Ensure `core/lib/seo/canonical.ts` exists (it is included in this release). Update `core/app/[locale]/(default)/page.tsx` (home): ```diff + import { Metadata } from 'next'; import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; ... + import { getMetadataAlternates } from '~/lib/seo/canonical'; ... + export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + return { + alternates: await getMetadataAlternates({ path: '/', locale }), + }; + } + export default async function Home({ params }: Props) { ``` For entity pages (product, category, brand, blog, blog post, webpage), add the import and include `alternates` in the existing `generateMetadata` return value using the entity `path` (or breadcrumb-derived path for category and webpage). Example for a brand page: ```diff + import { getMetadataAlternates } from '~/lib/seo/canonical'; ... export async function generateMetadata(props: Props): Promise { - const { slug } = await props.params; + const { slug, locale } = await props.params; ... return { title: pageTitle || brand.name, description: metaDescription, keywords: metaKeywords ? metaKeywords.split(',') : null, + alternates: await getMetadataAlternates({ path: brand.path, locale }), }; } ``` ### Step 5: Gift certificates pages Update `core/app/[locale]/(default)/gift-certificates/page.tsx`: ```diff + import { getMetadataAlternates } from '~/lib/seo/canonical'; ... export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); return { title: t('title') || 'Gift certificates', + alternates: await getMetadataAlternates({ path: '/gift-certificates', locale }), }; } ``` Update `core/app/[locale]/(default)/gift-certificates/balance/page.tsx`: ```diff + import { getMetadataAlternates } from '~/lib/seo/canonical'; ... return { title: t('title') || 'Gift certificates - Check balance', + alternates: await getMetadataAlternates({ path: '/gift-certificates/balance', locale }), }; ``` Add `generateMetadata` to `core/app/[locale]/(default)/gift-certificates/purchase/page.tsx`: ```diff + import { Metadata } from 'next'; import { getFormatter, getTranslations } from 'next-intl/server'; ... + import { getMetadataAlternates } from '~/lib/seo/canonical'; ... + 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 }), + }; + } ``` ### Step 6: Contact page Update `core/app/[locale]/(default)/webpages/[id]/contact/page.tsx`: ```diff + import { getMetadataAlternates } from '~/lib/seo/canonical'; ... export async function generateMetadata({ params }: Props): Promise { - const { id } = await params; + const { id, locale } = await params; const webpage = await getWebPage(id); const { pageTitle, metaDescription, metaKeywords } = webpage.seo; return { title: pageTitle || webpage.title, description: metaDescription, keywords: metaKeywords ? metaKeywords.split(',') : null, + alternates: await getMetadataAlternates({ path: webpage.path, locale }), }; } ``` ### Step 7: Public wishlist page Update `core/app/[locale]/(default)/wishlist/[token]/page.tsx`: ```diff + import { getMetadataAlternates } from '~/lib/seo/canonical'; ... export async function generateMetadata({ params, searchParams }: Props): Promise { const { locale, token } = await params; ... return { title: wishlist?.name ?? t('title'), + alternates: await getMetadataAlternates({ path: `/wishlist/${token}`, locale }), }; } ``` ### Step 8: Compare page Update `core/app/[locale]/(default)/compare/page.tsx`: ```diff + import { getMetadataAlternates } from '~/lib/seo/canonical'; ... export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'Compare' }); return { title: t('title'), + alternates: await getMetadataAlternates({ path: '/compare', locale }), }; } ``` - [#2898](https://github.com/bigcommerce/catalyst/pull/2898) [`46ee3de`](https://github.com/bigcommerce/catalyst/commit/46ee3de640f030be56111c668102bbeeb961b4a4) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Conditionally include optional SEO metadata fields in `generateMetadata` across page files. Fields `description`, `keywords`, `alternates`, and `openGraph` are now only included in the returned metadata object when they have a value, using spread syntax (`...(value && { key: value })`). Previously, these fields were always set — potentially assigning `null` or an empty string — which could cause Next.js to render empty `` tags. ## Migration steps Update `generateMetadata` in the following pages to use conditional spread syntax for optional metadata fields: ### brand, category, webpages (contact + normal) ```diff return { title: pageTitle || entity.name, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + ...(metaDescription && { description: metaDescription }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), }; ``` For `brand/[slug]/page.tsx`, also guard the `alternates` field: ```diff - alternates: await getMetadataAlternates({ path: brand.path, locale }), + ...(brand.path && { alternates: await getMetadataAlternates({ path: brand.path, locale }) }), ``` ### blog/[blogId]/page.tsx ```diff return { title: pageTitle || blogPost.name, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + ...(metaDescription && { description: metaDescription }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), ...(blogPost.path && { alternates: await getMetadataAlternates({ path: blogPost.path, locale }), }), }; ``` ### product/[slug]/page.tsx ```diff - keywords: metaKeywords ? metaKeywords.split(',') : null, + ...(metaKeywords && { keywords: metaKeywords.split(',') }), - openGraph: url - ? { - images: [{ url, alt }], - } - : null, + ...(url && { openGraph: { images: [{ url, alt }] } }), ``` ### blog/page.tsx Extract the description to a variable and spread conditionally: ```diff + const 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, + ...(description && { description }), ...(blog?.path && { alternates: await getMetadataAlternates({ path: blog.path, locale }) }), }; ``` - [#2897](https://github.com/bigcommerce/catalyst/pull/2897) [`8d128fc`](https://github.com/bigcommerce/catalyst/commit/8d128fc75006ef8ab330e3597bfcf15cdc70da71) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. ## 1.4.2 ### Patch Changes - [#2842](https://github.com/bigcommerce/catalyst/pull/2842) [`aadc1e3`](https://github.com/bigcommerce/catalyst/commit/aadc1e35533733905e7ce9ada457c2679995a727) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Addresses https://vercel.com/changelog/summary-of-cve-2026-23864 by bumping React and Next.js ## 1.4.1 ### Patch Changes - [#2827](https://github.com/bigcommerce/catalyst/pull/2827) [`49b1097`](https://github.com/bigcommerce/catalyst/commit/49b1097c5d4f56b8c3a8d3d97181b09cb79a4070) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Filter out child cart items (items with `parentEntityId`) from cart and cart analytics to prevent duplicate line items when products have parent-child relationships, such as product bundles. ## Migration steps ### Step 1: GraphQL Fragment Updates The `parentEntityId` field has been added to both physical and digital cart item fragments to identify child items. Update `core/app/[locale]/(default)/cart/page-data.ts`: ```diff export const PhysicalItemFragment = graphql(` fragment PhysicalItemFragment on CartPhysicalItem { entityId quantity productEntityId variantEntityId + parentEntityId listPrice { currencyCode value } } `); export const DigitalItemFragment = graphql(` fragment DigitalItemFragment on CartDigitalItem { entityId quantity productEntityId variantEntityId + parentEntityId listPrice { currencyCode value } } `); ``` ### Step 2: Cart Display Filtering Cart line items are now filtered to exclude child items when displaying the cart. Update `core/app/[locale]/(default)/cart/page.tsx`: ```diff const lineItems = [ ...cart.lineItems.giftCertificates, ...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems, - ]; + ].filter((item) => !('parentEntityId' in item) || !item.parentEntityId); ``` ### Step 3: Analytics Data Filtering Analytics data collection now only includes top-level items to prevent duplicate tracking. Update `core/app/[locale]/(default)/cart/page.tsx` in the `getAnalyticsData` function: ```diff - 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 + ); ``` ### Step 4: Styling Update Cart subtitle text color has been updated for improved contrast. Update `core/vibes/soul/sections/cart/client.tsx`: ```diff - + {lineItem.subtitle} ``` - [#2811](https://github.com/bigcommerce/catalyst/pull/2811) [`b57bffa`](https://github.com/bigcommerce/catalyst/commit/b57bffaf28b8c9714f99f7af581871f6e9f6f944) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Fix pagination cursor persistence when changing sort order. The `before` and `after` query parameters are now cleared when the sort option changes, preventing stale pagination cursors from causing incorrect results or empty pages. - [#2833](https://github.com/bigcommerce/catalyst/pull/2833) [`a520dbc`](https://github.com/bigcommerce/catalyst/commit/a520dbcf14e78b54e10a38c05e11f427b30431c1) Thanks [@jamesqquick](https://github.com/jamesqquick)! - Add placeholders for gift certificate inputs and remove redundant placeholders in the gift certificate purchase form. - [#2818](https://github.com/bigcommerce/catalyst/pull/2818) [`74e4dd1`](https://github.com/bigcommerce/catalyst/commit/74e4dd11ebf00c312695013c86aab29930fd7a53) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Disable product filters that are no longer available based on the selection. ## Migration steps ### Step 1 Update the `facetsTransformer` function in `core/data-transformers/facets-transformer.ts` to handle disabled filters: ```diff return allFacets.map((facet) => { const refinedFacet = refinedFacets.find((f) => f.displayName === facet.displayName); + if (refinedFacet == null) { + return null; + } + if (facet.__typename === 'CategorySearchFilter') { const refinedCategorySearchFilter = - refinedFacet?.__typename === 'CategorySearchFilter' ? refinedFacet : null; + refinedFacet.__typename === 'CategorySearchFilter' ? refinedFacet : null; return { type: 'toggle-group' as const, paramName: 'categoryIn', label: facet.displayName, defaultCollapsed: facet.isCollapsedByDefault, options: facet.categories.map((category) => { const refinedCategory = refinedCategorySearchFilter?.categories.find( (c) => c.entityId === category.entityId, ); const isSelected = filters.categoryEntityIds?.includes(category.entityId) === true; + const disabled = refinedCategory == null && !isSelected; + const productCountLabel = disabled ? '' : ` (${category.productCount})`; + const label = facet.displayProductCount + ? `${category.name}${productCountLabel}` + : category.name; return { - label: facet.displayProductCount - ? `${category.name} (${category.productCount})` - : category.name, + label, value: category.entityId.toString(), - disabled: refinedCategory == null && !isSelected, + disabled, }; }), }; } if (facet.__typename === 'BrandSearchFilter') { const refinedBrandSearchFilter = - refinedFacet?.__typename === 'BrandSearchFilter' ? refinedFacet : null; + refinedFacet.__typename === 'BrandSearchFilter' ? refinedFacet : null; return { type: 'toggle-group' as const, paramName: 'brand', label: facet.displayName, defaultCollapsed: facet.isCollapsedByDefault, options: facet.brands.map((brand) => { const refinedBrand = refinedBrandSearchFilter?.brands.find( (b) => b.entityId === brand.entityId, ); const isSelected = filters.brandEntityIds?.includes(brand.entityId) === true; + const disabled = refinedBrand == null && !isSelected; + const productCountLabel = disabled ? '' : ` (${brand.productCount})`; + const label = facet.displayProductCount + ? `${brand.name}${productCountLabel}` + : brand.name; return { - label: facet.displayProductCount ? `${brand.name} (${brand.productCount})` : brand.name, + label, value: brand.entityId.toString(), - disabled: refinedBrand == null && !isSelected, + disabled, }; }), }; } if (facet.__typename === 'ProductAttributeSearchFilter') { const refinedProductAttributeSearchFilter = - refinedFacet?.__typename === 'ProductAttributeSearchFilter' ? refinedFacet : null; + refinedFacet.__typename === 'ProductAttributeSearchFilter' ? refinedFacet : null; return { type: 'toggle-group' as const, paramName: `attr_${facet.filterKey}`, label: facet.displayName, defaultCollapsed: facet.isCollapsedByDefault, options: facet.attributes.map((attribute) => { const refinedAttribute = refinedProductAttributeSearchFilter?.attributes.find( (a) => a.value === attribute.value, ); const isSelected = filters.productAttributes?.some((attr) => attr.values.includes(attribute.value)) === true; + const disabled = refinedAttribute == null && !isSelected; + const productCountLabel = disabled ? '' : ` (${attribute.productCount})`; + const label = facet.displayProductCount + ? `${attribute.value}${productCountLabel}` + : attribute.value; + return { - label: facet.displayProductCount - ? `${attribute.value} (${attribute.productCount})` - : attribute.value, + label, value: attribute.value, - disabled: refinedAttribute == null && !isSelected, + disabled, }; }), }; } if (facet.__typename === 'RatingSearchFilter') { const refinedRatingSearchFilter = - refinedFacet?.__typename === 'RatingSearchFilter' ? refinedFacet : null; + refinedFacet.__typename === 'RatingSearchFilter' ? refinedFacet : null; const isSelected = filters.rating?.minRating != null; return { type: 'rating' as const, paramName: 'minRating', label: facet.displayName, disabled: refinedRatingSearchFilter == null && !isSelected, defaultCollapsed: facet.isCollapsedByDefault, }; } if (facet.__typename === 'PriceSearchFilter') { const refinedPriceSearchFilter = - refinedFacet?.__typename === 'PriceSearchFilter' ? refinedFacet : null; + refinedFacet.__typename === 'PriceSearchFilter' ? refinedFacet : null; const isSelected = filters.price?.minPrice != null || filters.price?.maxPrice != null; return { type: 'range' as const, minParamName: 'minPrice', maxParamName: 'maxPrice', label: facet.displayName, min: facet.selected?.minPrice ?? undefined, max: facet.selected?.maxPrice ?? undefined, disabled: refinedPriceSearchFilter == null && !isSelected, defaultCollapsed: facet.isCollapsedByDefault, }; } if (facet.freeShipping) { const refinedFreeShippingSearchFilter = - refinedFacet?.__typename === 'OtherSearchFilter' && refinedFacet.freeShipping + refinedFacet.__typename === 'OtherSearchFilter' && refinedFacet.freeShipping ? refinedFacet : null; const isSelected = filters.isFreeShipping === true; return { type: 'toggle-group' as const, paramName: `shipping`, label: t('freeShippingLabel'), defaultCollapsed: facet.isCollapsedByDefault, options: [ { label: t('freeShippingLabel'), value: 'free_shipping', disabled: refinedFreeShippingSearchFilter == null && !isSelected, }, ], }; } if (facet.isFeatured) { const refinedIsFeaturedSearchFilter = - refinedFacet?.__typename === 'OtherSearchFilter' && refinedFacet.isFeatured + refinedFacet.__typename === 'OtherSearchFilter' && refinedFacet.isFeatured ? refinedFacet : null; const isSelected = filters.isFeatured === true; return { type: 'toggle-group' as const, paramName: `isFeatured`, label: t('isFeaturedLabel'), defaultCollapsed: facet.isCollapsedByDefault, options: [ { label: t('isFeaturedLabel'), value: 'on', disabled: refinedIsFeaturedSearchFilter == null && !isSelected, }, ], }; } if (facet.isInStock) { const refinedIsInStockSearchFilter = - refinedFacet?.__typename === 'OtherSearchFilter' && refinedFacet.isInStock + refinedFacet.__typename === 'OtherSearchFilter' && refinedFacet.isInStock ? refinedFacet : null; const isSelected = filters.hideOutOfStock === true; return { type: 'toggle-group' as const, paramName: `stock`, label: t('inStockLabel'), defaultCollapsed: facet.isCollapsedByDefault, options: [ { label: t('inStockLabel'), value: 'in_stock', disabled: refinedIsInStockSearchFilter == null && !isSelected, }, ], }; } return null; }); ``` ### Step 2 Fix the disabled state CSS classes in `core/vibes/soul/form/toggle-group/index.tsx`: ```diff ; } +type InnerProps = Props & { filters: Filter[] }; + function getParamCountLabel(params: Record, key: string) { const value = params[key]; if (Array.isArray(value) && value.length > 0) return `(${value.length})`; return ''; } export function FiltersPanel({ className, - filters, + filters: streamableFilters, resetFiltersLabel, rangeFilterApplyLabel, }: Props) { return ( - }> - - + } value={streamableFilters}> + {(filters) => ( + + )} + ); } export function FiltersPanelInner({ className, - filters: streamableFilters, + filters, resetFiltersLabel: streamableResetFiltersLabel, rangeFilterApplyLabel: streamableRangeFilterApplyLabel, paginationInfo: streamablePaginationInfo, -}: Props) { - const filters = useStreamable(streamableFilters); +}: InnerProps) { const resetFiltersLabel = useStreamable(streamableResetFiltersLabel) ?? 'Reset filters'; const rangeFilterApplyLabel = useStreamable(streamableRangeFilterApplyLabel); const paginationInfo = useStreamable(streamablePaginationInfo); const startCursorParamName = paginationInfo?.startCursorParamName ?? 'before'; const endCursorParamName = paginationInfo?.endCursorParamName ?? 'after'; const [params, setParams] = useQueryStates( { ...getFilterParsers(filters), [startCursorParamName]: parseAsString, [endCursorParamName]: parseAsString, }, { shallow: false, history: 'push', }, ); const [isPending, startTransition] = useTransition(); const [optimisticParams, setOptimisticParams] = useOptimistic(params); - const [accordionItems, setAccordionItems] = useState(() => + const [expandedItems, setExpandedItems] = useState(() => { + const initial = new Set(); + filters .filter((filter) => filter.type !== 'link-group') - .map((filter, index) => ({ - key: index.toString(), - value: index.toString(), + .slice(0, 3) + .forEach((filter) => { + initial.add(filter.label.toLowerCase()); + }); + + return initial; + }); + + const accordionItems = filters + .filter((filter) => filter.type !== 'link-group') + .map((filter) => { + return { + key: filter.label.toLowerCase(), + value: filter.label.toLowerCase(), filter, - expanded: index < 3, - })), - ); + expanded: expandedItems.has(filter.label.toLowerCase()), + }; + }); if (filters.length === 0) return null; const linkGroupFilters = filters.filter( (filter): filter is LinkGroupFilter => filter.type === 'link-group', ); ``` ```diff ))} - setAccordionItems((prevItems) => - prevItems.map((prevItem) => ({ - ...prevItem, - expanded: items.includes(prevItem.value), - })), - ) - } + onValueChange={(items) => { + setExpandedItems(new Set(items)); + }} type="multiple" value={accordionItems.filter((item) => item.expanded).map((item) => item.value)} > ``` - [#2822](https://github.com/bigcommerce/catalyst/pull/2822) [`5c3e4d2`](https://github.com/bigcommerce/catalyst/commit/5c3e4d25c2d929af2b86cee3e4133838e5f3987b) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Run E2E tests on PRs, add 'required' prop to cart shipping calculator ## Migration Add `required` prop to the Country selector and Country input fields in `core/vibes/soul/sections/cart/shipping-form/index.tsx` on lines 280 and 289. - [#2813](https://github.com/bigcommerce/catalyst/pull/2813) [`ea9d633`](https://github.com/bigcommerce/catalyst/commit/ea9d6337d2bb8e5c166cb1de3385631e12fea4a3) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Delete duplicate Select component. - [#2823](https://github.com/bigcommerce/catalyst/pull/2823) [`dcad856`](https://github.com/bigcommerce/catalyst/commit/dcad8565d5eb835a5a68785f59811a67149d0137) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Refactor DynamicForm actions to decouple fields and passwordComplexity from state, passing them as separate arguments instead. This reduces state payload size by removing fields from state objects and stripping options from fields before passing them to actions (options are only needed for rendering, not processing). All form actions now accept a `DynamicFormActionArgs` object as the first parameter containing fields and optional passwordComplexity, followed by the previous state and formData. ## Migration steps ### Step 1: Changes to DynamicForm component The `DynamicForm` component and related utilities have been updated to support the new action signature pattern: **`core/vibes/soul/form/dynamic-form/index.tsx`**: - Added `DynamicFormActionArgs` interface that contains `fields` and optional `passwordComplexity` - Updated `DynamicFormAction` type to accept `DynamicFormActionArgs` as the first parameter - Removed `fields` and `passwordComplexity` from the `State` interface - Added automatic removal of `options` from fields before passing to actions (options are only needed for rendering) - Updated action binding to use `action.bind(null, { fields: fieldsWithoutOptions, passwordComplexity })` **`core/vibes/soul/form/dynamic-form/utils.ts`** (new file): - Added `removeOptionsFromFields()` utility function that strips the `options` property from field definitions before passing them to actions, reducing the state payload size ```diff + export interface DynamicFormActionArgs { + fields: Array>; + passwordComplexity?: PasswordComplexitySettings | null; + } + + type Action = ( + args: DynamicFormActionArgs, + state: Awaited, + payload: P, + ) => S | Promise; + interface State { lastResult: SubmissionResult | null; - fields: Array>; - passwordComplexity?: PasswordComplexitySettings | null; } ``` ### Step 2: Update DynamicForm action signatures All form actions that use `DynamicForm` must be updated to accept `DynamicFormActionArgs` as the first parameter instead of including fields in the state. Update your form action function signature: ```diff + import { DynamicFormActionArgs } from '@/vibes/soul/form/dynamic-form'; import { Field, FieldGroup, schema } from '@/vibes/soul/form/dynamic-form/schema'; - export async function myFormAction( - prevState: { - lastResult: SubmissionResult | null; - fields: Array>; - passwordComplexity?: PasswordComplexitySettings | null; - }, - formData: FormData, - ) { + export async function myFormAction( + { fields, passwordComplexity }: DynamicFormActionArgs, + _prevState: { + lastResult: SubmissionResult | null; + }, + formData: FormData, + ) { ``` ### Step 2: Remove fields and passwordComplexity from state interfaces Update state interfaces to remove fields and passwordComplexity properties: ```diff interface State { lastResult: SubmissionResult | null; - fields: Array>; - passwordComplexity?: PasswordComplexitySettings | null; } ``` ### Step 3: Update action implementations Remove references to `prevState.fields` and `prevState.passwordComplexity` in action implementations: ```diff const submission = parseWithZod(formData, { - schema: schema(prevState.fields, prevState.passwordComplexity), + schema: schema(fields, passwordComplexity), }); if (submission.status !== 'success') { return { lastResult: submission.reply(), - fields: prevState.fields, - passwordComplexity: prevState.passwordComplexity, }; } ``` ### Step 4: Update action calls in components For actions used with `AddressListSection`, update the action signature to accept fields as the first parameter: ```diff - export async function addressAction( - prevState: Awaited, - formData: FormData, - ): Promise { + export async function addressAction( + fields: Array>, + prevState: Awaited, + formData: FormData, + ): Promise { ``` ### Step 5: Update DynamicForm usage No changes needed to `DynamicForm` component usage. The component automatically handles binding fields and passwordComplexity to actions. The `DynamicForm` component now: - Automatically removes options from fields before passing them to actions (reducing payload size) - Binds fields and passwordComplexity to the action using `action.bind()` - Maintains the same props interface, so existing usage continues to work ### Affected files The following files were updated in this refactor: - `core/vibes/soul/form/dynamic-form/index.tsx` - Added `DynamicFormActionArgs` type and updated action binding - `core/vibes/soul/form/dynamic-form/utils.ts` - Added `removeOptionsFromFields` utility function - `core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts` - `core/app/[locale]/(default)/account/addresses/_actions/address-action.ts` - `core/app/[locale]/(default)/account/addresses/_actions/create-address.ts` - `core/app/[locale]/(default)/account/addresses/_actions/update-address.ts` - `core/app/[locale]/(default)/account/addresses/_actions/delete-address.ts` - `core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx` - `core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts` - `core/vibes/soul/sections/address-list-section/index.tsx` - [#2816](https://github.com/bigcommerce/catalyst/pull/2816) [`b4b87a3`](https://github.com/bigcommerce/catalyst/commit/b4b87a361790bf2edd7614bab1d97490d91ed22f) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Add support for additional HTML attributes on script tags. The scripts transformer now extracts and passes through attributes like `async`, `defer`, `crossorigin`, and `data-*` attributes from BigCommerce script tags to the C15T consent manager, ensuring scripts load with their intended behavior. - [#2817](https://github.com/bigcommerce/catalyst/pull/2817) [`d469078`](https://github.com/bigcommerce/catalyst/commit/d4690786b17a7d05ef32f8941e4090101980f8bc) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Persist the checkbox product modifier since it can modify pricing and other product data. By persisting this and tracking in the url, this will trigger a product refetch when added or removed. Incidentally, now we manually control what fields are persisted, since `option.isVariantOption` doesn't apply to `checkbox`, additionally multi options modifiers that are not variant options can also modify price and other product data. ## Migration ### Step 1 Update `product-options-transformer.ts` to manually track persisted fields: ```ts case 'DropdownList': { return { // before // persist: option.isVariantOption, // after (manually persist) persist: true, type: 'select', label: option.displayName, required: option.isRequired, name: option.entityId.toString(), defaultValue: values.find((value) => value.isDefault)?.entityId.toString(), options: values.map((value) => ({ label: value.label, value: value.entityId.toString(), })), }; } ``` Fields that persist and can affect product pricing when selected: - Swatch - RectangleBoxes - RadioButtons - ProductPickList - ProductPickListWithImages - CheckboxOption ### Step 2 Remove `isVariantOption` from GQL query since we no longer use it: ```ts export const ProductOptionsFragment = graphql( ` fragment ProductOptionsFragment on Product { entityId productOptions(first: 50) { edges { node { __typename entityId displayName isRequired isVariantOption // remove this ...MultipleChoiceFieldFragment ...CheckboxFieldFragment ...NumberFieldFragment ...TextFieldFragment ...MultiLineTextFieldFragment ...DateFieldFragment } } } } `, [ MultipleChoiceFieldFragment, CheckboxFieldFragment, NumberFieldFragment, TextFieldFragment, MultiLineTextFieldFragment, DateFieldFragment, ], ); ``` ### Step 3 Update `product-detail-form.tsx` to include separate handing of the checkbox field: ```ts const defaultValue = fields.reduce<{ [Key in keyof SchemaRawShape]?: z.infer; }>( (acc, field) => { // Checkbox field has to be handled separately because we want to convert checked or unchecked value to true or undefined respectively. // This is because the form expects a boolean value, but we want to store the checked or unchecked value in the query params. if (field.type === 'checkbox') { if (params[field.name] === field.checkedValue) { return { ...acc, [field.name]: 'true', }; } if (params[field.name] === field.uncheckedValue) { return { ...acc, [field.name]: undefined, }; } return { ...acc, [field.name]: field.defaultValue, // Default value is either 'true' or undefined }; } return { ...acc, [field.name]: params[field.name] ?? field.defaultValue, }; }, { quantity: minQuantity ?? 1 }, ); ... const handleChange = useCallback( (value: string) => { // Checkbox field has to be handled separately because we want to convert 'true' or '' to the checked or unchecked value respectively. if (field.type === 'checkbox') { void setParams({ [field.name]: value ? field.checkedValue : field.uncheckedValue }); } else { void setParams({ [field.name]: value || null }); // Passing `null` to remove the value from the query params if fieldValue is falsey } controls.change(value || ''); // If fieldValue is falsey, we set it to an empty string }, [setParams, field, controls], ); ``` ### Step 4 Update schema in `core/vibes/soul/sections/product-detail/schema.ts`: ```ts type CheckboxField = { type: 'checkbox'; defaultValue?: string; checkedValue: string; // add uncheckedValue: string; // add } & FormField; ``` - [#2820](https://github.com/bigcommerce/catalyst/pull/2820) [`a50fa6f`](https://github.com/bigcommerce/catalyst/commit/a50fa6fda64cbb1370862f43d272b0069f6d6307) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix WishlistDetails page from exceeding GraphQL complexity limit, and fix wishlist e2e tests. Additionally, add the `required` prop to `core/components/wishlist/modals/new.tsx` and `core/components/wishlist/modals/rename.tsx` ## Migration ### Step 1: Update wishlist GraphQL fragments In `core/components/wishlist/fragment.ts`, replace the `WishlistItemProductFragment` to use explicit fields instead of `ProductCardFragment`: ```typescript export const WishlistItemProductFragment = graphql( ` fragment WishlistItemProductFragment on Product { entityId name defaultImage { altText url: urlTemplate(lossy: true) } path brand { name path } reviewSummary { numberOfReviews averageRating } sku showCartAction inventory { isInStock } availabilityV2 { status } ...PricingFragment } `, [PricingFragment], ); ``` Remove `ProductCardFragment` from all fragment dependencies in the same file. ### Step 2: Update product card transformer In `core/data-transformers/product-card-transformer.ts`: 1. Import the `WishlistItemProductFragment`: ```typescript import { WishlistItemProductFragment } from '~/components/wishlist/fragment'; ``` 2. Update the `singleProductCardTransformer` function signature to accept both fragment types: ```typescript product: ResultOf; ``` 3. Add a conditional check for the `inventoryMessage` field: ```typescript inventoryMessage: 'variants' in product ? getInventoryMessage(product, outOfStockMessage, showBackorderMessage) : undefined, ``` 4. Update the `productCardTransformer` function signature similarly: ```typescript products: Array>; ``` ### Step 3: Fix wishlist e2e tests In `core/tests/ui/e2e/account/wishlists.spec.ts`, update label selectors to use `{ exact: true }` for specificity: Update all locators for the wishlist name input selectors: ```diff - page.getByLabel(t('Form.nameLabel')) + page.getByLabel(t('Form.nameLabel'), { exact: true }) ``` ### Step 4: Fix mobile wishlist e2e tests In `core/tests/ui/e2e/account/wishlists.mobile.spec.ts`, update translation calls to use namespace prefixes: 1. Update the translation initialization: ```diff - const t = await getTranslations('Account.Wishlist'); + const t = await getTranslations(); ``` 2. Update all translation keys to include the namespace: ```diff - await locator.getByRole('button', { name: t('actionsTitle') }).click(); - await page.getByRole('menuitem', { name: t('share') }).click(); + await locator.getByRole('button', { name: t('Wishlist.actionsTitle') }).click(); + await page.getByRole('menuitem', { name: t('Wishlist.share') }).click(); ``` ```diff - await expect(page.getByText(t('shareSuccess'))).toBeVisible(); + await expect(page.getByText(t('Wishlist.shareSuccess'))).toBeVisible(); ``` ### Step 5: Add `required` prop to wishlist modals Update the modal forms to include the `required` prop on the name input field: In `core/components/wishlist/modals/new.tsx`: ```diff { defaultValue.current = e.target.value; }} + required /> ``` In `core/components/wishlist/modals/rename.tsx`: ```diff { defaultValue.current = e.target.value; }} + required /> ``` - [#2814](https://github.com/bigcommerce/catalyst/pull/2814) [`fcb946e`](https://github.com/bigcommerce/catalyst/commit/fcb946e47aafc8be2e63bf3fe776ec6caab1fa72) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Shoppers will now see the store's actual password complexity requirements in the tooltip on the new customer registration form, preventing confusion and failed registration attempts. The schema() function in core/vibes/soul/form/dynamic-form/schema.ts now accepts an optional second parameter passwordComplexity to enable dynamic password validation. The DynamicForm, DynamicFormSection components and their associated server actions also accept an optional passwordComplexity prop that flows through to the schema. Action Required: If you have custom registration or password forms and want to use store-specific password complexity settings, fetch passwordComplexitySettings from the GraphQL API (under site.settings.customers.passwordComplexitySettings) and pass it to your DynamicFormSection component and maintain it in your server action's state. If you don't pass it, password validation defaults to: minimum 8 characters, at least one number, and at least one special character. Conflict Resolution: If merging into custom forms, ensure the passwordComplexity prop is threaded through: Page → DynamicFormSection → DynamicForm → useActionState → schema(). In server actions, add passwordComplexity?: Parameters[1] to your state type and include it in all return statements to maintain state consistency. - [#2821](https://github.com/bigcommerce/catalyst/pull/2821) [`e5a03f6`](https://github.com/bigcommerce/catalyst/commit/e5a03f6a73fe16da55b96b75de70d3bbd846dfba) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix data-disabled class selectors in UI components ## Migration Updated Tailwind CSS class selectors from `data-disabled:` to `data-[disabled]:` in the following components: - `vibes/soul/form/button-radio-group/index.tsx` - `vibes/soul/form/card-radio-group/index.tsx` - `vibes/soul/form/radio-group/index.tsx` - `vibes/soul/form/rating-radio-group/index.tsx` - `vibes/soul/form/swatch-radio-group/index.tsx` - `vibes/soul/form/switch/index.tsx` - `vibes/soul/primitives/dropdown-menu/index.tsx` If you have customized any of these components, update your class names: ```diff - data-disabled:pointer-events-none data-disabled:opacity-50 + data-[disabled]:pointer-events-none data-[disabled]:opacity-50 ``` This change ensures proper styling of disabled states using the correct Tailwind CSS data attribute syntax. - [#2819](https://github.com/bigcommerce/catalyst/pull/2819) [`a1f1ed8`](https://github.com/bigcommerce/catalyst/commit/a1f1ed857eab954e085ba67956a954fb2b63882a) Thanks [@jamesqquick](https://github.com/jamesqquick)! - The login form input data will no longer reset on a failed login attempt. - [#2836](https://github.com/bigcommerce/catalyst/pull/2836) [`06fd9aa`](https://github.com/bigcommerce/catalyst/commit/06fd9aa921ad2c4b8b60706e114a9f425f049d40) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#2826](https://github.com/bigcommerce/catalyst/pull/2826) [`b5f460c`](https://github.com/bigcommerce/catalyst/commit/b5f460c0a5fa5161c853884e18816796b9ea73d9) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#2815](https://github.com/bigcommerce/catalyst/pull/2815) [`52ee85e`](https://github.com/bigcommerce/catalyst/commit/52ee85ef1b08fc6114aa953f7a7e67c11876e7e2) Thanks [@jamesqquick](https://github.com/jamesqquick)! - Add default optional text to form input labels for inputs that are not required. ## Migration The new required props are optional, so they are backwards compatible. However, this does mean that the `(optional)` text will now show up on fields that aren't explicitly marked as required by passing the required prop to the Label component. - [#2829](https://github.com/bigcommerce/catalyst/pull/2829) [`8096cc5`](https://github.com/bigcommerce/catalyst/commit/8096cc5ebaf983431f06bd2bd94a9a81899c2b29) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Improve accessibility for price displays by adding screen reader announcements for original prices, sale prices, and price ranges. Visual price elements are hidden from assistive technologies using `aria-hidden="true"` to prevent duplicate announcements, while visually hidden text provides context about pricing information. ## Migration steps ### Step 1: Update Cart Price Display Update `core/vibes/soul/sections/cart/client.tsx` to add accessibility labels for sale prices: ```diff {lineItem.salePrice && lineItem.salePrice !== lineItem.price ? ( - {lineItem.price} {lineItem.salePrice} + {t('originalPrice', { price: lineItem.price })} + {' '} + {t('currentPrice', { price: lineItem.salePrice })} + ) : ( {lineItem.price} )} ``` ### Step 2: Update PriceLabel Component Update `core/vibes/soul/primitives/price-label/index.tsx` to add accessibility improvements for sale prices and price ranges: ```diff import { clsx } from 'clsx'; + import { useTranslations } from 'next-intl'; export function PriceLabel({ className, colorScheme = 'light', price }: Props) { + const t = useTranslations('Components.Price'); if (typeof price === 'string') { return ( ... ); } switch (price.type) { case 'range': return ( - {price.minValue} -  –  - {price.maxValue} + + {t('range', { minValue: price.minValue, maxValue: price.maxValue })} + + ); case 'sale': return ( + {t('originalPrice', { price: price.previousValue })} {' '} + {t('currentPrice', { price: price.currentValue })} ); } } ``` ### Step 3: Add Translation Keys Update `core/messages/en.json` to include new translation keys for price accessibility: ```diff "Cart": { "title": "Cart", "heading": "Your cart", "proceedToCheckout": "Proceed to checkout", "increment": "Increase quantity", "decrement": "Decrease quantity", "removeItem": "Remove item", "cartCombined": "We noticed you had items saved in a previous cart, so we've added them to your current cart for you.", "cartRestored": "You started a cart on another device, and we've restored it here so you can pick up where you left off.", "cartUpdateInProgress": "You have a cart update in progress. Are you sure you want to leave this page? Your changes may be lost.", + "originalPrice": "Original price was {price}.", + "currentPrice": "Current price is {price}.", ``` ```diff }, + "Price": { + "originalPrice": "Original price was {price}.", + "currentPrice": "Current price is {price}.", + "range": "Price from {minValue} to {maxValue}." + } }, "GiftCertificates": { ``` - [#2809](https://github.com/bigcommerce/catalyst/pull/2809) [`dd559b2`](https://github.com/bigcommerce/catalyst/commit/dd559b2d9f354387e19d7c81a809eed97bfc9be3) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Minor UX improvements for the Reviews section: - Show `totalCount` for reviews. - Show `averageRating` up to the first decimal. - Hide `averageRating` next to rating stars when there are no reviews. ## 1.4.0 ### Minor Changes - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Upgrade c15t to 1.8.2, migrate from custom mode to offline mode, refactor consent cookie handling to use c15t's compact format, add script location support for HEAD/BODY rendering, and add privacy policy link support to CookieBanner. ## What Changed - Upgraded `@c15t/nextjs` to version `1.8.2` - Changed consent manager mode from `custom` (with endpoint handlers) to `offline` mode - Removed custom `handlers.ts` implementation - Added `enabled` prop to `C15TConsentManagerProvider` to control consent manager functionality - Removed custom consent cookie encoder/decoder implementations (`decoder.ts`, `encoder.ts`) - Added `parse-compact-format.ts` to handle c15t's compact cookie format - Compact format: `i.t:timestamp,c.necessary:1,c.functionality:1,etc...` - Updated cookie parsing logic in both client and server to use the new compact format parser - Scripts now support `location` field from BigCommerce API and can be rendered in `` or `` based on the `target` property - `CookieBanner` now supports the `privacyPolicyUrl` field from BigCommerce API and will be rendered in the banner description if available. ## Migration Path ### Consent Manager Provider Changes The `ConsentManagerProvider` now uses `offline` mode instead of `custom` mode with endpoint handlers. The provider configuration has been simplified: **Before:** ```typescript showConsentBanner(isCookieConsentEnabled), setConsent, verifyConsent, }, }} > {children} ``` **After:** ```typescript {children} ``` **Key changes:** - `mode` changed from `'custom'` to `'offline'` - Removed `endpointHandlers` - no longer needed in offline mode - Added `enabled` prop to control consent manager functionality - Added `storageConfig` for cookie storage configuration ### Cookie Handling If you have custom code that directly reads or writes consent cookies, you'll need to update it: **Before:** The previous implementation used custom encoding/decoding. If you were directly accessing consent cookie values, you would have needed to use the custom decoder. **After:** The consent cookie now uses c15t's compact format. The public API for reading cookies remains the same: ```typescript import { getConsentCookie } from '~/lib/consent-manager/cookies/client'; // client-side // or import { getConsentCookie } from '~/lib/consent-manager/cookies/server'; // server-side const consent = getConsentCookie(); ``` The `getConsentCookie()` function now internally uses `parseCompactFormat()` to parse the compact format cookie string. If you were directly parsing cookie values, you should now use the `getConsentCookie()` helper instead. `getConsentCookie` now returns a compact version of the consent values: ```typescript { i.t: 123456789, c.necessary: true, c.functionality: true, c.marketing: false, c.measurment: false } ``` Updated instances where `getConsentCookie` is used to reflect this new schema. Removed `setConsentCookie` from server and client since this is now handled by the c15t library. ### Script Location Support Scripts now support rendering in either `` or `` based on the `location` field from the BigCommerce API: ```typescript // Scripts transformer now includes target based on location target: script.location === 'HEAD' ? 'head' : 'body'; ``` The `ScriptsFragment` GraphQL query now includes the `location` field, allowing scripts to be placed in the appropriate DOM location. `FOOTER` location is still not supported. ### Privacy Policy The `RootLayoutMetadataQuery` GraphQL query now includes the `privacyPolicyUrl` field, which renders a provicy policy link in the `CookieBanner` description. ```typescript ``` The privacy policy link: - Opens in a new tab (`target="_blank"`) - Only renders if `privacyPolicyUrl` is provided as a non-empty string Add translatable `privacyPolicy` field to `Components.ConsentManager.CookieBanner` translation namespace for the privacy policy link text. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Conditionally display product ratings in the storefront based on `site.settings.display.showProductRating`. The storefront logic when this setting is enabled/disabled matches exactly the logic of Stencil + Cornerstone. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Adds product review submission functionality to the product detail page via a modal form with validation for rating, title, review text, name, and email fields. Integrates with BigCommerce's GraphQL API using Conform and Zod for form validation and real-time feedback. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Introduce displayName and displayKey fields to facets for improved labeling and filtering Facet filters now use the `displayName` field for more descriptive labels in the UI, replacing the deprecated `name` field. Product attribute facets now support the `filterKey` field for consistent parameter naming. The facet transformer has been updated to use `displayName` with a fallback to `filterName` when `displayName` is not available. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Updated product and brand pages to include the number of reviews in the product data. Fixed visual spacing within product cards. Enhanced the Rating component to display the number of reviews alongside the rating. Introduced a new RatingLink component for smooth scrolling to reviews section on PDP. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Make newsletter signup component on homepage render conditionally based on BigCommerce settings. ## What Changed - Newsletter signup component (`Subscribe`) on homepage now conditionally renders based on `showNewsletterSignup` setting from BigCommerce. - Added `showNewsletterSignup` field to `HomePageQuery` GraphQL query to fetch newsletter settings. - Newsletter signup now uses `Stream` component with `Streamable` pattern for progressive loading. ## Migration To make newsletter signup component render conditionally based on BigCommerce settings, update your homepage code: ### 1. Update GraphQL Query (`page-data.ts`) Add the `newsletter` field to your `HomePageQuery`: ```typescript const HomePageQuery = graphql( ` query HomePageQuery($currencyCode: currencyCode) { site { // ... existing fields settings { inventory { defaultOutOfStockMessage showOutOfStockMessage showBackorderMessage } newsletter { showNewsletterSignup } } } } `, [FeaturedProductsCarouselFragment, FeaturedProductsListFragment], ); ``` ### 2. Update Homepage Component (`page.tsx`) Import `Stream` and create a streamable for newsletter settings: ```typescript import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; // Inside your component, create the streamable: const streamableShowNewsletterSignup = Streamable.from(async () => { const data = await streamablePageData; const { showNewsletterSignup } = data.site.settings?.newsletter ?? {}; return showNewsletterSignup; }); // Replace direct rendering with conditional Stream: {(showNewsletterSignup) => showNewsletterSignup && } ``` **Before:** ```typescript ``` **After:** ```typescript {(showNewsletterSignup) => showNewsletterSignup && } ``` - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Refactor the `ReviewForm` to accept `trigger` prop instead of `formButtonLabel` for flexible rendering. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Adds OpenTelemetry instrumentation for Catalyst, enabling the collection of spans for Catalyst storefronts. ### Migration Change is new code only, so just copy over `/core/instrumentation.ts` and `core/lib/otel/tracers.ts`. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Implement functional newsletter subscription feature with BigCommerce GraphQL API integration. ## What Changed - Replaced the mock implementation in `subscribe.ts` with a real BigCommerce GraphQL API call using the `SubscribeToNewsletterMutation`. - Added comprehensive error handling for invalid emails, already-subscribed users, and unexpected errors. - Improved form error handling in `InlineEmailForm` to use `form.errors` instead of field-level errors for better error display. - Added comprehensive E2E tests and test fixtures for subscription functionality. ## Migration Guide Replace the `subscribe` action in `core/components/subscribe/_actions/subscribe.ts` with the latest changes to include: - BigCommerce GraphQL mutation for newsletter subscription - Error handling for invalid emails, already-subscribed users, and unexpected errors - Proper error messages returned via Conform's `submission.reply()` Update `inline-email-form` to fix issue of not showing server-side error messages from form actions. **`core/vibes/soul/primitives/inline-email-form/index.tsx`** 1. Add import for `FieldError` component: ```tsx import { FieldError } from '@/vibes/soul/form/field-error'; ``` 2. Remove the field errors extraction: ```tsx // Remove: const { errors = [] } = fields.email; ``` 3. Update border styling to check both form and field errors: ```tsx // Changed from: errors.length ? 'border-error' : 'border-black', // Changed to: form.errors?.length || fields.email.errors?.length ? 'border-error focus-within:border-error' : 'border-black focus-within:border-primary', ``` 4. Update error rendering to display both field-level and form-level errors: ```tsx // Changed from: { errors.map((error, index) => ( {error} )); } // Changed to: { fields.email.errors?.map((error) => {error}); } { form.errors?.map((error, index) => ( {error} )); } ``` This change ensures that server-side error messages returned from form actions (like `formErrors` from Conform's `submission.reply()`) are now properly displayed to users. Add the following translation keys to your locale files (e.g., `messages/en.json`): ```json { "Components": { "Subscribe": { "title": "Sign up for our newsletter", "placeholder": "Enter your email", "description": "Stay up to date with the latest news and offers from our store.", "subscribedToNewsletter": "You have been subscribed to our newsletter!", "Errors": { "invalidEmail": "Please enter a valid email address.", "somethingWentWrong": "Something went wrong. Please try again later." } } } } ``` - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Separate first and last name fields on user session object. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Conditionally enable storefront reviews functionality based on `site.settings.reviews.enabled`. The storefront logic when this setting is enabled/disabled matches exactly the logic of Stencil + Cornerstone. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Add out-of-stock / backorder message to product cards on PLPs based on store settings: - Add out of stock message if the product is out of stock and stock is set to display it. - Add the backorder message if the product has no on-hand stock and is available for backorder and the store/product is set to display the backorder message ## Migration ### Option 1: Automatic Migration (Recommended) For existing Catalyst stores, the simplest way to get the newly added feature is to rebase the existing code with the new release code. The files that will be updated are listed below. ### Option 2: Manual Migration If you prefer not to rebase or have made customizations that prevent rebasing, follow these manual steps: #### Step 1: Update GraphQL Fragment Add the inventory fields to your product card fragment in `core/components/product-card/fragment.ts` under `Product`: ```graphql inventory { hasVariantInventory isInStock aggregated { availableForBackorder unlimitedBackorder availableOnHand } } variants(first: 1) { edges { node { entityId sku inventory { byLocation { edges { node { locationEntityId backorderMessage } } } } } } } ``` #### Step 2: Update Product interface in Product Card component Update the `Product` interface in `core/vibes/soul/primitives/product-card/index.tsx` adding the following field to it: `inventoryMessage?: string;` #### Step 3: Update Data Transformer Modify `core/data-transformers/product-card-transformer.ts` to include inventory message in the transformed data. You can simply copy the whole file from this release as it does not have UI breaking changes. #### Step 4: Update Product Card Layout Update `core/vibes/soul/primitives/product-card/index.tsx` layout to display the new `inventoryMessage` product field. #### Step 5: Update Page Data GraphQL queries Add inventory settings queries to the pages data. Add the following query to the main GQL query under `site.settings`: ``` inventory { defaultOutOfStockMessage showOutOfStockMessage showBackorderMessage } ``` to the following page data files: - `core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts` - `core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts` - `core/app/[locale]/(default)/(faceted)/search/page-data.ts` - `core/app/[locale]/(default)/page-data.ts` #### Step 6: Update Page Components Update the corresponding page components to use the `productCardTransformer` method (if not already using it) to get the product card, and pass inventory data to those product cards based on the store inventory settings. Use the following code while retrieving the product lists: ``` const { defaultOutOfStockMessage, showOutOfStockMessage, showBackorderMessage } = data.site.settings?.inventory ?? {}; return productCardTransformer( featuredProducts, format, showOutOfStockMessage ? defaultOutOfStockMessage : undefined, showBackorderMessage, ); ``` in the following files: - `core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx` - `core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx` - `core/app/[locale]/(default)/(faceted)/search/page.tsx` - `core/app/[locale]/(default)/page.tsx` ### Files Modified in This Change - `core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts` - `core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx` - `core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts` - `core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx` - `core/app/[locale]/(default)/(faceted)/search/page-data.ts` - `core/app/[locale]/(default)/(faceted)/search/page.tsx` - `core/app/[locale]/(default)/page-data.ts` - `core/app/[locale]/(default)/page.tsx` - `core/components/product-card/fragment.ts` - `core/data-transformers/product-card-transformer.ts` - `core/vibes/soul/primitives/product-card/index.tsx` - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Add newsletter subscription toggle to account settings page, allowing customers to manage their marketing preferences directly from their account. ## What Changed - Added `NewsletterSubscriptionForm` component with a toggle switch for subscribing/unsubscribing to newsletters - Created `updateNewsletterSubscription` server action that handles both subscribe and unsubscribe operations via BigCommerce GraphQL API - Updated `AccountSettingsSection` to conditionally display the newsletter subscription form when enabled - Enhanced `CustomerSettingsQuery` to fetch `isSubscribedToNewsletter` status and `showNewsletterSignup` store setting - Updated account settings page to pass newsletter subscription props and bind customer info to the action - Added translation keys for newsletter subscription UI in `Account.Settings.NewsletterSubscription` namespace - Added E2E tests for subscribing and unsubscribing functionality ## Migration Guide To add the newsletter subscription toggle to your account settings page: ### Step 1: Copy the server action Copy the new server action file to your account settings directory: ```bash cp core/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts \ your-app/app/[locale]/(default)/account/settings/_actions/update-newsletter-subscription.ts ``` ### Step 2: Update the GraphQL query Update `core/app/[locale]/(default)/account/settings/page-data.tsx` to include newsletter subscription fields: ```tsx // Renamed CustomerSettingsQuery to AccountSettingsQuery const AccountSettingsQuery = graphql(` query AccountSettingsQuery(...) { customer { ... isSubscribedToNewsletter # Add this field } site { settings { ... newsletter { # Add this section showNewsletterSignup } } } } `); ``` Also update the return statement to include `newsletterSettings`: ```tsx const newsletterSettings = response.data.site.settings?.newsletter; return { ...newsletterSettings, // Add this }; ``` ### Step 3: Copy the NewsletterSubscriptionForm component Copy the new form component: ```bash cp core/vibes/soul/sections/account-settings/newsletter-subscription-form.tsx \ your-app/vibes/soul/sections/account-settings/newsletter-subscription-form.tsx ``` ### Step 4: Update AccountSettingsSection Update `core/vibes/soul/sections/account-settings/index.tsx`: 1. Import the new component: ```tsx import { NewsletterSubscriptionForm, UpdateNewsletterSubscriptionAction, } from './newsletter-subscription-form'; ``` 2. Add props to the interface: ```tsx export interface AccountSettingsSectionProps { ... newsletterSubscriptionEnabled?: boolean; isAccountSubscribed?: boolean; newsletterSubscriptionTitle?: string; newsletterSubscriptionLabel?: string; newsletterSubscriptionCtaLabel?: string; updateNewsletterSubscriptionAction?: UpdateNewsletterSubscriptionAction; } ``` 3. Add the form section in the component (after the change password form): ```tsx { newsletterSubscriptionEnabled && updateNewsletterSubscriptionAction && (

{newsletterSubscriptionTitle}

); } ``` ### Step 5: Update the account settings page Update `core/app/[locale]/(default)/account/settings/page.tsx`: 1. Import the action: ```tsx import { updateNewsletterSubscription } from './_actions/update-newsletter-subscription'; ``` 2. Extract newsletter settings from the query: ```tsx const newsletterSubscriptionEnabled = accountSettings.storeSettings?.showNewsletterSignup; const isAccountSubscribed = accountSettings.customerInfo.isSubscribedToNewsletter; ``` 3. Bind customer info to the action: ```tsx const updateNewsletterSubscriptionActionWithCustomerInfo = updateNewsletterSubscription.bind( null, { customerInfo: accountSettings.customerInfo, }, ); ``` 4. Pass props to `AccountSettingsSection`: ```tsx ``` ### Step 6: Add translation keys Add the following keys to your locale files (e.g., `messages/en.json`): ```json { "Account": { "Settings": { ... "NewsletterSubscription": { "title": "Marketing preferences", "label": "Opt-in to receive emails about new products and promotions.", "marketingPreferencesUpdated": "Marketing preferences have been updated successfully!", "somethingWentWrong": "Something went wrong. Please try again later." } } } } ``` ### Step 7: Verify the feature 1. Ensure your BigCommerce store has newsletter signup enabled in store settings 2. Navigate to `/account/settings` as a logged-in customer 3. Verify the newsletter subscription toggle appears below the change password form 4. Test subscribing and unsubscribing functionality The newsletter subscription form will only display if `newsletterSubscriptionEnabled` is `true` (controlled by the `showNewsletterSignup` store setting). - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Add the following backorder messages to PDP based on the store inventory settings and the product backorders data: - Backorder availability prompt - Quantity on backorder - Backorder message ## Migration For existing Catalyst stores, to get the newly added feature, simply rebase the existing code with the new release code. The files to be rebased for this change to be applied are: - core/messages/en.json - core/app/[locale]/(default)/product/[slug]/page-data.ts - core/app/[locale]/(default)/product/[slug]/page.tsx - core/app/[locale]/(default)/product/[slug]/\_components/product-viewed/fragment.ts - core/vibes/soul/sections/product-detail/index.tsx - core/vibes/soul/sections/product-detail/product-detail-form.tsx ### Patch Changes - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Update /login/token route error handling and messaging ## Migration steps ### 1. Add `invalidToken` translation key to the `Auth.Login` namespace: ```json "invalidToken": "Your login link is invalid or has expired. Please try logging in again.", ``` ### 2. In `core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts`, add a `console.error` in the `catch` block to log the error details: ```typescript } catch (error) { // eslint-disable-next-line no-console console.error(error); // ... } ``` ### 3. In `core/app/[locale]/(default)/(auth)/login/page.tsx`, add `error` prop to searchParams and pass it down into the `SignInSection` component: ```typescript export default async function Login({ params, searchParams }: Props) { const { locale } = await params; const { redirectTo = '/account/orders', error } = await searchParams; setRequestLocale(locale); const t = await getTranslations('Auth.Login'); 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 ( <> { // If the form errors change when an "error" search param is in the URL, // the search param should be removed to prevent showing stale errors. if (form.errors) { const url = new URL(window.location.href); if (url.searchParams.has('error')) { url.searchParams.delete('error'); window.history.replaceState({}, '', url.toString()); } } }, [form.errors]); const formErrors = () => { // Form errors should take precedence over the error prop that is passed in. // This ensures that the most recent errors are displayed to avoid confusion. if (form.errors) { return form.errors; } if (error) { return [error]; } return []; }; return (
// ... {submitLabel} {formErrors().map((err, index) => ( {err} ))}
); } ``` ### 6. Copy all changes in the `core/tests` directory - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Passes `formButtonLabel` from `Reviews` to `ReviewsEmptyState` (was missing) and sets a default value for `formButtonLabel` - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Remove "Exclusive Offers" field temporarily. Currently, the field is not fully implemented in GraphQL, so it may be misleading to display it on the storefront if it's not actually doing anything when registering a customer. Once the Register Customer operation takes this field into account, we can display it again. ## Migration Update `core/app/[locale]/(default)/(auth)/register/page.tsx` and add the function: ```ts // There is currently a GraphQL gap where the "Exclusive Offers" field isn't accounted for // during customer registration, so the field should not be shown on the Catalyst storefront until it is hooked up. function removeExlusiveOffersField(field: Field | Field[]): boolean { if (Array.isArray(field)) { // Exclusive offers field will always have ID '25', since it is made upon store creation and is also read-only. return !field.some((f) => f.id === '25'); } return field.id !== '25'; } ``` Then, add the following code at the end of the `const fields` declaration: ```ts }) .filter(exists) .filter(removeExlusiveOffersField); // <--- ``` - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Add missing check for optional text field in `core/vibes/soul/form/dynamic-form/schema.ts`. ## Migration Add `if (field.required !== true) fieldSchema = fieldSchema.optional();` to `text` case in `core/vibes/soul/form/dynamic-form/schema.ts`: ```typescript case 'text': fieldSchema = z.string(); if (field.pattern != null) { fieldSchema = fieldSchema.regex(new RegExp(field.pattern), { message: 'Invalid format.', }); } if (field.required !== true) fieldSchema = fieldSchema.optional(); break; ``` - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Improved login error handling to display a custom error message when BigCommerce indicates a password reset is required, instead of showing a generic error message. ## What's Fixed When attempting to log in with an account that requires a password reset, users now see an informative error message: "Password reset required. Please check your email for instructions to reset your password." **Before**: Generic "something went wrong" error message **After**: Clear error message explaining the password reset requirement ## Migration ### Step 1: Update Translation Files Add this translation key to your locale files (e.g., `core/messages/en.json`): ```json { "Auth": { "Login": { "passwordResetRequired": "Password reset required. Please check your email for instructions to reset your password." } } } ``` Repeat for all supported locales if you maintain custom translations. ### Step 2: Update Login Server Action In your login server action (e.g., `core/app/[locale]/(default)/(auth)/login/_actions/login.ts`): Add the password reset error handling block: ```typescript 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')] }); } ``` This should be placed in your error handling, before the generic "Invalid credentials" check. - [#2803](https://github.com/bigcommerce/catalyst/pull/2803) [`dbd80fe`](https://github.com/bigcommerce/catalyst/commit/dbd80fe8d74d4e97252fa94c6568115748b6bbea) Thanks [@jorgemoya](https://github.com/jorgemoya)! - - Added optional `salePrice?: string` property to the `CartLineItem` interface - Cart UI now displays sale prices with a strikethrough on the original price when `salePrice` is provided and differs from `price` ## Migration If you're using the `Cart` component with custom line items, you can now optionally include a `salePrice` property: ```tsx const lineItems = [ { // ... other properties price: '$100.00', salePrice: '$80.00', // Optional: when provided, displays as strikethrough price + sale price }, ]; ``` ### Backward Compatibility This change is **fully backward compatible**. The `salePrice` property is optional, so existing implementations will continue to work without modification. If `salePrice` is not provided or equals `price`, only the regular price will be displayed. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Update translations. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Update translations. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Update translations. - [#2806](https://github.com/bigcommerce/catalyst/pull/2806) [`becb67d`](https://github.com/bigcommerce/catalyst/commit/becb67df001e4a85a3e59ece24c3e44bd3a24cf6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Update translations. ## 1.3.7 ### 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. ## 🔒 Security Update **This upgrade addresses a security vulnerability ([CVE-2025-55184 + CVE-2025-55183](https://nextjs.org/blog/security-update-2025-12-11))** that affects React Server Components. These vulnerabilities allow a Denial of Service attack and Source Code Exposure attach. This upgrade includes: - Next.js 15.5.9 with the security patch - React 19.1.4 and React DOM 19.1.4 with the security patch **All users are strongly encouraged to upgrade immediately.** ## Key Changes - ⚡ **Next.js 15.5.9**: Upgraded from Next.js 15.5.7 to 15.5.9 - ⚛️ **React 19**: Upgraded to React 19.1.4 and React DOM 19.1.4 ## Migration Guide ### Update Dependencies If you're maintaining a custom Catalyst store, update your `package.json`: ```json { "dependencies": { "next": "15.5.9", "react": "19.1.4", "react-dom": "19.1.4" }, "devDependencies": { "@next/bundle-analyzer": "15.5.9", "eslint-config-next": "15.5.9" } } ``` Then run: ```bash pnpm install ``` ## 1.3.6 ### 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 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. ## 🔒 Critical Security Update **This upgrade addresses a critical security vulnerability ([CVE-2025-55184 + CVE-2025-55183](https://nextjs.org/blog/security-update-2025-12-11))** that affects React Server Components. These vulnerabilities allow a Denial of Service attack and Source Code Exposure attach. This upgrade includes: - Next.js 15.5.8 with the security patch - React 19.1.3 and React DOM 19.1.3 with the security patch **All users are strongly encouraged to upgrade immediately.** ## Key Changes - ⚡ **Next.js 15.5.8**: Upgraded from Next.js 15.5.7 to 15.5.8 - ⚛️ **React 19**: Upgraded to React 19.1.3 and React DOM 19.1.3 ## Migration Guide ### Update Dependencies If you're maintaining a custom Catalyst store, update your `package.json`: ```json { "dependencies": { "next": "15.5.8", "react": "19.1.3", "react-dom": "19.1.3" }, "devDependencies": { "@next/bundle-analyzer": "15.5.8", "eslint-config-next": "15.5.8" } } ``` Then run: ```bash pnpm install ``` ## 1.3.5 ### Patch Changes - [#2744](https://github.com/bigcommerce/catalyst/pull/2744) [`720fe17`](https://github.com/bigcommerce/catalyst/commit/720fe1722295841a995277ec514bc8280644b879) Thanks [@chanceaclark](https://github.com/chanceaclark)! - # Next.js 15.5.7 Upgrade 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 **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 ## 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', // } ``` Remove or comment out eslint config ```typescript // eslint: { // ignoreDuringBuilds: !!process.env.CI, // dirs: [ // 'app', // 'auth', // 'build-config', // 'client', // 'components', // 'data-transformers', // 'i18n', // 'lib', // 'middlewares', // 'scripts', // 'tests', // 'vibes', // ], // }, ``` ### Step 3: Remove `export const experimental_ppr` Remove any references to `export const experimental_ppr` in your codebase as it is not being used anymore. ## 1.3.4 ### Patch Changes - [#2720](https://github.com/bigcommerce/catalyst/pull/2720) [`ebd5993`](https://github.com/bigcommerce/catalyst/commit/ebd5993cfbb507da1d2341fb6f1a7276eee50795) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Noop release to account for typecheck issue. ## 1.3.3 ### Patch Changes - [#2695](https://github.com/bigcommerce/catalyst/pull/2695) [`6d565c2`](https://github.com/bigcommerce/catalyst/commit/6d565c2cbf98e3fa0c2b0142734fc68a5d48bd2c) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add Gift Certificates link to the header/footer. - [#2694](https://github.com/bigcommerce/catalyst/pull/2694) [`fdedcaa`](https://github.com/bigcommerce/catalyst/commit/fdedcaa99c83d5c32c54dec0974962b7d17447cf) Thanks [@BC-AdamWard](https://github.com/BC-AdamWard)! - Fix anonymous session cookie maxAge calculation to correctly set 7 days instead of 7 hours. ## 1.3.2 ### Patch Changes - [`ce1731f`](https://github.com/bigcommerce/catalyst/commit/ce1731f0cb0f0e411c3ffa4734b0256dbdacafbb) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Updates the scripts transformer to account for inline scripts having a `src` attribute with empty content. ## 1.3.1 ### Patch Changes - [#2679](https://github.com/bigcommerce/catalyst/pull/2679) [`323483a`](https://github.com/bigcommerce/catalyst/commit/323483adfdd7dc1bf925034ae360d7a803d76ab9) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Update the `lint` command to utilize `eslint` CLI directly. `next lint` is deprecated in Next.js version 16 and this provides a lower migration impact when the time comes. - [#2681](https://github.com/bigcommerce/catalyst/pull/2681) [`cacfb55`](https://github.com/bigcommerce/catalyst/commit/cacfb55b89d7de64c80eb66b671d279898a43a1e) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Minor refactor to improve the performance when navigating from the cart to the checkout. - [#2680](https://github.com/bigcommerce/catalyst/pull/2680) - [#2681](https://github.com/bigcommerce/catalyst/pull/2681) ### Migration Use the above PR diffs as a reference. 1. Remove `core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts` 2. Update `checkoutAction` in `core/app/[locale]/(default)/cart/page.tsx` to `"/checkout"` 3. Copy changes to `core/app/[locale]/(default)/checkout/route.ts` 4. Update `core/lib/server-toast.ts` and set the cookie `maxAge` to `1` - this ensures any toast errors live through the redirect back to the `/cart` page 5. Copy changes in `core/vibes/soul/sections/cart/client.tsx` 6. Update `en.json` with the updated translation values (optional) ## 1.3.0 ### Minor Changes - [#2659](https://github.com/bigcommerce/catalyst/pull/2659) [`abaa461`](https://github.com/bigcommerce/catalyst/commit/abaa46102d1024b99d6a3fca116ac910c104b719) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Adds consent-aware script loading to Catalyst's consent manager, achieving parity with Stencil's behavior where scripts are conditionally rendered based on user consent preferences. BigCommerce scripts from the Store Scripts API are now transformed and loaded via C15T's ClientSideOptionsProvider, with ESSENTIAL/UNKNOWN scripts rendering by default, all scripts rendering when consent is fully granted, and specific scripts loading based on granular consent selections. - [#2643](https://github.com/bigcommerce/catalyst/pull/2643) [`391e20d`](https://github.com/bigcommerce/catalyst/commit/391e20d2c03e8f607f6f6c80d294565e79546693) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Adds a new consent manager provider using the `@c15t/nextjs` package to handle cookie consent management with support for multiple consent categories and cookie-based persistence. - [#2666](https://github.com/bigcommerce/catalyst/pull/2666) [`ed1f615`](https://github.com/bigcommerce/catalyst/commit/ed1f615a9b84a70e24fd7015fbd17bd5abe47695) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Passes the shoppers consent to the checkout redirect mutation - [#2619](https://github.com/bigcommerce/catalyst/pull/2619) [`19077cd`](https://github.com/bigcommerce/catalyst/commit/19077cd294c5c710dfdeae54f29f13a76401bfa4) Thanks [@Tharaae](https://github.com/Tharaae)! - Add current stock message to product details page based on the store/product inventory settings. ## Migration For existing Catalyst stores, to get the newly added feature, simply rebase the existing code with the new release code. The files to be rebased for this change to be applied are: - core/messages/en.json - core/app/[locale]/(default)/product/[slug]/page-data.ts - core/app/[locale]/(default)/product/[slug]/page.tsx - [#2664](https://github.com/bigcommerce/catalyst/pull/2664) [`71cfd62`](https://github.com/bigcommerce/catalyst/commit/71cfd62e99c223391e77d20198e9cf673bc61dd9) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Provides a way to track analytics consent updates within the analytics provider. This also enables a the Google Analytics provider to be able to get the initial consent values so it can initialize the default consent values. - [#2661](https://github.com/bigcommerce/catalyst/pull/2661) [`be00b44`](https://github.com/bigcommerce/catalyst/commit/be00b44cd3afd82dff84dfa1104eccbcb6df946f) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Integrates Catalyst's consent manager with the BigCommerce Control Panel's Cookie Consent setting, allowing merchants to centrally control whether the consent banner displays on the storefront. When disabled in the Control Panel, the consent banner is suppressed and all script categories are consented implicitly, matching Stencil behavior. - [#2650](https://github.com/bigcommerce/catalyst/pull/2650) [`416796f`](https://github.com/bigcommerce/catalyst/commit/416796fa4143d28ce41ba89a3f176f3b7fba552c) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Added consent manager UI components with Catalyst styling and next-intl integration. The `CookieBanner` and `ConsentManagerDialog` provide a customizable banner and preference dialog for cookie consent. ### Patch Changes - [#2577](https://github.com/bigcommerce/catalyst/pull/2577) [`baf07ca`](https://github.com/bigcommerce/catalyst/commit/baf07ca89fdbb65bebf5926738b72690e4e6db60) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove unused exports from core - [#2551](https://github.com/bigcommerce/catalyst/pull/2551) [`be23108`](https://github.com/bigcommerce/catalyst/commit/be2310809342d83cfc1a3619a537d2abf66dfe79) Thanks [@jkanive](https://github.com/jkanive)! - fix: resolve maintenance page width issues - Add w-full classes to ensure proper width expansion - Remove flex-1 in favor of w-full for column layout - [#2574](https://github.com/bigcommerce/catalyst/pull/2574) [`be80d14`](https://github.com/bigcommerce/catalyst/commit/be80d14c1c1051189e629d182dfd46fc60c363b1) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove unused dependencies. - [#2609](https://github.com/bigcommerce/catalyst/pull/2609) [`4e6f58d`](https://github.com/bigcommerce/catalyst/commit/4e6f58dfda649571c56ec57b642e7addfb92a4aa) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Adds the product count to the facet label if the facet provides the count. This also fixes an issue where the facets weren't respecting the collapse by default setting. - [#2572](https://github.com/bigcommerce/catalyst/pull/2572) [`337b7ce`](https://github.com/bigcommerce/catalyst/commit/337b7ce05ee71ec3d937c3aa373aec8516896254) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove unused UI files. - [#2580](https://github.com/bigcommerce/catalyst/pull/2580) [`f790cd6`](https://github.com/bigcommerce/catalyst/commit/f790cd67a4b632ecb4f3dfd5e7d416859cbcb042) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove unused export types from core. - [#2670](https://github.com/bigcommerce/catalyst/pull/2670) [`d5fbb73`](https://github.com/bigcommerce/catalyst/commit/d5fbb7394696dc77da5cf9b615a2b73eba28e8e5) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fixed issue with 301 redirect loops when `TRAILING_SLASH` is set to `false`, or when 301 redirects exist targeting the same path but with different capitalization. - [#2585](https://github.com/bigcommerce/catalyst/pull/2585) [`a40b96f`](https://github.com/bigcommerce/catalyst/commit/a40b96f743ce5462cba4615026cbc6951aa87104) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Add graceful error handling for invalid anonymous JWT cookies - [#2578](https://github.com/bigcommerce/catalyst/pull/2578) [`bb7940c`](https://github.com/bigcommerce/catalyst/commit/bb7940cedd169f053d55b787cc2b7183f737edba) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove recpatcha code until we're ready to add it at a later point (if needed). ## Migration - A lot of the code removed was just old commented out blocks. - Remove any recaptcha mention from graphql mutation and queries - [#2656](https://github.com/bigcommerce/catalyst/pull/2656) [`ff9aa17`](https://github.com/bigcommerce/catalyst/commit/ff9aa17a78c4731af02ebb4d220f86960c0e9169) Thanks [@dependabot](https://github.com/apps/dependabot)! - Updates next-auth to the latest beta version ## Migration Delete the `@ts-expect-error` comments within the `with-auth.ts` middleware. - [#2662](https://github.com/bigcommerce/catalyst/pull/2662) [`8c6626e`](https://github.com/bigcommerce/catalyst/commit/8c6626e104d81dce09ecc623cdec949a23224ee8) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Replace `StreamableAnalyticsProvider`with simplified `AnalyticsProvider` component. - Removed `StreamableAnalyticsProvider` that used `Streamable.from()` for async data loading. - Added new `AnalyticsProvider` component that accepts `channelId` and `settings` as direct props. - Simplifies analytics initialization by removing unnecessary streaming complexity. - Maintains same functionality with cleaner, more straightforward implementation. - Fixes issue of events not triggering by properly wrapping `children` inside the provider. ## Migration - Use new `AnalyticsProvider` component in `core/app/[locale]/layout.tsx`, instead of `StreamableAnalyticsProvider`. - [#2667](https://github.com/bigcommerce/catalyst/pull/2667) [`c8dbba6`](https://github.com/bigcommerce/catalyst/commit/c8dbba6de4aa70dbe7c4ced3ce29375aea214d1f) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#2589](https://github.com/bigcommerce/catalyst/pull/2589) [`d3391ee`](https://github.com/bigcommerce/catalyst/commit/d3391eea6c87d05629e15f4f47bb5ad54c47f081) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#2675](https://github.com/bigcommerce/catalyst/pull/2675) [`ab9f11e`](https://github.com/bigcommerce/catalyst/commit/ab9f11ed0d75a0f6cd6f3abc039c1652415057a2) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#2608](https://github.com/bigcommerce/catalyst/pull/2608) [`3d47825`](https://github.com/bigcommerce/catalyst/commit/3d4782536022e1f9a33237963c4f013558108cf3) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#2648](https://github.com/bigcommerce/catalyst/pull/2648) [`7914650`](https://github.com/bigcommerce/catalyst/commit/791465079b8abaeae7662745c28e99f67876393c) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. ## 1.2.0 ### Minor Changes - [#2475](https://github.com/bigcommerce/catalyst/pull/2475) [`33b574d`](https://github.com/bigcommerce/catalyst/commit/33b574d2fefe8514c4e512acf3f706058a6c8a2f) Thanks [@bookernath](https://github.com/bookernath)! - Implement Vercel Runtime Cache API as replacement for Vercel KV adapter ### Patch Changes - [#2526](https://github.com/bigcommerce/catalyst/pull/2526) [`2089a58`](https://github.com/bigcommerce/catalyst/commit/2089a58f6bdaeab68a014ad66422932f392e6c46) Thanks [@chanceaclark](https://github.com/chanceaclark)! - The anonymous session cookie had `secure` always set to true regardless if we were prefixing it or not. This change updates the cookie to set `secure` to the same "value" if we prefix the cookie with `__Secure-`. - [#2564](https://github.com/bigcommerce/catalyst/pull/2564) [`69797a4`](https://github.com/bigcommerce/catalyst/commit/69797a4c9f0bfc8b27b7f144ded5545fdbb5e5cf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add visual queues when the cart state is being updated in the Cart page. Will also warn about pending state when trying to navigate away from page. ## Migration 1. Update `/core/vibes/soul/sections/cart/client.tsx` to include latest changes: - Use `isLineItemActionPending` to track when we need to disable checkout button and add a loading state. - Add skeletons to checkout summary fields that will update when the pending state is complete. - Add side effects to handle when a user `beforeunload` and when user tries to navigate using a link. - Add prop to `lineItemActionPendingLabel` to be able to pass in a translatable label to the window alert. 2. Add label to dictionary of choice. - [#2521](https://github.com/bigcommerce/catalyst/pull/2521) [`6f6a8af`](https://github.com/bigcommerce/catalyst/commit/6f6a8af4fd7a5754b9d08aef75c4e40ab3057318) Thanks [@bookernath](https://github.com/bookernath)! - Preconnect to checkout domain on cart page to improve checkout load time - Updated dependencies [[`707ec24`](https://github.com/bigcommerce/catalyst/commit/707ec24745b6a0040551328d64657ff40df4e252), [`a27054f`](https://github.com/bigcommerce/catalyst/commit/a27054f4f22013707d40a100b15122c22354c956)]: - @bigcommerce/catalyst-client@1.0.1 ## 1.1.0 ### Minor Changes - [#2477](https://github.com/bigcommerce/catalyst/pull/2477) [`02af32c`](https://github.com/bigcommerce/catalyst/commit/02af32c459719f97e8973a19b6889e5fa73d0c38) Thanks [@bookernath](https://github.com/bookernath)! - Add support for Scripts API/Script Manager scripts rendering via next/script ### Patch Changes - [#2465](https://github.com/bigcommerce/catalyst/pull/2465) [`a438bb6`](https://github.com/bigcommerce/catalyst/commit/a438bb660bc3bd11adacd125769ba99ba2e1c38d) Thanks [@bookernath](https://github.com/bookernath)! - Bump next to 15.4.0-canary.114 to fix issue with PDPs 500ing on Docker builds - [#2474](https://github.com/bigcommerce/catalyst/pull/2474) [`989bf97`](https://github.com/bigcommerce/catalyst/commit/989bf974c534a7201782ace9a4bf3fe745e8af01) Thanks [@bookernath](https://github.com/bookernath)! - Respect min/max purchase quantity from API in quantity selector - [#2464](https://github.com/bigcommerce/catalyst/pull/2464) [`474f960`](https://github.com/bigcommerce/catalyst/commit/474f960c4c428e28874022b36ae2b03e0b301e20) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove edge runtime declarations to be able to run Catalyst with OpenNext. - [#2468](https://github.com/bigcommerce/catalyst/pull/2468) [`8b64931`](https://github.com/bigcommerce/catalyst/commit/8b6493156a70490c0c35c35d45ebd9ad8f23615c) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. ## 1.0.1 ### Patch Changes - [#2448](https://github.com/bigcommerce/catalyst/pull/2448) [`e4444a2`](https://github.com/bigcommerce/catalyst/commit/e4444a2ca83b5b73776c842feff56e47f57344dc) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Fixes an issue where the anonymous session wasn't getting cleared after an actual session was established. ## 1.0.0 ### Major Changes - [`6b17bdb`](https://github.com/bigcommerce/catalyst/commit/6b17bdb) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Introduce Soul VIBE UI library to the repository. - Added a collection of reusable primitives with modern styles - Prebuilt sections and page templates that are easy to use - Fast performance and modern patterns leveraging the latest features of Next.js - Easy customization to best represent your brand - Utilize @conform-to/react for progressively enhanced HTML forms Join the discussion [here](https://github.com/bigcommerce/catalyst/discussions/1861) for more details of this major milestone for Catalyst! ### Minor Changes - [`589c91a`](https://github.com/bigcommerce/catalyst/commit/589c91a) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Enable cart restoration on non-persistent cart logouts. **Migration** Update the logout mutation to include the `cartEntityId` variable + the `cartUnassignResult` node and make sure the `client.fetch` method contains the new variable. ```diff -mutation LogoutMutation { +mutation LogoutMutation($cartEntityId: String) { - logout { + logout(cartEntityId: $cartEntityId) { result + cartUnassignResult { + cart { + entityId + } + } } } ``` - [`32a28b9`](https://github.com/bigcommerce/catalyst/commit/32a28b9) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Use isomorphic-dompurify to santize any sort of shopper supplied input. - [`f039b2c`](https://github.com/bigcommerce/catalyst/commit/f039b2c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Properly handle `BigCommerceGQLError` in actions, by returning the error messages from the request. - [`dd66f96`](https://github.com/bigcommerce/catalyst/commit/dd66f96) Thanks [@matthewvolk](https://github.com/matthewvolk)! - In order to maintain parity with Stencil's 404 page, we wanted to allow the user to search from the 404 page. Since the search included with the header component is fully featured, we included a CTA to open the same search that you get when clicking the search icon in the header. **Migration** Most changes are additive, so they should hopefully be easy to resolve if flagged for merge conflicts. Change #3 below replaces the Search state with the new search context, be sure to pay attention to the new 1. This change adds a new directory under `core/` called `context/` containing a `search-context.tsx` file. Since this is a new file, there shouldn't be any merge conflicts 2. `SearchProvider` is imported into `core/app/providers` and replaces the React fragment (`<>`) that currently wraps `` and `{children}` 3. In `core/vibes/soul/primitives/navigation`, replace `useState` with `useSearch` imported from the new context file, and update the dependency arrays for the `useEffect`'s in the `Navigation component` 4. Add search `Button` that calls `setIsSearchOpen(true)` to the `NotFound` component in `core/vibes/sections/not-found/index.tsx` - [`62b891c`](https://github.com/bigcommerce/catalyst/commit/62b891c) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Adds support for nested web page children / trees. Restructure web page routing to support a layout file. - [`44342ee`](https://github.com/bigcommerce/catalyst/commit/44342ee) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Sets a default session when any user first visits the page. - [`ff57b8a`](https://github.com/bigcommerce/catalyst/commit/ff57b8a) Thanks [@eugene(yevhenii)kuzmenko]()! - Pass analytics cookies to checkout mutation to preserve the analytics session whenever shopper redirects to the external checkout - [`067d5a4`](https://github.com/bigcommerce/catalyst/commit/067d5a4) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Move the anonymous session into it's own cookie, separate from Auth.js in order to have better non-persistent cart support. **Migration** If you were using `await signIn('anonymous', { redirect: false });`, you'll need to migrate over to using the `await anonymousSignIn()` function. Otherwise, we am only changing the underlying logic in existing API's so pulling in the changes should immediately pick this up. - [`9b3541d`](https://github.com/bigcommerce/catalyst/commit/9b3541d) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Adds a new analytics provider meant to replace the other provider. This provider is built being framework agnostic but exposes a react provider to use within context. The initial implementation comes with a Google Analytics provider with some basic events to get started. We need to add some other events around starting checkout, banners, consent loading, and search. This change is additive only so no migration is needed until consumption. - [`bd3bc8b`](https://github.com/bigcommerce/catalyst/commit/bd3bc8b) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Implement the new analytics provider, utilizing the GoogleAnalytics provider as the first analytics solution. Most changes are additive so merge conflicts should be easy to resolve. In order to use the new provider from the previous provider, if it's already not setup in the BigCommerce control panel for checkout analytics, you'll need to add the GA4 property ID. This will automatically be used by the new GoogleAnalytics provider. - [`70afa5a`](https://github.com/bigcommerce/catalyst/commit/70afa5a) Thanks [@eugene(yevhenii)kuzmenko]()! - Dispatch Visit started and Product Viewed analytics events - [`da2a462`](https://github.com/bigcommerce/catalyst/commit/da2a462) Thanks [@bookernath](https://github.com/bookernath)! - Add currency selector to header - [`f3b4d90`](https://github.com/bigcommerce/catalyst/commit/f3b4d90) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add Wishlist account pages and public wishlist page - [`59ff1ce`](https://github.com/bigcommerce/catalyst/commit/59ff1ce) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Fetches the stores URLs on build which can remove the need of setting NEXT_PUBLIC_BIGCOMMERCE_CDN_HOSTNAME. The environment variable is still provided in case customization is needed. - [`a0e6425`](https://github.com/bigcommerce/catalyst/commit/a0e6425) Thanks [@eugene(yevhenii)kuzmenko]()! - Adds analytics cookies needed for native analytics. This is a add-only change, so migration should be as simple as pulling in the new code. - [`a601f7e`](https://github.com/bigcommerce/catalyst/commit/a601f7e) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes compare for caching and the eventual use of dynamicIO. **Key modifications include:** - Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. - Use `Streamable.from` to generate our streaming props that are passed to our UI components. **Migration instructions:** - Updated `/app/[locale]/(default)/compare/page.tsx` to use `Streamable.from` pattern. - Renamed `getCompareData` query to `getComparedProducts`. - Updated query - Returns empty `[]` if no product ids are passed - [`c6e38a6`](https://github.com/bigcommerce/catalyst/commit/c6e38a6) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Reorganize and cleanup files: - Moved `core/context/search-context` to `core/lib/search`. - Moved `core/client/mutations/add-cart-line-item.ts` and `core/client/mutations/create-cart.ts` into `core/lib/cart/*`. - Removed `core/client/queries/get-cart.ts` in favor of a smaller, more focused query within `core/lib/cart/validate-cart.ts`. **Migration** - Replace imports from `~/context/search-context` to `~/lib/search`. - Replace imports from `~/client/mutations/` to `~/lib/cart/`. - Remove any direct imports from `~/client/queries/get-cart.ts` and use the new `validate-cart.ts` query instead. If you need the previous `getCart` function, you can copy it from the old file and adapt it to your needs. - [`7b3b81c`](https://github.com/bigcommerce/catalyst/commit/7b3b81c) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Replaces the REST-powered `client.fetchShippingZones` method with a GraphQL-powered query containing the `site.settings.shipping.supportedShippingDestinations` field. **Migration:** 1. The return type of `getShippingCountries` has the same shape as the `Country` BigCommerce GraphQL type, so you should be able to copy the graphql query from `core/app/[locale]/(default)/cart/page-data.ts` into your project and replace the existing `getShippingCountries` method in there. 2. Remove the argument `data.geography` from the `getShippingCountries` invocation in `core/app/[locale]/(default)/cart/page.tsx` 3. Finally, you should be able to delete the file `core/client/management/get-shipping-zones.ts` assuming it is no longer referenced anywhere in `core/` - [`53e0b5e`](https://github.com/bigcommerce/catalyst/commit/53e0b5e) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes category PLP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. **Key modifications include:** - We don't stream in Category page data, instead it's a blocking call that will redirect to `notFound` when category is not found. Same for metadata. - Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. - Use `Streamable.from` to generate our streaming props that are passed to our UI components. - Remove use of nuqs' `createSearchParamsCache` in favor of nuqs' `createLoader`. **Migration instructions:** - Update `/(facted)/category/[slug]/page.tsx` - For this page we are now doing a blocking request for category page data. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. We still stream in filter and product data. - Update `/(facted)/category/[slug]/page-data.tsx` - Request now accept `customerAccessToken` as a prop instead of calling internally. - Update`/(facted)/category/[slug]/fetch-compare-products.ts` - Request now accept `customerAccessToken` as a prop instead of calling internally. - Update `/(faceted)/fetch-faceted-search.ts` - Request now accept `customerAccessToken` and `currencyCode` as a prop instead of calling internally. - [`537db2c`](https://github.com/bigcommerce/catalyst/commit/537db2c) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Add the ability to redirect from the login page. Developers can now append a relative path to the `?redirectTo=` query param on the `/login` page. When a shopper successfully logs in, it'll redirect them to the given relative path. Defaults to `/account/orders` to prevent a breaking change. - [`b20dfb0`](https://github.com/bigcommerce/catalyst/commit/b20dfb0) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Adds an eslint rule to import expect and test from ~/tests/fixtures instead of the @playwright/test module. This is to create a more consistent testing experience across the codebase. **Migration** Any import statements that import `expect` and `test` from `@playwright/test` should be updated to import from `~/tests/fixtures` instead. All other imports from `@playwright/test` should remain unchanged. ```diff -import { expect, type Page, test } from '@playwright/test'; +import { type Page } from '@playwright/test'; + +import { expect, test } from '~/tests/fixtures'; ``` - [`f0464a8`](https://github.com/bigcommerce/catalyst/commit/f0464a8) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Drops CSS support for Safari < 15 due to those versions only having 0.09% global usage. - [`1d6cf64`](https://github.com/bigcommerce/catalyst/commit/1d6cf64) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Render address fields for customer registration form. - [`42ded4a`](https://github.com/bigcommerce/catalyst/commit/42ded4a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes home page, header, and footer for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. **Key modifications include:** - Header and Footer now have a blocking request for the shared data that is the same for all users. - Data that can change for logged in users is now a separate request. - Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. - Dynamic fetches (using customerAccessToken or preferred currency) are now all streaming queries. - Use `Streamable.from` to generate our streaming props that are passed to our UI components. - Update Header UI component to allow streaming in of currencies data. **Migration instructions:** - Renamed `/app/[locale]/(default)/query.ts` to `/app/[locale]/(default)/page-data.ts`, include page query on this page. - Updated `/app/[locale]/(default)/page.ts` to use `Streamable.from` pattern. - Split data that can vary by user from `core/components/footer/fragment.ts` and `core/components/header/fragment.ts` - Updated `core/components/header/index.tsx` and `core/components/footer/index.tsx` to fetch shared data in a blocking request and pass data that varies by customer as streamable data. Updated to use the new `Streamable.from` pattern. - [`061063f`](https://github.com/bigcommerce/catalyst/commit/061063f) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes search PLP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. **Key modifications include:** - We don't stream in Search page data, instead it's a blocking call to get page data. - Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. - Use `Streamable.from` to generate our streaming props that are passed to our UI components. - Remove use of nuqs' `createSearchParamsCache` in favor of nuqs' `createLoader`. **Migration instructions:** - Update `/(facted)/search/page.tsx` - For this page we are now doing a blocking request for brand page data. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. We still stream in filter and product data. - [`da2a462`](https://github.com/bigcommerce/catalyst/commit/da2a462) Thanks [@bookernath](https://github.com/bookernath)! - Adds the ability to redirect after logout. - [`863d744`](https://github.com/bigcommerce/catalyst/commit/863d744) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Removes the old analytics provider in favor of the provider that fetches the configuration from the GraphQL API. - [`061063f`](https://github.com/bigcommerce/catalyst/commit/061063f) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes brand PLP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. **Key modifications include:** - We don't stream in Brand page data, instead it's a blocking call that will redirect to `notFound` when brand is not found. Same for metadata. - Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. - Use `Streamable.from` to generate our streaming props that are passed to our UI components. - Remove use of nuqs' `createSearchParamsCache` in favor of nuqs' `createLoader`. **Migration instructions:** - Update `/(facted)/brand/[slug]/page.tsx` - For this page we are now doing a blocking request for brand page data. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. We still stream in filter and product data. - Update `/(facted)/brand/[slug]/page-data.tsx` - Request now accept `customerAccessToken` as a prop instead of calling internally. ### Patch Changes - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`c73b57e`](https://github.com/bigcommerce/catalyst/commit/c73b57e) Thanks [@migueloller](https://github.com/migueloller)! - Use `setRequestLocale` in all pages and layouts and pass `locale` parameter to `getTranslations` in all `generateMetadata` to maximize static rendering. This is part of the ongoing work in preparation of enabling PPR and `dynamicIO` for all routes. - [`d70596e`](https://github.com/bigcommerce/catalyst/commit/d70596e) Thanks [@alanpledger](https://github.com/alanpledger)! - Fixes types for signIn credentials and improves error handling for registering a customer. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Applied streamable pattern to Cart. - [`54ee390`](https://github.com/bigcommerce/catalyst/commit/54ee390) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove unecessary `fetchOptions` in object that has nothing to do with a client request. - [`ab1f0a0`](https://github.com/bigcommerce/catalyst/commit/ab1f0a0) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add wishlist support to product display pages **Migration** - Ensure WishlistButton component is passed to additionalActions prop on ProductDetail - Ensure WishlistButtonForm is used on product page - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add persistent cart support - [`27b2823`](https://github.com/bigcommerce/catalyst/commit/27b2823) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix issue where delete button is not displayed if you have only 1 address **Migration steps:** Update `/core/app/[locale]/(default)/account/addresses/page.tsx` and pass the `minimumAddressCount={0}` prop to the AddressListSection component. Example: ```tsx return ( ); ``` - [`0779856`](https://github.com/bigcommerce/catalyst/commit/0779856) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Adds Tailwind classes used to style the checkbox input and label based on the disabled state of the checkbox. **Migration:** Since this is a one-file change, you should be able to simply grab the diff from [this PR](https://github.com/bigcommerce/catalyst/pull/2399). The main changes to note are that we are [adding a `peer` class](https://v3.tailwindcss.com/docs/hover-focus-and-other-states#styling-based-on-sibling-state) to the CheckboxPrimitive.Root, explicitly styling the `enabled` pseudoclass, and only applying hover styles when the checkbox is enabled. - [`604450d`](https://github.com/bigcommerce/catalyst/commit/604450d) Thanks [@bookernath](https://github.com/bookernath)! - Re-apply auth grouping approach with middleware exemption to preserve functionality of /login/token endpoint for Customer Login API - [`82290cd`](https://github.com/bigcommerce/catalyst/commit/82290cd) Thanks [@migueloller](https://github.com/migueloller)! - Upgrade `next-intl` to v4 and add strong types for translated messages via TypeScript type augmentation. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Clean up 'en' dictionary. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove unused dependencies. - [`6b0c85a`](https://github.com/bigcommerce/catalyst/commit/6b0c85a) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Remove unused search props, add missing search translations **Migration** `core/components/header/index.tsx` Ensure the following props are passed to the `HeaderSection` navigation prop: ```tsx searchInputPlaceholder: t('Search.inputPlaceholder'), searchSubmitLabel: t('Search.submitLabel'), ``` `core/messages/en.json` Add the following keys to the `Components.Header.Search` translations: ```json "somethingWentWrong": "Something went wrong. Please try again.", "inputPlaceholder": "Search products, categories, brands...", "submitLabel": "Search" ``` `core/vibes/soul/primitives/navigation/index.tsx` Copy all changes from this file: 1. Create `searchSubmitLabel?: string;` property, ensure it is passed into `SearchForm` 2. On the `SearchForm`, remove the `searchCtaLabel = 'View more',` property, as it is unused, and rename `submitLabel` to `searchSubmitLabel` 3. Ensure that `SearchForm` passes `searchSubmitLabel` to the `SearchButton`: `` 4. Remove the `searchCtaLabel` property from the `SearchResults` component - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Format totalCount value for i18n. - [`dd42b25`](https://github.com/bigcommerce/catalyst/commit/dd42b25) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Fix the faceted search pages to account for facets with spaces or other special characters in the name. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add date field to product details form. - [`d9685ee`](https://github.com/bigcommerce/catalyst/commit/d9685ee) Thanks [@bookernath](https://github.com/bookernath)! - Remove featured products panel from 404 page, allowing the page to be static in preparation for adding a search box - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`8baf8b3`](https://github.com/bigcommerce/catalyst/commit/8baf8b3) Thanks [@matthewvolk](https://github.com/matthewvolk)! - Memoize `GetCartCountQuery` using React.js `cache()` so that it only hits the GraphQL API once per render, instead of twice. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add shipping selection to checkout. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`6401bb2`](https://github.com/bigcommerce/catalyst/commit/6401bb2) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update ProductListSection's and ReviewsSection's `totalCount` prop to string. - [`43351ab`](https://github.com/bigcommerce/catalyst/commit/43351ab) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Dedupe requests in various pages by properly caching/memoizing the function per page render. - [`b19ee74`](https://github.com/bigcommerce/catalyst/commit/b19ee74) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Updates `SelectField` to have a hidden input to pass the value of the select to the form. This is a workaround for a [Radix Select issue](https://github.com/radix-ui/primitives/issues/3198) that auto selects the first option in the select when submitting a form (even when no selection has been made). Additionally, fixes an issue of incorrectly adding an empty query param for product options when an option is empty. **Migration** Migration is straighforward and requires adding the hidden input to the component and renaming the `name` prop for the `Select` component to something temporary. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`d663741`](https://github.com/bigcommerce/catalyst/commit/d663741) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Revert UI changes for product form since streaming in fields causes an issue with the form. - [`7bc57c8`](https://github.com/bigcommerce/catalyst/commit/7bc57c8) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Set a min-height for the Navigation fallback skeleton to prevent layout shift. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`c70bff2`](https://github.com/bigcommerce/catalyst/commit/c70bff2) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Dedupe requests in "webpages" by properly caching/memoizing the fetch function per page render. - [`5a853c2`](https://github.com/bigcommerce/catalyst/commit/5a853c2) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Check for `error.type` instead of `error.name` auth error in Login, since `error.name` gets minified in production and the check never returns `true`. Additionally, add a check for the `cause.err` to be of type `BigcommerceGQLError`. **Migration:** - Change `error.name === 'CallbackRouteError'` to `error.type === 'CallbackRouteError'` check in the error handling of the login action and include `error.cause.err instanceof BigCommerceGQLError`. - [`fada842`](https://github.com/bigcommerce/catalyst/commit/fada842) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Adds the `__Secure-` prefix to the add additional broswer security policies around this cookie. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`976c74d`](https://github.com/bigcommerce/catalyst/commit/976c74d) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix blog post card date formatting on alternate locales **Migration** `core/vibes/soul/primitives/blog-post-card/index.tsx` Update the component to use `` for the date, instead of calling `new Date(date).toLocaleDateString(...)`. - [`9176f56`](https://github.com/bigcommerce/catalyst/commit/9176f56) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix possibility of duplicate `key` error in Breadcrumbs component for truncated breadcrumbs. **Migration** Update `core/vibes/soul/sections/breadcrumbs/index.tsx` to use `index` as the `key` property instead of `href` - [`9827e4c`](https://github.com/bigcommerce/catalyst/commit/9827e4c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Translate home breadcrumb in Contact Us page. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`48d5c99`](https://github.com/bigcommerce/catalyst/commit/48d5c99) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix public wishlist analytics/server error - Add translation key for a Publish Wishlist empty state **Migration** 1. Add the following imports to `core/app/[locale]/(default)/wishlist/[token]/page.tsx`: ```tsx import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { WishlistAnalyticsProvider } from '~/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider'; ``` 2. Add the following function into the file: ```tsx const getAnalyticsData = async (token: string, searchParamsPromise: Promise) => { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); const wishlist = await getPublicWishlist(token, searchParamsParsed); if (!wishlist) { return []; } return removeEdgesAndNodes(wishlist.items) .map(({ product }) => product) .filter((product) => product !== null) .map((product) => { return { id: product.entityId, name: product.name, sku: product.sku, brand: product.brand?.name ?? '', price: product.prices?.price.value ?? 0, currency: product.prices?.price.currencyCode ?? '', }; }); }; ``` 3. Wrap the component in the `WishlistAnalyticsProvider`: ```tsx export default async function PublicWishlist({ params, searchParams }: Props) { // ... return ( getAnalyticsData(token, searchParams))}> // ... ); } ``` 4. Update `/core/messages/en.json` "PublishWishlist" to have translations: ```json "PublicWishlist": { "title": "Public Wish List", "defaultName": "Public wish list", "emptyWishlist": "This wish list doesn't have any products yet." }, ``` 5. Update `WishlistDetails` component to accept the `emptyStateText` and `placeholderCount` props: ```tsx // ... export const WishlistDetails = ({ className = '', wishlist: streamableWishlist, emptyStateText, paginationInfo, headerActions, prevHref, placeholderCount, action, removeAction, }: Props) => { ``` 6. Update `WishlistDetails` component to pass the `emptyStateText` and `placeholderCount` props to both the `WishlistDetailSkeleton` and `WishlistItems` components: ```tsx ``` ```tsx ``` - [`1147a9e`](https://github.com/bigcommerce/catalyst/commit/1147a9e) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Deduplicate default image in the image gallery in PDP. - [`47b3ad0`](https://github.com/bigcommerce/catalyst/commit/47b3ad0) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fix an issue with orders with deleted products throwing an error and stopping page render by settings the errorPolicy for requests to ignore errors and update Soul components to render the products without using links for these cases. - [`589c91a`](https://github.com/bigcommerce/catalyst/commit/589c91a) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Remove cache from a customer-specific wishlist query. - [`aecc145`](https://github.com/bigcommerce/catalyst/commit/aecc145) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: localized home page routes are rewritten to the "catch all" page - [`3015503`](https://github.com/bigcommerce/catalyst/commit/3015503) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Fix style override issues with the latest version of the Tailwind bump. Changes should be easily rebasable. - [`a7b369c`](https://github.com/bigcommerce/catalyst/commit/a7b369c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fixes the error warning by having a `ProductPickList` with no images, by making the `image` prop optional for when it is not needed. **Migration** - Update `schema.ts` to allow optional `image` prop for `CardRadioField` - Update `productOptionsTransformer` switch to have two cases for `ProductPickList` - `ProductPickList` with no image object - `ProductPickListWithImages` with image object - Update ui component to make the `image` prop optional and conditionally render the image. - [`f16a6be`](https://github.com/bigcommerce/catalyst/commit/f16a6be) Thanks [@migueloller](https://github.com/migueloller)! - Adds `Streamable.from` and uses it wherever we were unintentionally executing an async function in a React Server Component. - [`43351ab`](https://github.com/bigcommerce/catalyst/commit/43351ab) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Pass in currency code to quick search results. - [`17d72ca`](https://github.com/bigcommerce/catalyst/commit/17d72ca) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Add the `store_hash` `` element to better support merchants. This enabled BigCommerce to identify the store more easily. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`7071dfe`](https://github.com/bigcommerce/catalyst/commit/7071dfe) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add locale prefix to auth middleware protected route URLPattern **Migration** In `core/middlewares/with-auth.ts`, update the `protectedPathPattern` variable to include an optional path segment for the locale: ```tsx const protectedPathPattern = new URLPattern({ pathname: `{/:locale}?/(account)/*` }); ``` - [`67715bf`](https://github.com/bigcommerce/catalyst/commit/67715bf) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Update GQL client and auth middleware to handle invalid tokens and invalidate session. **Summary** This will ensure that if a user is logged out elsewhere, they will be redirected to the /login page when they try to access a protected route. Previously, the pages would 404 which is misleading. **Migration** 1. Copy all changes from the `/core/client` directory and the `/packages/client` directory 2. Copy translation values 3. Copy all changes from the `/core/app/[locale]/(default)/account/` directory server actions 4. Copy all changes from the `/core/app/[locale]/(default)/checkout/route.ts` file - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`6c77e57`](https://github.com/bigcommerce/catalyst/commit/6c77e57) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor optimizes PDP for caching and the eventual use of dynamicIO. With these changes we leverage data caching to hit mostly cache data for guest shoppers in different locales and with different currencies. **Key modifications include:** - Split queries into four: - Page Metadata (metadata fields that only depend on locale) - Product (for fields that only depend on locale) - Streamable Product (for fields that depend on locale and variant selection) - Product Pricing and Related Products (for fields that require locale, variant selection, and currency -- in this case, pricing and related products) - We don't stream in Product data, instead it's a blocking call that will redirect to `notFound` when product is not found. - Our query functions now take in all params required for fetching, instead of accessing dynamic variables internally. This is important to serialize arguments if we want to eventually `use cache`. - Use `Streamable.from` to generate our streaming props that are passed to our UI components. - Update UI components to allow streaming product options before streaming in buy button. **Migration instructions:** - Update `/product/[slug]/page.tsx` - For this page we are now doing a blocking request that is simplified for metadata and as a base product. Instead of having functions that each would read from props, we share streamable functions that can be passed to our UI components. - Update `/product/[slug]/page-data.tsx` - Expect our requests to be simplified/merged, essentially replacing what we had before for new requests and functions. - Update`/product/[slug]/_components`. - Similar to `page.tsx` and `page.data`, expect changes in the fragments defined and how we pass streamable functions to UI components. - Update `/vibes/soul/product-detail/index.tsx` & `/vibes/soul/product-detail/product-detail-form.tsx` - Minor changes to allow streaming in data. - [`8a25424`](https://github.com/bigcommerce/catalyst/commit/8a25424) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Refactors the sign in functionality to use two separate providers instead of one. This is some work needed to be done in order to provide a better API for session syncing so it shouldn't effect any existing functionality. - [`e968366`](https://github.com/bigcommerce/catalyst/commit/e968366) Thanks [@alekseygurtovoy](https://github.com/alekseygurtovoy)! - fix: `useCompareDrawer` does not throw on missing context - [`a19b3ba`](https://github.com/bigcommerce/catalyst/commit/a19b3ba) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Fix persistent cart behavior during login. **Migration** In `core/auth/index.ts`, create the `cartIdSchema` variable: ```ts const cartIdSchema = z .string() .uuid() .or(z.literal('undefined')) // auth.js seems to pass the cart id as a string literal 'undefined' when not set. .optional() .transform((val) => (val === 'undefined' ? undefined : val)); ``` Then, update all `Credentials` schemas to use this new `cartIdSchema`: ```ts const PasswordCredentials = z.object({ email: z.string().email(), password: z.string().min(1), cartId: cartIdSchema, }); const AnonymousCredentials = z.object({ cartId: cartIdSchema, }); const JwtCredentials = z.object({ jwt: z.string(), cartId: cartIdSchema, }); const SessionUpdate = z.object({ user: z.object({ cartId: cartIdSchema, }), }); ``` - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add discounts summary item to Cart. - [`2de3c51`](https://github.com/bigcommerce/catalyst/commit/2de3c51) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fixes an issue with the checkbox not properly triggering the required validation. - Fixes an issue with the checkbox not setting the default value from the API. - Fixes an issue with the field value being incorrectly set as `undefined` **Migration** Update the props to set a `checked` value and pasa an empty string when checked box is unselected. ``` case 'checkbox': return ( handleChange(value ? 'true' : '')} onFocus={controls.focus} required={formField.required} value={controls.value ?? ''} /> ); ``` - [`c5ce9dc`](https://github.com/bigcommerce/catalyst/commit/c5ce9dc) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Properly handle the auth error when login is invalid. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`2a7b05f`](https://github.com/bigcommerce/catalyst/commit/2a7b05f) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add translations for 'Search' button on 404 page **Migration** 1. Add `"search"` translation key in the `"NotFound"` translations 2. In `core/vibes/soul/sections/not-found/index.tsx`, add a `ctaLabel` property and ensure it is used in place of the "Search" text 3. In `core/app/[locale]/not-found.tsx`, pass the `ctaLabel` prop as the new translation key `ctaLabel={t('search')}` - [`c095663`](https://github.com/bigcommerce/catalyst/commit/c095663) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Moves some auth related route handlers under the (auth) route group. This is to cleanup some of the routing. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add result type to all `generateMetadata`. - [`a15d84c`](https://github.com/bigcommerce/catalyst/commit/a15d84c) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Renames `core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider/index.tsx` to `core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider.tsx` for consistency with the other analytics components. **Migration** To migrate, rename the file with git: ```bash git mv core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider/index.tsx core/app/[locale]/(default)/product/[slug]/_components/product-analytics-provider.tsx ``` - [`5e5314b`](https://github.com/bigcommerce/catalyst/commit/5e5314b) Thanks [@jorgemoya](https://github.com/jorgemoya)! - We want state to be persitent on the `ProductDetailForm`, even after submit. This change will allow the API error messages to properly show when the form is submitted. Additionally, other form fields will retain state (like item quantity). **Migration** - Update `ProductDetailForm` to prevent reset on submit, by removing `requestFormReset` in the `onSubmit`. - Remove `router.refresh()` call and instead call new `revalidateCart` action. - `revalidateCart` is an action that `revalidateTag(TAGS.cart)` - This prevents the form from fully refreshing on success. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`8c4f374`](https://github.com/bigcommerce/catalyst/commit/8c4f374) Thanks [@jordanarldt](https://github.com/jordanarldt)! - - Redirect to `/account/wishlists/` when a wishlist ID is not found - Pass `actionsTitle` to WishlistActionsMenu on WishlistDetails page **Migration** 1. Copy changes from `/core/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-actions.tsx` - Ensure that `actionsTitle` is an allowed property and that it is passed into the `WishlistActionsMenu` component 2. Copy changes from `/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx` - Redirect to `/account/wishlists/` on 404 3. Ensure that the `removeButtonTitle` prop is passed down all the way to the `RemoveWishlistItemButton` component in the `WishlistItemCard` component - [`45bbd92`](https://github.com/bigcommerce/catalyst/commit/45bbd92) Thanks [@jordanarldt](https://github.com/jordanarldt)! - - Update the account pages to match the style of VIBES and remain consistent with the rest of Catalyst. - Updated OrderDetails line items styling to display cost of each item and the selected `productOptions` - Created OrderDetails skeletons - Updated /account/orders/[id] to use `Streamable` **Migration** 1. Copy all changes in the `/core/vibes/soul` directory 2. Copy all changes in the `/core/app/[locale]/(default)/account` directory - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add coupon code form to Cart page. - [`e8c693a`](https://github.com/bigcommerce/catalyst/commit/e8c693a) Thanks [@jordanarldt](https://github.com/jordanarldt)! - Add toast message when changing password **Migration** `core/vibes/soul/sections/account-settings/change-password-form.tsx` 1. Import `toast`: ```ts import { toast } from '@/vibes/soul/primitives/toaster'; ``` 2. Update the `ChangePasswordAction` types: ```ts type Action = (state: Awaited, payload: P) => S | Promise; interface State { lastResult: SubmissionResult | null; successMessage?: string; } export type ChangePasswordAction = Action; ``` 3. Update the `useActionState` hook: ```ts const [state, formAction] = useActionState(action, { lastResult: null }); ``` 4. Update the `useEffect` hook to display a toast message on success: ```ts useEffect(() => { if (state.lastResult?.status === 'success' && state.successMessage != null) { toast.success(state.successMessage); } if (state.lastResult?.error) { // eslint-disable-next-line no-console console.log(state.lastResult.error); } }, [state]); ``` `core/app/[locale]/(default)/account/settings/_actions/change-password.ts` Update all of the `return` values to match the new `ChangePasswordAction` interface, and return the `passwordUpdated` message on success. ```ts export const changePassword: ChangePasswordAction = async (prevState, formData) => { const t = await getTranslations('Account.Settings'); const customerAccessToken = await getSessionCustomerAccessToken(); const submission = parseWithZod(formData, { schema: changePasswordSchema }); if (submission.status !== 'success') { return { lastResult: submission.reply() }; } const input = { currentPassword: submission.value.currentPassword, newPassword: submission.value.password, }; try { const response = await client.fetch({ document: CustomerChangePasswordMutation, variables: { input, }, customerAccessToken, }); const result = response.data.customer.changePassword; if (result.errors.length > 0) { return { lastResult: submission.reply({ formErrors: result.errors.map((error) => error.message) }), }; } return { lastResult: submission.reply(), successMessage: t('passwordUpdated'), }; } catch (error) { // eslint-disable-next-line no-console console.error(error); if (error instanceof BigCommerceGQLError) { return { lastResult: submission.reply({ formErrors: error.errors.map(({ message }) => message), }), }; } if (error instanceof Error) { return { lastResult: submission.reply({ formErrors: [error.message] }), }; } return { lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }) }; } }; ``` - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Disable prefetch for the `/logout` link. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add textarea field to product details form. - [`525afdb`](https://github.com/bigcommerce/catalyst/commit/525afdb) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update empty state for account pages, adjusting headers and empty designs. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Set currency on cart at creation time - [`e145673`](https://github.com/bigcommerce/catalyst/commit/e145673) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Allow a list of CDN hostnames for cases when there can be more than one CDN available for image loader. **Migration:** - Update `build-config` schema to make `cdnUrls` an array of strings. - Update `next.config.ts` to set `cdnUrls` as an array, and set multiple preconnected Link headers (one per CDN). - `shouldUseLoaderProp` function now reads from array. - [`6b99400`](https://github.com/bigcommerce/catalyst/commit/6b99400) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Split coupon discounts and regular discounts from summary items, use total `cart.discountedAmount` for discounts. - [`0900330`](https://github.com/bigcommerce/catalyst/commit/0900330) Thanks [@chancellorclark](https://github.com/chancellorclark)! - Refactors redirecting to checkout as a route. This will enable session syncing to happen through a redirect using the sites and routes API. - [`11ecddf`](https://github.com/bigcommerce/catalyst/commit/11ecddf) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update translations. - [`7668774`](https://github.com/bigcommerce/catalyst/commit/7668774) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Disable PPR in Compare page due to an issue of Next.js and PPR, which causes the products to be removed once one is added to cart. More info: https://github.com/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 ## 0.24.1 ### Patch Changes - [`632a645`](https://github.com/bigcommerce/catalyst/commit/632a645850c500be9ea478490e1df4b98d9b3543) Thanks [@bookernath](https://github.com/bookernath)! - Add stub for generating Customer Login API tokens for SSO integrations - [`632a645`](https://github.com/bigcommerce/catalyst/commit/632a645850c500be9ea478490e1df4b98d9b3543) Thanks [@bookernath](https://github.com/bookernath)! - Add /login/token endpoint to power Customer Login API - [#1816](https://github.com/bigcommerce/catalyst/pull/1816) [`6eb30ac`](https://github.com/bigcommerce/catalyst/commit/6eb30ac1745e2dcc37aef892fb001f218d9b8ddb) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. ## 0.24.0 ### Minor Changes - [#1749](https://github.com/bigcommerce/catalyst/pull/1749) [`cacdd22`](https://github.com/bigcommerce/catalyst/commit/cacdd22de140897f57fb8aaf52b2a9e7f48c23c4) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Change the rest of the auth pages to use toasts. - [#1746](https://github.com/bigcommerce/catalyst/pull/1746) [`0e34915`](https://github.com/bigcommerce/catalyst/commit/0e34915171da18ed141ecfacc6fa4c2a8f5e4c23) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Converts the change password messages over to using a toast. This should provide a better DX and UX. - [#1747](https://github.com/bigcommerce/catalyst/pull/1747) [`608b886`](https://github.com/bigcommerce/catalyst/commit/608b886978518f3d27230f50a2ad462363527d63) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Update the register customer page to use toasts for messaging. - [#1749](https://github.com/bigcommerce/catalyst/pull/1749) [`cacdd22`](https://github.com/bigcommerce/catalyst/commit/cacdd22de140897f57fb8aaf52b2a9e7f48c23c4) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Converts the reset password messages over to using a toast. - [#1749](https://github.com/bigcommerce/catalyst/pull/1749) [`cacdd22`](https://github.com/bigcommerce/catalyst/commit/cacdd22de140897f57fb8aaf52b2a9e7f48c23c4) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Remove the account state provider components - [#1749](https://github.com/bigcommerce/catalyst/pull/1749) [`cacdd22`](https://github.com/bigcommerce/catalyst/commit/cacdd22de140897f57fb8aaf52b2a9e7f48c23c4) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Converts the login messages over to using a toast. - [#1743](https://github.com/bigcommerce/catalyst/pull/1743) [`7c03428`](https://github.com/bigcommerce/catalyst/commit/7c03428bf815bf2cc7b8aa35ff331379f7615094) Thanks [@chanceaclark](https://github.com/chanceaclark)! - After login, redirect to orders page instead of an account overview page. This also removes the account overview page. - [#1741](https://github.com/bigcommerce/catalyst/pull/1741) [`5136fac`](https://github.com/bigcommerce/catalyst/commit/5136fac6e05c6eb1ebce9707abcf1f180712358e) Thanks [@chanceaclark](https://github.com/chanceaclark)! - If a customer is already logged in, we want to redirect them back to their account pages if they are trying to hit one of the non-logged-in customer auth routes. The prevents any side effects that may occur trying to re-auth the client. This is done by providing a root layout.tsx page under the (auth) route group. - [#1749](https://github.com/bigcommerce/catalyst/pull/1749) [`cacdd22`](https://github.com/bigcommerce/catalyst/commit/cacdd22de140897f57fb8aaf52b2a9e7f48c23c4) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Converts the change/forgot password messages over to using a toast. ### Patch Changes - [#1765](https://github.com/bigcommerce/catalyst/pull/1765) [`1c9b880`](https://github.com/bigcommerce/catalyst/commit/1c9b8804cec99f5fd9700b422a3fb9739a850045) Thanks [@bookernath](https://github.com/bookernath)! - Assign cart to customer as part of initial login mutation - [#1760](https://github.com/bigcommerce/catalyst/pull/1760) [`f6161c5`](https://github.com/bigcommerce/catalyst/commit/f6161c5dcf2fbd65f4192eec36ebd3e62e60bd33) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. ## 0.23.0 ### Minor Changes - [#1639](https://github.com/bigcommerce/catalyst/pull/1639) [`ae2c6cd`](https://github.com/bigcommerce/catalyst/commit/ae2c6cd76b2ccc5c994bd298983cb1665c571d02) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - Add orders for customer account. Now customer can open orders history or move to specific order details. - [#1729](https://github.com/bigcommerce/catalyst/pull/1729) [`d52affe`](https://github.com/bigcommerce/catalyst/commit/d52affe56dee23a81263392030fe635c824fb182) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Removed ReCaptcha validation when you are logged in and making account changes. We have already validated a customer is human at the loggin screen. - [#1728](https://github.com/bigcommerce/catalyst/pull/1728) [`d7dbd7a`](https://github.com/bigcommerce/catalyst/commit/d7dbd7a04fc8cb87cf223fb5a17af8d59c6431ea) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Convert the messages that were displayed when deleting an address over to using the toast functionality. ### Patch Changes - [#1727](https://github.com/bigcommerce/catalyst/pull/1727) [`d3c6dbc`](https://github.com/bigcommerce/catalyst/commit/d3c6dbc25c16901f694e053ccdee8193647f5760) Thanks [@migueloller](https://github.com/migueloller)! - Ignore empty strings when parsing array URL search parameters in faceted search. - [#1730](https://github.com/bigcommerce/catalyst/pull/1730) [`ad8c86d`](https://github.com/bigcommerce/catalyst/commit/ad8c86d574474eb5ed18d99265fe4001d267fb5f) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Fixes the inventory handling to handle some options being out of stock. ## 0.22.1 ### Patch Changes - [#1649](https://github.com/bigcommerce/catalyst/pull/1649) [`d38f164`](https://github.com/bigcommerce/catalyst/commit/d38f164d3e87ca87d3e792f8058a74c1f13e4220) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - improve account forms submit errors message - [#1651](https://github.com/bigcommerce/catalyst/pull/1651) [`1a222cb`](https://github.com/bigcommerce/catalyst/commit/1a222cb09dfc65b440090f868b01291e644bec4a) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - refresh the entire list of addresses after deleting an address - [#1722](https://github.com/bigcommerce/catalyst/pull/1722) [`1f0c2ef`](https://github.com/bigcommerce/catalyst/commit/1f0c2ef9212be079630f64a15a2f121ed7a358f9) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Remove `--turbo` from `pnpm dev` as it has some issues with the latest dependency bump, along with others. ## 0.22.0 ### Minor Changes - [#1717](https://github.com/bigcommerce/catalyst/pull/1717) [`12fea79`](https://github.com/bigcommerce/catalyst/commit/12fea7962c25c395b550717343300561fb8d6a4c) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Add a check for variant stock levels on add to cart button - [#1674](https://github.com/bigcommerce/catalyst/pull/1674) [`512c338`](https://github.com/bigcommerce/catalyst/commit/512c338e4abcb3cdb7f457e4012e0c90c6a8391a) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Uses the API responses to show better errors when adding a product to the cart. - [#1710](https://github.com/bigcommerce/catalyst/pull/1710) [`15edf31`](https://github.com/bigcommerce/catalyst/commit/15edf311f5508a85f09acd8135fbf2b4aae09ff0) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Rename `BcImage` to `Image` - [#1703](https://github.com/bigcommerce/catalyst/pull/1703) [`7b598ff`](https://github.com/bigcommerce/catalyst/commit/7b598ff012ce40fe4b34be780c01cdbbe61e9b7e) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Adds localized data fetching withing the beforeRequest client helper. If information is translated (currently possible to update via the Admin GraphQL API) then we will return the translated product data. See https://developer.bigcommerce.com/docs/store-operations/catalog/graphql-admin/product-basic-info for more information on how to use overrides. - [#1710](https://github.com/bigcommerce/catalyst/pull/1710) [`15edf31`](https://github.com/bigcommerce/catalyst/commit/15edf311f5508a85f09acd8135fbf2b4aae09ff0) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Force usage of the `` component. This component should fallback to using the default image loader if the url doesn't come from the BigCommerce CDN. - [#1672](https://github.com/bigcommerce/catalyst/pull/1672) [`ffefc61`](https://github.com/bigcommerce/catalyst/commit/ffefc6151b0fb09bf83e7556736452a3138ef9c4) Thanks [@chanceaclark](https://github.com/chanceaclark)! - If a string is not provided in the selected locale, the translation system will fallback to "en" for that specific entry. ### Patch Changes - [#1661](https://github.com/bigcommerce/catalyst/pull/1661) [`93d9984`](https://github.com/bigcommerce/catalyst/commit/93d99844ed4957a5a4611970589a2246b1dffb16) Thanks [@bookernath](https://github.com/bookernath)! - Remove webpack chunk plugin - [#1688](https://github.com/bigcommerce/catalyst/pull/1688) [`3267840`](https://github.com/bigcommerce/catalyst/commit/3267840981ebb6ed62e0b87f60623d0c4352309d) Thanks [@thebigrick](https://github.com/thebigrick)! - Added aria label for compare button - [#1617](https://github.com/bigcommerce/catalyst/pull/1617) [`c852961`](https://github.com/bigcommerce/catalyst/commit/c852961063fb090907b23074301fcbc41e75b8ec) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - UX improvements for account pages - [#1690](https://github.com/bigcommerce/catalyst/pull/1690) [`ee6bbb9`](https://github.com/bigcommerce/catalyst/commit/ee6bbb96e9c357af249fb881f5de503f9e164fb1) Thanks [@thebigrick](https://github.com/thebigrick)! - Added localization to hardcoded strings - [#1647](https://github.com/bigcommerce/catalyst/pull/1647) [`ad5ed3f`](https://github.com/bigcommerce/catalyst/commit/ad5ed3f50f6d3025bf299cc04f51bf0864afd3a2) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - update submit create account errors message - [#1715](https://github.com/bigcommerce/catalyst/pull/1715) [`2960a70`](https://github.com/bigcommerce/catalyst/commit/2960a708084030b484de945e725b5bd0c32462ee) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#1694](https://github.com/bigcommerce/catalyst/pull/1694) [`07f8463`](https://github.com/bigcommerce/catalyst/commit/07f84634000c4d1dac6f89037d9501bc056537c9) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. ## 0.21.0 ### Minor Changes - [#1631](https://github.com/bigcommerce/catalyst/pull/1631) [`58d9e7c`](https://github.com/bigcommerce/catalyst/commit/58d9e7ccb7915593cd012cce6d9f4bdf66cb381f) Thanks [@deini](https://github.com/deini)! - fetch available locales at build time ### Patch Changes - [#1636](https://github.com/bigcommerce/catalyst/pull/1636) [`23abacf`](https://github.com/bigcommerce/catalyst/commit/23abacfb8ff4ff9d269e51821a6a992a9cb2d4f5) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Remove console.error when falling back to defaultChannelId - [#1636](https://github.com/bigcommerce/catalyst/pull/1636) [`23abacf`](https://github.com/bigcommerce/catalyst/commit/23abacfb8ff4ff9d269e51821a6a992a9cb2d4f5) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Clean up login error handling. - Updated dependencies [[`23abacf`](https://github.com/bigcommerce/catalyst/commit/23abacfb8ff4ff9d269e51821a6a992a9cb2d4f5)]: - @bigcommerce/catalyst-client@0.14.0 ## 0.20.0 ### Minor Changes - [#1623](https://github.com/bigcommerce/catalyst/pull/1623) [`16e3a76`](https://github.com/bigcommerce/catalyst/commit/16e3a763571324dccd9031a79e400409eff9ee0c) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Next 15 upgrade ### Patch Changes - [#1629](https://github.com/bigcommerce/catalyst/pull/1629) [`72a30a8`](https://github.com/bigcommerce/catalyst/commit/72a30a84193f7ed8a09b770d16dd2c9a8a7d1347) Thanks [@deini](https://github.com/deini)! - Use Typescript on Next Config - [#1618](https://github.com/bigcommerce/catalyst/pull/1618) [`d60e916`](https://github.com/bigcommerce/catalyst/commit/d60e916661385fab211f7e8b1342dbda2fd504b9) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - Updated dependencies [[`16e3a76`](https://github.com/bigcommerce/catalyst/commit/16e3a763571324dccd9031a79e400409eff9ee0c)]: - @bigcommerce/catalyst-client@0.13.0 ## 0.19.0 ### Minor Changes - [#1262](https://github.com/bigcommerce/catalyst/pull/1262) [`0c2023b`](https://github.com/bigcommerce/catalyst/commit/0c2023bae650039cd79ba51b1161b5c8c16f0b8d) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Removes all usages of the customer impersonation token. Also updates the docs to correspond with the Storefront API Token. - [#1262](https://github.com/bigcommerce/catalyst/pull/1262) [`0c2023b`](https://github.com/bigcommerce/catalyst/commit/0c2023bae650039cd79ba51b1161b5c8c16f0b8d) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Allows the ability to consume a [storefront token](https://developer.bigcommerce.com/docs/rest-authentication/tokens#storefront-tokens). This new token will allow Catalyst to create `customerAccessToken`'s whenever a user logs into their account. This change doesn't include consuming the either token, only adding the ability to pass it in. ### Patch Changes - Updated dependencies [[`0c2023b`](https://github.com/bigcommerce/catalyst/commit/0c2023bae650039cd79ba51b1161b5c8c16f0b8d), [`0c2023b`](https://github.com/bigcommerce/catalyst/commit/0c2023bae650039cd79ba51b1161b5c8c16f0b8d)]: - @bigcommerce/catalyst-client@0.12.0 ## 0.18.1 ### Patch Changes - [#1525](https://github.com/bigcommerce/catalyst/pull/1525) [`e751319`](https://github.com/bigcommerce/catalyst/commit/e751319728359a2e72d48072a4b68055ed4dbb1e) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - fix warning for using the same keys on items - [#1521](https://github.com/bigcommerce/catalyst/pull/1521) [`fd83a78`](https://github.com/bigcommerce/catalyst/commit/fd83a78f94b170dcf6e8aed14c61e3791b64c5de) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - fix styles for active account tab - [#1520](https://github.com/bigcommerce/catalyst/pull/1520) [`c898792`](https://github.com/bigcommerce/catalyst/commit/c898792a0ed3ee9849cdfeda7018245e491e8016) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - improve error message on reset password page - [#1524](https://github.com/bigcommerce/catalyst/pull/1524) [`f08883c`](https://github.com/bigcommerce/catalyst/commit/f08883c8fa559f0b6015321e2396606d77fa0ad6) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - improve behaviour for change password page for logged in user - [#1529](https://github.com/bigcommerce/catalyst/pull/1529) [`22426b2`](https://github.com/bigcommerce/catalyst/commit/22426b256e29b6c3dd145fd6df9ed57c5a99bd75) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - fix validation message for email on account settings page - [#1516](https://github.com/bigcommerce/catalyst/pull/1516) [`41270c2`](https://github.com/bigcommerce/catalyst/commit/41270c29a6e21217622c29b18e91f9a24d58ea8b) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#1534](https://github.com/bigcommerce/catalyst/pull/1534) [`de48618`](https://github.com/bigcommerce/catalyst/commit/de486186acfec2604d749b9f6d2b4656a9e9280a) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. ## 0.18.0 ### Minor Changes - [#1491](https://github.com/bigcommerce/catalyst/pull/1491) [`313a591`](https://github.com/bigcommerce/catalyst/commit/313a5913181a144b53cb12208132f4a9924e2256) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Bump `next-intl` which includes [some minor changes and updated APIs](<(https://next-intl-docs.vercel.app/blog/next-intl-3-22)>): - Use new `createNavigation` api. - Pass `locale` to redirects. - `setRequestLocale` is no longer unstable. ### Patch Changes - [#1505](https://github.com/bigcommerce/catalyst/pull/1505) [`691ec2b`](https://github.com/bigcommerce/catalyst/commit/691ec2bcbb8839446463e292856080cc9b16c584) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - update login page & error message styles - [#1506](https://github.com/bigcommerce/catalyst/pull/1506) [`ac83d3e`](https://github.com/bigcommerce/catalyst/commit/ac83d3eb98e19307a3a82fa94c222cff3c0806f0) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - remove unnecessary fields from account settings form and update confirmation message - [#1499](https://github.com/bigcommerce/catalyst/pull/1499) [`b5aea9b`](https://github.com/bigcommerce/catalyst/commit/b5aea9b36159d11a77d090fee62cb1736bc794be) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Bumps next-intl to fix issue with hashes and query params in urls. - [#1511](https://github.com/bigcommerce/catalyst/pull/1511) [`370d0b1`](https://github.com/bigcommerce/catalyst/commit/370d0b18f0f47100d7e520fcf9f209f6e41f34e9) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - update styles for reset password validation - [#1454](https://github.com/bigcommerce/catalyst/pull/1454) [`53599e6`](https://github.com/bigcommerce/catalyst/commit/53599e6e02988ab63d158c5c9f587669a5581402) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - remove unnecessary fields from create account form - [#1487](https://github.com/bigcommerce/catalyst/pull/1487) [`a22233f`](https://github.com/bigcommerce/catalyst/commit/a22233f8fc94c5ad602fa734cadbb892af34fe6b) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. ## 0.17.1 ### Patch Changes - Updated dependencies [[`d4120d3`](https://github.com/bigcommerce/catalyst/commit/d4120d39c10398e842a7ebe14ada685ec8aae3a8)]: - @bigcommerce/catalyst-client@0.11.0 ## 0.17.0 ### Minor Changes - [#1401](https://github.com/bigcommerce/catalyst/pull/1401) [`3095002`](https://github.com/bigcommerce/catalyst/commit/3095002d7a10b9c4058016076deb7a45fc8ae7bb) Thanks [@bookernath](https://github.com/bookernath)! - Add dynamic robots.txt from control panel settings ### Patch Changes - [#1477](https://github.com/bigcommerce/catalyst/pull/1477) [`79e705f`](https://github.com/bigcommerce/catalyst/commit/79e705f151a733a811effed40757030aba6b6300) Thanks [@deini](https://github.com/deini)! - Breadcrumbs for top level category pages are no longer rendered - [#1467](https://github.com/bigcommerce/catalyst/pull/1467) [`e763a83`](https://github.com/bigcommerce/catalyst/commit/e763a83bcd4b8b5311586247291338eb65fbc476) Thanks [@deini](https://github.com/deini)! - Fixes an issue when a numeric product option set to a minimum <= 0 breaks the counter component. - [#1459](https://github.com/bigcommerce/catalyst/pull/1459) [`b4485c7`](https://github.com/bigcommerce/catalyst/commit/b4485c76de8c83546c68a7b50fcb7991603dbf6e) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Updates the with-routes middleware to fallback on locale based rewrite logic if the redirect is a dynamic entity redirect. - [#1469](https://github.com/bigcommerce/catalyst/pull/1469) [`8e9e7f3`](https://github.com/bigcommerce/catalyst/commit/8e9e7f3d40545004b080146b4dbb42f4ac7cf17c) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Fixes the product quantity reseting back to the previous value when adjusting the quantity fails. - [#1476](https://github.com/bigcommerce/catalyst/pull/1476) [`d47e3ac`](https://github.com/bigcommerce/catalyst/commit/d47e3aceb244713bc996287319357e6af3d865ed) Thanks [@deini](https://github.com/deini)! - adds an empty state to category pages - [#1458](https://github.com/bigcommerce/catalyst/pull/1458) [`3d67f8d`](https://github.com/bigcommerce/catalyst/commit/3d67f8d0d1776d747e9aa485b0b29a738eeacf3c) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Add no-store to mutations that are rate limited. - [#1453](https://github.com/bigcommerce/catalyst/pull/1453) [`1c8b042`](https://github.com/bigcommerce/catalyst/commit/1c8b04278074eb55358a5515f330a011de9561b5) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - Updated dependencies [[`2d1526a`](https://github.com/bigcommerce/catalyst/commit/2d1526a50402b2eb677abd55f19fb904234d1a84)]: - @bigcommerce/catalyst-client@0.10.0 ## 0.16.0 ### Minor Changes - [#1410](https://github.com/bigcommerce/catalyst/pull/1410) [`53cca82`](https://github.com/bigcommerce/catalyst/commit/53cca82611272fc3be24505b7c6d5866f10c87fd) Thanks [@bookernath](https://github.com/bookernath)! - Move /reset page to /login/forgot-password in order to reduce top-level routes. - [#1384](https://github.com/bigcommerce/catalyst/pull/1384) [`17692ca`](https://github.com/bigcommerce/catalyst/commit/17692caa3ff9b25180359d8a020470ece3e589f6) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Pass customer ip address into requests that don't rely on cached values. - [#1388](https://github.com/bigcommerce/catalyst/pull/1388) [`a309a4d`](https://github.com/bigcommerce/catalyst/commit/a309a4dd47083a58c998a4f6d169185177cca571) Thanks [@deini](https://github.com/deini)! - wraps header and footer in suspense boundaries ### Patch Changes - [#1374](https://github.com/bigcommerce/catalyst/pull/1374) [`1f76f61`](https://github.com/bigcommerce/catalyst/commit/1f76f615b38bb41db770653bd8e7947cd6361b18) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Prepend locale for redirected urls in tests. More info: https://github.com/amannn/next-intl/issues/1335 - [#1373](https://github.com/bigcommerce/catalyst/pull/1373) [`971033f`](https://github.com/bigcommerce/catalyst/commit/971033fc63181bad15aa46abb65b0d44501922c9) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add missing metadata in account settings page. - [#1370](https://github.com/bigcommerce/catalyst/pull/1370) [`655d518`](https://github.com/bigcommerce/catalyst/commit/655d518b2fd662614539467fff940b2b5ff78567) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#1446](https://github.com/bigcommerce/catalyst/pull/1446) [`ba4820b`](https://github.com/bigcommerce/catalyst/commit/ba4820bf6dd36d0155028ad3db094bd9745d5d94) Thanks [@deini](https://github.com/deini)! - Fixes a bug where product variant was not reliably being selected on PDP when using pre-selected options. - [#1391](https://github.com/bigcommerce/catalyst/pull/1391) [`4d64c31`](https://github.com/bigcommerce/catalyst/commit/4d64c31d4765dd72c81c1836b66aa1d7cb34b5f5) Thanks [@bookernath](https://github.com/bookernath)! - Get lossy image from API instead of setting param in code - [#1389](https://github.com/bigcommerce/catalyst/pull/1389) [`a4eaff6`](https://github.com/bigcommerce/catalyst/commit/a4eaff6bb2520f748630e24a6a28ca31cd2eb2c3) Thanks [@bookernath](https://github.com/bookernath)! - Add additional IP address header - [#1402](https://github.com/bigcommerce/catalyst/pull/1402) [`6e75ef5`](https://github.com/bigcommerce/catalyst/commit/6e75ef5097e0f3227c04ac0d9d7bbc484513bcce) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - fixing the problem with submitting the password change form - [#1407](https://github.com/bigcommerce/catalyst/pull/1407) [`ac9832f`](https://github.com/bigcommerce/catalyst/commit/ac9832fcc61f01413a5b8f101f5f27c53ca1fce5) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#1392](https://github.com/bigcommerce/catalyst/pull/1392) [`76227ac`](https://github.com/bigcommerce/catalyst/commit/76227ac06bb349f604f1d2d4a9b68e7d0869eba4) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#1424](https://github.com/bigcommerce/catalyst/pull/1424) [`4874add`](https://github.com/bigcommerce/catalyst/commit/4874addfbdde90ac45aa57c10767587ba4c50735) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. - [#1445](https://github.com/bigcommerce/catalyst/pull/1445) [`ba3f513`](https://github.com/bigcommerce/catalyst/commit/ba3f513ac4242ce6883ad6ab635d38156a271ca9) Thanks [@deini](https://github.com/deini)! - Adds optimistic updates to all "Add to cart" buttons. This change makes the UI feel snappier and give quick feedback on user interaction. - Updated dependencies [[`17692ca`](https://github.com/bigcommerce/catalyst/commit/17692caa3ff9b25180359d8a020470ece3e589f6)]: - @bigcommerce/catalyst-client@0.9.0 ## 0.15.0 ### Minor Changes - [#1362](https://github.com/bigcommerce/catalyst/pull/1362) [`0814afe`](https://github.com/bigcommerce/catalyst/commit/0814afefca00b2497dddb0622df45f4d50865882) Thanks [@deini](https://github.com/deini)! - If app is not running on Vercel's infra, `` and `` are not rendered. Opt-out of vercel analytics and speed insights by setting the following env vars to `true` - `DISABLE_VERCEL_ANALYTICS` - `DISABLE_VERCEL_SPEED_INSIGHTS` - [#1354](https://github.com/bigcommerce/catalyst/pull/1354) [`3d298c7`](https://github.com/bigcommerce/catalyst/commit/3d298c7190e01309ee706c0b9696f8851071e73c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Move address forms in account to their own /add and /edit pages. - [#1280](https://github.com/bigcommerce/catalyst/pull/1280) [`27cbfd2`](https://github.com/bigcommerce/catalyst/commit/27cbfd20307d630f44c2c236e2e0c61a9e57be33) Thanks [@bookernath](https://github.com/bookernath)! - Add dynamic favicon from API on a static route - [#1357](https://github.com/bigcommerce/catalyst/pull/1357) [`3176491`](https://github.com/bigcommerce/catalyst/commit/317649109861e75fa46794e0cbf67dca500947a6) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add /account/settings/change-password route for change password form. ### Patch Changes - [#1361](https://github.com/bigcommerce/catalyst/pull/1361) [`dd10d06`](https://github.com/bigcommerce/catalyst/commit/dd10d064156e8fc0376f0cce6f698dc8b834f95e) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Enforce use of next-intl's wrapper navigation APIs. - [#1360](https://github.com/bigcommerce/catalyst/pull/1360) [`00f72dd`](https://github.com/bigcommerce/catalyst/commit/00f72ddc7e3c2cff780430e074341ee72bc0c893) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Change LocalePrefix mode to `as-needed`, since there's an issue that is causing caching problems when using `never`. More info about LocalePrefixes: https://next-intl-docs.vercel.app/docs/routing#shared-configuration Open issue: https://github.com/amannn/next-intl/issues/786 - [#1338](https://github.com/bigcommerce/catalyst/pull/1338) [`d50613a`](https://github.com/bigcommerce/catalyst/commit/d50613a669696f34a695bc35b9d40099eeea0660) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - improve redirect behavior after change password on account page - [#1358](https://github.com/bigcommerce/catalyst/pull/1358) [`48db1b8`](https://github.com/bigcommerce/catalyst/commit/48db1b80a8aeb8e63fb920bf4374413c0d6c67c5) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update da and fr translations to use correct string templates. - [#1368](https://github.com/bigcommerce/catalyst/pull/1368) [`d032e65`](https://github.com/bigcommerce/catalyst/commit/d032e659ba0ea1b45dc47e3afcb9094ca4f38afc) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Localize metadata titles. - [#1369](https://github.com/bigcommerce/catalyst/pull/1369) [`c9a5ab5`](https://github.com/bigcommerce/catalyst/commit/c9a5ab58be4dad966dc8d406ade8433f0f2b5d25) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Pass in default channel to favicon query, since `getLocale` can't be used in routes. ## 0.14.2 ### Patch Changes - Updated dependencies [[`88663d1`](https://github.com/bigcommerce/catalyst/commit/88663d165691380b35f83726f0589896bdc73bf2)]: - @bigcommerce/catalyst-client@0.8.0 ## 0.14.1 ### Patch Changes - [#1257](https://github.com/bigcommerce/catalyst/pull/1257) [`d656e79`](https://github.com/bigcommerce/catalyst/commit/d656e7981c7516be560b1944e4351916572b7a05) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - add numbers-only field & utils for account form fields - [#1277](https://github.com/bigcommerce/catalyst/pull/1277) [`8e6253d`](https://github.com/bigcommerce/catalyst/commit/8e6253dbd3048b8318ce502192bc9f07314b3641) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update Slideshow prop to use altText for image. Rename Hero wrapper component to Slideshow. - [#1302](https://github.com/bigcommerce/catalyst/pull/1302) [`a620a19`](https://github.com/bigcommerce/catalyst/commit/a620a191d3d30d50d0fa79fc36ad32ee28db8728) Thanks [@deini](https://github.com/deini)! - fix: decode webpage id to fix 404 on some Webpages - [#1257](https://github.com/bigcommerce/catalyst/pull/1257) [`d656e79`](https://github.com/bigcommerce/catalyst/commit/d656e7981c7516be560b1944e4351916572b7a05) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - add checkboxes field for account & addresses forms - [#1346](https://github.com/bigcommerce/catalyst/pull/1346) [`33e133d`](https://github.com/bigcommerce/catalyst/commit/33e133df74b263aeabd23f72f6b8ccfdc22c1a36) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - fix placeholder positioning for picklist custom form field - [#1316](https://github.com/bigcommerce/catalyst/pull/1316) [`4aea109`](https://github.com/bigcommerce/catalyst/commit/4aea109593e7ac060552dca18198e39c0b070e55) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Normalizes translations across all pages, updates the next-intl configuration, and simplifies translation handling in the project. - [#1257](https://github.com/bigcommerce/catalyst/pull/1257) [`d656e79`](https://github.com/bigcommerce/catalyst/commit/d656e7981c7516be560b1944e4351916572b7a05) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - add dates field for account & address forms - [#1141](https://github.com/bigcommerce/catalyst/pull/1141) [`9f3c949`](https://github.com/bigcommerce/catalyst/commit/9f3c9492b2d4edcd404cffc92dfcfec6a0afc395) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - improve redirect behavior after creating new address - [#1305](https://github.com/bigcommerce/catalyst/pull/1305) [`b11ba3d`](https://github.com/bigcommerce/catalyst/commit/b11ba3d63547d2772a649078274b5b71702c402a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Refactors tabs in `/account` to each be their own page. This also removes unused links in account home page (and tests) until we have that functionality available. Previous structure: ``` /account [tab] page.tsx ``` New structure: ``` /account (tabs) addresses page.tsx settings page.tsx ...etc ``` - [#1257](https://github.com/bigcommerce/catalyst/pull/1257) [`d656e79`](https://github.com/bigcommerce/catalyst/commit/d656e7981c7516be560b1944e4351916572b7a05) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - add multipleChoices field(radio-buttons, picklist) for account & address forms - [#1334](https://github.com/bigcommerce/catalyst/pull/1334) [`00f43f0`](https://github.com/bigcommerce/catalyst/commit/00f43f045b4ac2f71aef36a41a1ef643bfc66247) Thanks [@deini](https://github.com/deini)! - Fixes a server crash when user switches language settings - [#1333](https://github.com/bigcommerce/catalyst/pull/1333) [`e2c0153`](https://github.com/bigcommerce/catalyst/commit/e2c01535e0efbd474b1236d0a7e63ad2263475db) Thanks [@deini](https://github.com/deini)! - Splits i18n into request.ts and routing.ts This helps reduce our middleware bundle as we no longer do a dynamic import on our middleware entrypoint. - [#1342](https://github.com/bigcommerce/catalyst/pull/1342) [`f7bb1e2`](https://github.com/bigcommerce/catalyst/commit/f7bb1e2654912c2b25851f3a86f77fa6f1014817) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update localeSwitcher to use a link instead of a form. - [#1326](https://github.com/bigcommerce/catalyst/pull/1326) [`255c648`](https://github.com/bigcommerce/catalyst/commit/255c6482a48d735a28c632746b4a652d8ba1dfed) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Ensure recaptcha is bypassed for functional tests. - [#1278](https://github.com/bigcommerce/catalyst/pull/1278) [`f8553c6`](https://github.com/bigcommerce/catalyst/commit/f8553c6c9fb35ab7a143fabd60719c8156269448) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fix wrapping author text in BlogPostCard. - [#1322](https://github.com/bigcommerce/catalyst/pull/1322) [`77ecb4b`](https://github.com/bigcommerce/catalyst/commit/77ecb4bb4f527e079788b0f9dff2468e92d0bc1a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Split auth forms to four different pages: - /login - /register - /reset - /change-password Additionally, moved shared form field components to `/components/form-fields/` and updated translations. - [#1317](https://github.com/bigcommerce/catalyst/pull/1317) [`7802361`](https://github.com/bigcommerce/catalyst/commit/780236150bab6e2c43e73a230ed69113e3e1bae3) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Rename NEXT_PUBLIC_DEFAULT_REVALIDATE_TARGET to DEFAULT_REVALIDATE_TARGET since we don't need this exposed to the client. - [#1296](https://github.com/bigcommerce/catalyst/pull/1296) [`fcd44bb`](https://github.com/bigcommerce/catalyst/commit/fcd44bb90bf2d82b098600f4809ae3f37d5c01dc) Thanks [@bookernath](https://github.com/bookernath)! - Add link header to preconnect to CDN - [#1088](https://github.com/bigcommerce/catalyst/pull/1088) [`644361e`](https://github.com/bigcommerce/catalyst/commit/644361e8a75185e05964a782569c4b17dc5a9f98) Thanks [@bc-yevhenii-buliuk](https://github.com/bc-yevhenii-buliuk)! - improve redirect behavior after creating account - [#1329](https://github.com/bigcommerce/catalyst/pull/1329) [`ad601e1`](https://github.com/bigcommerce/catalyst/commit/ad601e1be0f2e2b0e458363af13d3b7881f8cf24) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - update multiline form-field to respect required settings - [#1257](https://github.com/bigcommerce/catalyst/pull/1257) [`d656e79`](https://github.com/bigcommerce/catalyst/commit/d656e7981c7516be560b1944e4351916572b7a05) Thanks [@bc-alexsaiannyi](https://github.com/bc-alexsaiannyi)! - add multilinetext field for account & address forms - [#1300](https://github.com/bigcommerce/catalyst/pull/1300) [`b32198b`](https://github.com/bigcommerce/catalyst/commit/b32198b78dcd18b05ba0c0f57269cbd62023a654) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Refactor queries, fragments, and mutations in an effort to set a pattern on where these functions need to be defined. Shared queries and mutations will remain in /client for now. - [#1349](https://github.com/bigcommerce/catalyst/pull/1349) [`dd9cf6f`](https://github.com/bigcommerce/catalyst/commit/dd9cf6f61efb6b17322e1485225003d9799cbf9a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove updateCustomer and getCustomerAddresses queries since they are defined now where they are used. - [#1313](https://github.com/bigcommerce/catalyst/pull/1313) [`6531bb2`](https://github.com/bigcommerce/catalyst/commit/6531bb2ee9b6a6125cd4f9f0e624e023897387be) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove "Quick add" button in PLP for products that have options. Will now just show a button that links to the product. ## 0.14.0 ### Minor Changes - [#1261](https://github.com/bigcommerce/catalyst/pull/1261) [`f715067`](https://github.com/bigcommerce/catalyst/commit/f715067aa36616b3818c9424c57fa08e28936cde) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Remove the need of fetching shipping countries by using the GraphQL data. - [#1261](https://github.com/bigcommerce/catalyst/pull/1261) [`f715067`](https://github.com/bigcommerce/catalyst/commit/f715067aa36616b3818c9424c57fa08e28936cde) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Fetch shipping zones if access token exists, otherwise regress back to using the geography node on graphql for shipping information. This is part of an effort to remove the need of the `BIGCOMMERCE_ACCESS_TOKEN`. ### Patch Changes - [#1256](https://github.com/bigcommerce/catalyst/pull/1256) [`686abe9`](https://github.com/bigcommerce/catalyst/commit/686abe9eae18cd2241e7ac17e17f7139d6b87bd6) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Consistency improvements to prop APIs for UI components. - Updated dependencies [[`f715067`](https://github.com/bigcommerce/catalyst/commit/f715067aa36616b3818c9424c57fa08e28936cde)]: - @bigcommerce/catalyst-client@0.7.0 ## 0.13.0 ### Minor Changes - [#1166](https://github.com/bigcommerce/catalyst/pull/1166) [`0661e53`](https://github.com/bigcommerce/catalyst/commit/0661e53e66a12713a5ad23292a0a0eb25cddd9dc) Thanks [@bookernath](https://github.com/bookernath)! - Use default SEO settings from store for pages without SEO information specified, normalize SEO implementation across pages - [#1194](https://github.com/bigcommerce/catalyst/pull/1194) [`b455b05`](https://github.com/bigcommerce/catalyst/commit/b455b05a6121b005bd5147a25c964b9554b1b350) Thanks [@BC-krasnoshapka](https://github.com/BC-krasnoshapka)! - Add basic support for Google Analytics via [Big Open Data Layer](https://developer.bigcommerce.com/docs/integrations/hosted-analytics). BODL and GA4 integration is encapsulated in `bodl` library which hides current complexity and limitations that will be improved in future. It can be extended with more events and integrations with other analytics providers later. Data transformation from Catalyst data models to BODL and firing events is done in client components, as only frontend events are supported by BODL for now. List of currently supported events: - View product category - View product page - Add product to cart - View cart - Remove product from cart In order to configure you need to specify `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` environment variable which is essentially your GA4 ID. ### Patch Changes - [#1225](https://github.com/bigcommerce/catalyst/pull/1225) [`127f3b6`](https://github.com/bigcommerce/catalyst/commit/127f3b6000f0345a1e277d038025edadeaa09d71) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Change prop `items` to `links` in Header. - [#1232](https://github.com/bigcommerce/catalyst/pull/1232) [`b7d4986`](https://github.com/bigcommerce/catalyst/commit/b7d4986b390932be770de9adcf12112df4bb58e1) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove `Popover` component, utilize radix primitives instead. - [#1196](https://github.com/bigcommerce/catalyst/pull/1196) [`b793661`](https://github.com/bigcommerce/catalyst/commit/b793661ab145a2acec5b2fa5aa0c5f1d6865cad9) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add locale picker in header. - [#1231](https://github.com/bigcommerce/catalyst/pull/1231) [`befb122`](https://github.com/bigcommerce/catalyst/commit/befb122d033ba56b87cb04f31e0f34fe4386d285) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add `Dropdown` component. - [#1209](https://github.com/bigcommerce/catalyst/pull/1209) [`ef2f3cb`](https://github.com/bigcommerce/catalyst/commit/ef2f3cbddb872a5a2ad1c188f40cd5671eaf77b7) Thanks [@bookernath](https://github.com/bookernath)! - Limit number of chunks in webpack, customizable via env - [#1239](https://github.com/bigcommerce/catalyst/pull/1239) [`9a37c6a`](https://github.com/bigcommerce/catalyst/commit/9a37c6a25ccaed7b7373cdb3637706c6826a380a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add `Search` component. - [#1199](https://github.com/bigcommerce/catalyst/pull/1199) [`e8bf185`](https://github.com/bigcommerce/catalyst/commit/e8bf185f34061be96cfe6a118431c3a4c62df7a2) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add more context when no result is found in search page. - [#1236](https://github.com/bigcommerce/catalyst/pull/1236) [`7d9e865`](https://github.com/bigcommerce/catalyst/commit/7d9e86568c5422cb74ef512ba851ee709e9d59f0) Thanks [@bookernath](https://github.com/bookernath)! - Exclude node_modules from tailwind config to improve build time - [#1214](https://github.com/bigcommerce/catalyst/pull/1214) [`4e890ff`](https://github.com/bigcommerce/catalyst/commit/4e890ffe203605c4a77be1acdf33622ff871405d) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Change prop `value` to `title` in Accordions. - [#1197](https://github.com/bigcommerce/catalyst/pull/1197) [`c831677`](https://github.com/bigcommerce/catalyst/commit/c831677cb873e67a898ffd1efeda0c518c6ab97d) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Set key before spreading prop in some form components. - [#1188](https://github.com/bigcommerce/catalyst/pull/1188) [`5c77f41`](https://github.com/bigcommerce/catalyst/commit/5c77f41eb6ced4677d85fef1adf898fe697a0452) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Rename brand prop to subtitle in Product Card. - [#1234](https://github.com/bigcommerce/catalyst/pull/1234) [`052e94a`](https://github.com/bigcommerce/catalyst/commit/052e94abd76b52700badde189ec36aee6cc383b1) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add `Breadcrumbs` component. - [#1224](https://github.com/bigcommerce/catalyst/pull/1224) [`5f934f9`](https://github.com/bigcommerce/catalyst/commit/5f934f91b790b9dd9001f133bdd75ce06951465c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Change prop `thumbnail` to `image` in BlogPostCard. - [#1206](https://github.com/bigcommerce/catalyst/pull/1206) [`d1cf327`](https://github.com/bigcommerce/catalyst/commit/d1cf327d4c2c28f01940391a74cc4750d79b03b7) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add `Slide` component to be used in `Slideshow`. - [#1198](https://github.com/bigcommerce/catalyst/pull/1198) [`22dc862`](https://github.com/bigcommerce/catalyst/commit/22dc86260daaaeec20276a84b89c152a3ae246a3) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add missing accessibility components to Sheet/Mobile Nav. - [#1226](https://github.com/bigcommerce/catalyst/pull/1226) [`d6d1224`](https://github.com/bigcommerce/catalyst/commit/d6d1224521d4304bbdb515763aaee402b1a97c94) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Rename `value` to `rating` for Rating component, remove unused props. - [#1190](https://github.com/bigcommerce/catalyst/pull/1190) [`d01b4e0`](https://github.com/bigcommerce/catalyst/commit/d01b4e0560b1b8b2b3df9ed348231a2fc375f785) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove title prop from Tabs, remove Tabs from /account since it's not needed. - [#1204](https://github.com/bigcommerce/catalyst/pull/1204) [`bde94ba`](https://github.com/bigcommerce/catalyst/commit/bde94bab5299b933047c58cd3c64a73022c039bc) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add missing accisibility components to Quick Search. - [#1200](https://github.com/bigcommerce/catalyst/pull/1200) [`51704d9`](https://github.com/bigcommerce/catalyst/commit/51704d9b9a7158c625c84f79e2ba95f98c6dc673) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Use the `geography` node to retrieve a list of countries. This removes one less dependency on the access token. - [#1235](https://github.com/bigcommerce/catalyst/pull/1235) [`53ccd31`](https://github.com/bigcommerce/catalyst/commit/53ccd31f51e5b6d8f311a340d0bf70b7edb632aa) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add `Pagination` component. - [#1211](https://github.com/bigcommerce/catalyst/pull/1211) [`ec81a3a`](https://github.com/bigcommerce/catalyst/commit/ec81a3a69182d015395d6dc7bfff1e9af2adb6f9) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Update price prop in ProductCard to accept an object instead of a ReactNode. - [#1208](https://github.com/bigcommerce/catalyst/pull/1208) [`315ed15`](https://github.com/bigcommerce/catalyst/commit/315ed154e1ccfe316dc4d1037e674b79c3bad308) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Move CompareDrawer to ui components. - Updated dependencies [[`51704d9`](https://github.com/bigcommerce/catalyst/commit/51704d9b9a7158c625c84f79e2ba95f98c6dc673)]: - @bigcommerce/catalyst-client@0.6.0 ## 0.12.0 ### Minor Changes - [#1178](https://github.com/bigcommerce/catalyst/pull/1178) [`f592d9f`](https://github.com/bigcommerce/catalyst/commit/f592d9fe0b71ddd7ceb5e1326ea0280f7b90c3c9) Thanks [@jorgemoya](https://github.com/jorgemoya)! - This refactor changes the structure of our UI components by replacing composability with a prop-based configuration. This change simplifies the use of our components, eliminating the need to build them individually from a composable approach. Additionally, it provides a single location for all class customizations, improving the experience when fully customizing the component. We believe this approach will make it easier to use components correctly and safeguard against incorrect usage. Ultimately, by adopting a prop-based configuration, we aim to achieve full replaceability and simplify theming for our components. Before refactor: ``` Title 1 Item Content 1 Title 2 Item Content 2 ``` After refactor: ``` ``` Before refactor: ``` ``` After refactor: ```