From b4215f06cc4fbda8694835c589c84b362fb1a8fc Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Tue, 2 Jun 2026 09:55:40 -0500 Subject: [PATCH 01/18] TRAC-640: fix(core) - Scope consent cookie to host instead of root domain (#3035) The consent manager set `crossSubdomain: true`, which wrote the `c15t-consent` cookie with `domain=.`. For stores on a sub-domain this caused the cookie to appear on both the root domain and the sub-domain. Remove the option so the cookie is host-only and exists only on the sub-domain the store runs on. Refs TRAC-640 Co-authored-by: Claude Opus 4.8 (1M context) --- .changeset/ltrac-657-consent-cookie-subdomain.md | 5 +++++ core/components/consent-manager/consent-providers.tsx | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .changeset/ltrac-657-consent-cookie-subdomain.md diff --git a/.changeset/ltrac-657-consent-cookie-subdomain.md b/.changeset/ltrac-657-consent-cookie-subdomain.md new file mode 100644 index 0000000000..be647e7267 --- /dev/null +++ b/.changeset/ltrac-657-consent-cookie-subdomain.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Scope the consent manager cookie (`c15t-consent`) to the current host instead of the top-level domain. Previously `crossSubdomain: true` caused the cookie to be set on the root domain (e.g. `.example.com`) for stores running on a sub-domain, so it appeared on both the root domain and the sub-domain. Removing it makes the cookie host-only, so it now exists only on the sub-domain the store runs on. diff --git a/core/components/consent-manager/consent-providers.tsx b/core/components/consent-manager/consent-providers.tsx index eec6fd115e..bf34a4fade 100644 --- a/core/components/consent-manager/consent-providers.tsx +++ b/core/components/consent-manager/consent-providers.tsx @@ -23,7 +23,6 @@ export function ConsentManagerProvider({ mode: 'offline', storageConfig: { storageKey: CONSENT_COOKIE_NAME, - crossSubdomain: true, }, consentCategories: ['necessary', 'functionality', 'marketing', 'measurement'], enabled: isCookieConsentEnabled, From 8bc379df6fe0c843ba82e901e31e245f506d0cf3 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Tue, 2 Jun 2026 16:09:44 -0500 Subject: [PATCH 02/18] fix(core): rewrite WYSIWYG /content image URLs to absolute CDN URLs (#3033) * LTRAC-297: fix(storefront) - Rewrite WYSIWYG /content image URLs Images uploaded through the Control Panel WYSIWYG editor are stored as root-relative /content/... paths. These resolve on a same-domain Stencil storefront but 404 on the headless Catalyst domain, rendering as broken images. Add a server-side transformer that rewrites src/href values starting with /content/ to absolute BigCommerce CDN URLs, and apply it to all raw-HTML surfaces: web pages, blog posts, and product description and warranty. Internal page links and already-absolute URLs are left intact. Fixes LTRAC-297 Co-Authored-By: Claude Opus 4.8 (1M context) * LTRAC-297: fix(storefront) - Also rewrite /product_images WYSIWYG paths The WYSIWYG image manager stores uploads under /product_images/... (not just /content/...). Generalize the rewrite to cover both WebDAV asset folders via a new cdnAssetUrl() helper, so image-manager images embedded in WYSIWYG content resolve to absolute CDN URLs. Refs LTRAC-297 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .changeset/fix-wysiwyg-content-image-urls.md | 5 +++++ .../[locale]/(default)/blog/[blogId]/page.tsx | 3 ++- .../[locale]/(default)/product/[slug]/page.tsx | 16 ++++++++++++++-- .../(default)/webpages/[id]/normal/page.tsx | 3 ++- .../html-content-transformer.ts | 16 ++++++++++++++++ core/lib/store-assets.ts | 15 ++++++++++++++- 6 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-wysiwyg-content-image-urls.md create mode 100644 core/data-transformers/html-content-transformer.ts diff --git a/.changeset/fix-wysiwyg-content-image-urls.md b/.changeset/fix-wysiwyg-content-image-urls.md new file mode 100644 index 0000000000..3106a22546 --- /dev/null +++ b/.changeset/fix-wysiwyg-content-image-urls.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Fix broken images in WYSIWYG content (web pages, blog posts, product description and warranty). Images uploaded through the Control Panel editor are stored as store-root-relative WebDAV paths (`/content/...` and `/product_images/...`) that 404 on the headless storefront domain; they are now rewritten to absolute BigCommerce CDN URLs. diff --git a/core/app/[locale]/(default)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index 6b8da45019..0560598d72 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page.tsx +++ b/core/app/[locale]/(default)/blog/[blogId]/page.tsx @@ -5,6 +5,7 @@ import { cache } from 'react'; import { BlogPostContent, BlogPostContentBlogPost } from '@/vibes/soul/sections/blog-post-content'; import { Breadcrumb } from '@/vibes/soul/sections/breadcrumbs'; +import { rewriteWysiwygContentUrls } from '~/data-transformers/html-content-transformer'; import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getBlogPageData } from './page-data'; @@ -59,7 +60,7 @@ async function getBlogPost(props: Props): Promise { return { author: blogPost.author ?? undefined, title: blogPost.name, - content: blogPost.htmlBody, + content: rewriteWysiwygContentUrls(blogPost.htmlBody), date: format.dateTime(new Date(blogPost.publishedDate.utc)), image: blogPost.thumbnailImage ? { alt: blogPost.thumbnailImage.altText, src: blogPost.thumbnailImage.url } diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 8d2001ee8c..fab77ab2fc 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -8,6 +8,7 @@ import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { FeaturedProductCarousel } from '@/vibes/soul/sections/featured-product-carousel'; import { ProductDetail } from '@/vibes/soul/sections/product-detail'; import { auth, getSessionCustomerAccessToken } from '~/auth'; +import { rewriteWysiwygContentUrls } from '~/data-transformers/html-content-transformer'; import { pricesTransformer } from '~/data-transformers/prices-transformer'; import { productCardTransformer } from '~/data-transformers/product-card-transformer'; import { productOptionsTransformer } from '~/data-transformers/product-options-transformer'; @@ -482,7 +483,12 @@ export default async function Product({ params, searchParams }: Props) { { title: t('ProductDetails.Accordions.warranty'), content: ( -
+
), }, ] @@ -569,7 +575,13 @@ export default async function Product({ params, searchParams }: Props) { product={{ id: baseProduct.entityId.toString(), title: baseProduct.name, - description:
, + description: ( +
+ ), href: baseProduct.path, images: streamableImages, price: streamablePrices, diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx index d673197576..f0da81ae2b 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx @@ -10,6 +10,7 @@ import { breadcrumbsTransformer, truncateBreadcrumbs, } from '~/data-transformers/breadcrumbs-transformer'; +import { rewriteWysiwygContentUrls } from '~/data-transformers/html-content-transformer'; import { getMetadataAlternates } from '~/lib/seo/canonical'; import { WebPageContent, WebPage as WebPageData } from '../_components/web-page'; @@ -33,7 +34,7 @@ const getWebPage = cache(async (id: string, customerAccessToken?: string): Promi return { title: webpage.name, breadcrumbs, - content: webpage.htmlBody, + content: rewriteWysiwygContentUrls(webpage.htmlBody), seo: webpage.seo, }; }); diff --git a/core/data-transformers/html-content-transformer.ts b/core/data-transformers/html-content-transformer.ts new file mode 100644 index 0000000000..6bedf18e0e --- /dev/null +++ b/core/data-transformers/html-content-transformer.ts @@ -0,0 +1,16 @@ +import { cdnAssetUrl } from '~/lib/store-assets'; + +// The Control Panel WYSIWYG editor stores uploaded images/files as store-root-relative WebDAV +// paths — `/content/...` for content uploads and `/product_images/...` for image-manager +// uploads. These resolve on a same-domain Stencil storefront but 404 on the headless Catalyst +// domain. Rewrite them to absolute BigCommerce CDN URLs. Scoped to known WebDAV asset folders +// so internal page links are left untouched. +const WEBDAV_ASSET_URL_REGEX = /(src|href)=("|')(\/(?:content|product_images)\/[^"']*)\2/gi; + +export function rewriteWysiwygContentUrls(html: string): string { + return html.replace( + WEBDAV_ASSET_URL_REGEX, + (_match, attr: string, quote: string, path: string) => + `${attr}=${quote}${cdnAssetUrl(path)}${quote}`, + ); +} diff --git a/core/lib/store-assets.ts b/core/lib/store-assets.ts index cfa5314c94..cc63c02863 100644 --- a/core/lib/store-assets.ts +++ b/core/lib/store-assets.ts @@ -22,6 +22,19 @@ const cdnImageUrlBuilder = ( return `https://${buildConfig.get('urls').cdnUrls.at(cdnIndex)}/s-${storeHash}/images/stencil/${sizeSegment}/${source}/${path}`; }; +/** + * Given a store-root-relative WebDAV path (e.g. `/content/foo.png` or + * `/product_images/uploaded_images/bar.jpg`), return the absolute CDN URL that + * serves it. WebDAV folders are mirrored to the CDN at `/s-{storeHash}{path}`. + * + * @param {string} rootRelativePath - The store-root-relative path, including its leading slash. + * @param {number} cdnIndex - The index of the CDN URL to use. Defaults to 0. + * @returns {string} The absolute CDN URL to the asset. + */ +export const cdnAssetUrl = (rootRelativePath: string, cdnIndex = 0): string => { + return `https://${buildConfig.get('urls').cdnUrls.at(cdnIndex)}/s-${storeHash}${rootRelativePath}`; +}; + /** * Given a path, return the full URL to the content asset. * These assets are accessible via the /content folder in WebDAV on the store. @@ -33,7 +46,7 @@ const cdnImageUrlBuilder = ( * @returns {string} The full URL to the content asset. */ export const contentAssetUrl = (path: string, cdnIndex = 0): string => { - return `https://${buildConfig.get('urls').cdnUrls.at(cdnIndex)}/s-${storeHash}/content/${path}`; + return cdnAssetUrl(`/content/${path}`, cdnIndex); }; /** From 15e365aeeb36a44901769b7831e635e9e0e7bcc1 Mon Sep 17 00:00:00 2001 From: mfaris9 Date: Thu, 4 Jun 2026 10:29:27 -0400 Subject: [PATCH 03/18] feat(core): TRAC-421 consume merchant-configured locale subfolders from BC backend (#3015) --- .../trac-421-custom-locale-subfolders.md | 5 ++++ core/build-config/schema.ts | 1 + core/i18n/locales.ts | 29 ++++++++++++++++++- core/i18n/routing.ts | 15 +++++++--- core/lib/seo/canonical.ts | 7 +++-- core/next.config.ts | 1 + core/proxies/with-routes.ts | 9 ++++-- 7 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 .changeset/trac-421-custom-locale-subfolders.md diff --git a/.changeset/trac-421-custom-locale-subfolders.md b/.changeset/trac-421-custom-locale-subfolders.md new file mode 100644 index 0000000000..a65f46c3a9 --- /dev/null +++ b/.changeset/trac-421-custom-locale-subfolders.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Consume merchant-configured per-locale URL subfolders from the BigCommerce Storefront GraphQL API (`Locale.path`). The locale that sits at the bare root URL (`/`) is derived from the CP configuration: if the default locale has no path, it sits at root; otherwise, if exactly one non-default locale has no path, that one sits at root; otherwise every locale gets a prefix. Locales with a path use it; locales without a path fall back to their locale code. diff --git a/core/build-config/schema.ts b/core/build-config/schema.ts index a2c802d0c7..710d49148c 100644 --- a/core/build-config/schema.ts +++ b/core/build-config/schema.ts @@ -5,6 +5,7 @@ export const buildConfigSchema = z.object({ z.object({ code: z.string(), isDefault: z.boolean(), + path: z.string().nullable(), }), ), urls: z.object({ diff --git a/core/i18n/locales.ts b/core/i18n/locales.ts index b9cde9ccb9..190e43bfc8 100644 --- a/core/i18n/locales.ts +++ b/core/i18n/locales.ts @@ -1,6 +1,33 @@ import { buildConfig } from '~/build-config/reader'; const localeNodes = buildConfig.get('locales'); +const defaultLocaleNode = localeNodes.find((locale) => locale.isDefault); export const locales = localeNodes.map((locale) => locale.code); -export const defaultLocale = localeNodes.find((locale) => locale.isDefault)?.code ?? 'en'; +export const defaultLocale = defaultLocaleNode?.code ?? 'en'; + +export const prefixes = localeNodes.reduce>((acc, locale) => { + if (locale.path) acc[locale.code] = `/${locale.path}`; + + return acc; +}, {}); + +// The locale that should occupy the bare root URL ("/"). +// +// Rule: +// 1. If the default locale has no CP path, default lives at "/". +// 2. Else, if exactly one non-default locale has no CP path, that one lives at "/". +// 3. Otherwise, no locale lives at "/" and every locale gets a prefix. +function computeRootLocale(): string | null { + if (!defaultLocaleNode) return defaultLocale; + if (!defaultLocaleNode.path) return defaultLocaleNode.code; + + const blankNonDefaults = localeNodes.filter((locale) => !locale.isDefault && !locale.path); + const [onlyBlank, ...rest] = blankNonDefaults; + + if (onlyBlank && rest.length === 0) return onlyBlank.code; + + return null; +} + +export const rootLocale = computeRootLocale(); diff --git a/core/i18n/routing.ts b/core/i18n/routing.ts index 60517b1884..38309131e7 100644 --- a/core/i18n/routing.ts +++ b/core/i18n/routing.ts @@ -1,21 +1,28 @@ import { createNavigation } from 'next-intl/navigation'; import { defineRouting } from 'next-intl/routing'; -import { defaultLocale, locales } from './locales'; +import { defaultLocale, locales, prefixes, rootLocale } from './locales'; enum LocalePrefixes { ALWAYS = 'always', - // Don't use NEVER as there is a issue that causes cache problems and returns the wrong messages. + // Don't use NEVER as there is an issue that causes cache problems and returns the wrong messages. // More info: https://github.com/amannn/next-intl/issues/786 // NEVER = 'never', ASNEEDED = 'as-needed', // removes prefix on default locale } -const localePrefix = LocalePrefixes.ASNEEDED; +// `rootLocale` is the locale (if any) that occupies the bare "/" URL. We hand it +// to next-intl as the routing default so URLs like "/" resolve correctly. When no +// locale lives at "/", every locale needs a prefix, so we use `always` mode and +// fall back to the BC default for next-intl's defaultLocale. +const localePrefix = + rootLocale === null + ? { mode: LocalePrefixes.ALWAYS, prefixes } + : { mode: LocalePrefixes.ASNEEDED, prefixes }; export const routing = defineRouting({ locales, - defaultLocale, + defaultLocale: rootLocale ?? defaultLocale, localePrefix, }); diff --git a/core/lib/seo/canonical.ts b/core/lib/seo/canonical.ts index dd1573947a..464d124de8 100644 --- a/core/lib/seo/canonical.ts +++ b/core/lib/seo/canonical.ts @@ -3,7 +3,7 @@ import { cache } from 'react'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; -import { defaultLocale, locales } from '~/i18n/locales'; +import { defaultLocale, locales, prefixes, rootLocale } from '~/i18n/locales'; interface CanonicalUrlOptions { /** @@ -90,7 +90,10 @@ function buildLocalizedUrl(baseUrl: string, pathname: string, locale: string): s const url = new URL(pathname, baseUrl); - url.pathname = locale === defaultLocale ? url.pathname : `/${locale}${url.pathname}`; + const prefix = prefixes[locale] ?? `/${locale}`; + const skipPrefix = locale === rootLocale; + + url.pathname = skipPrefix ? url.pathname : `${prefix}${url.pathname}`; if (trailingSlash && !url.pathname.endsWith('/')) { url.pathname += '/'; diff --git a/core/next.config.ts b/core/next.config.ts index 1f39548d60..afe17e2eb8 100644 --- a/core/next.config.ts +++ b/core/next.config.ts @@ -25,6 +25,7 @@ const SettingsQuery = graphql(` locales { code isDefault + path } } } diff --git a/core/proxies/with-routes.ts b/core/proxies/with-routes.ts index 2be0083849..f8a0dcbae7 100644 --- a/core/proxies/with-routes.ts +++ b/core/proxies/with-routes.ts @@ -5,6 +5,7 @@ import { auth } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; +import { prefixes } from '~/i18n/locales'; import { getVisitIdCookie, getVisitorIdCookie } from '~/lib/analytics/bigcommerce'; import { sendProductViewedEvent } from '~/lib/analytics/bigcommerce/data-events'; import { kvKey, STORE_STATUS_KEY } from '~/lib/kv/keys'; @@ -218,12 +219,14 @@ const updateStatusCache = async ( }; const clearLocaleFromPath = (path: string, locale: string) => { - if (path === `/${locale}` || path === `/${locale}/`) { + const prefix = prefixes[locale] ?? `/${locale}`; + + if (path === prefix || path === `${prefix}/`) { return '/'; } - if (path.startsWith(`/${locale}/`)) { - return path.replace(`/${locale}`, ''); + if (path.startsWith(`${prefix}/`)) { + return path.replace(prefix, ''); } return path; From 097eea7b9205ce35ba0724a8c1a5c04281f18dd7 Mon Sep 17 00:00:00 2001 From: Aleksey Gurtovoy Date: Tue, 9 Jun 2026 10:34:24 -0500 Subject: [PATCH 04/18] feat: enables client-side navigation in Makeswift builder (#2913) Enabled by upgrading to the latest Makeswift runtime --- .changeset/shiny-cougars-drop.md | 5 +++ .../app/api/makeswift/[...makeswift]/route.ts | 2 -- core/lib/makeswift/client.ts | 1 - core/lib/makeswift/provider.tsx | 8 +---- core/lib/makeswift/runtime.ts | 4 +++ core/package.json | 2 +- pnpm-lock.yaml | 36 +++++++++---------- 7 files changed, 29 insertions(+), 29 deletions(-) create mode 100644 .changeset/shiny-cougars-drop.md diff --git a/.changeset/shiny-cougars-drop.md b/.changeset/shiny-cougars-drop.md new file mode 100644 index 0000000000..6f3eb623af --- /dev/null +++ b/.changeset/shiny-cougars-drop.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-makeswift": minor +--- + +Enable interaction-mode site navigation in Makeswift builder diff --git a/core/app/api/makeswift/[...makeswift]/route.ts b/core/app/api/makeswift/[...makeswift]/route.ts index 375dc590ef..d860a0a3e4 100644 --- a/core/app/api/makeswift/[...makeswift]/route.ts +++ b/core/app/api/makeswift/[...makeswift]/route.ts @@ -24,8 +24,6 @@ const defaultVariants: Font['variants'] = [ const handler = MakeswiftApiHandler(process.env.MAKESWIFT_SITE_API_KEY, { runtime, - apiOrigin: process.env.NEXT_PUBLIC_MAKESWIFT_API_ORIGIN ?? process.env.MAKESWIFT_API_ORIGIN, - appOrigin: process.env.NEXT_PUBLIC_MAKESWIFT_APP_ORIGIN ?? process.env.MAKESWIFT_APP_ORIGIN, getFonts() { return [ { diff --git a/core/lib/makeswift/client.ts b/core/lib/makeswift/client.ts index 9a637384ca..cc2a7eced4 100644 --- a/core/lib/makeswift/client.ts +++ b/core/lib/makeswift/client.ts @@ -11,7 +11,6 @@ strict(process.env.MAKESWIFT_SITE_API_KEY, 'MAKESWIFT_SITE_API_KEY is required') export const client = new Makeswift(process.env.MAKESWIFT_SITE_API_KEY, { runtime, - apiOrigin: process.env.NEXT_PUBLIC_MAKESWIFT_API_ORIGIN ?? process.env.MAKESWIFT_API_ORIGIN, }); export const getPageSnapshot = async ({ path, locale }: { path: string; locale: string }) => diff --git a/core/lib/makeswift/provider.tsx b/core/lib/makeswift/provider.tsx index 3cb8c18686..d2c08583d6 100644 --- a/core/lib/makeswift/provider.tsx +++ b/core/lib/makeswift/provider.tsx @@ -15,13 +15,7 @@ export function MakeswiftProvider({ siteVersion: SiteVersion | null; }) { return ( - + {children} ); diff --git a/core/lib/makeswift/runtime.ts b/core/lib/makeswift/runtime.ts index 7956ee8307..80a7d417b1 100644 --- a/core/lib/makeswift/runtime.ts +++ b/core/lib/makeswift/runtime.ts @@ -1,3 +1,4 @@ +import { fetch } from '@makeswift/runtime/next'; import { registerBoxComponent } from '@makeswift/runtime/react/builtins/box'; import { registerDividerComponent } from '@makeswift/runtime/react/builtins/divider'; import { registerEmbedComponent } from '@makeswift/runtime/react/builtins/embed'; @@ -10,12 +11,15 @@ import { registerVideoComponent } from '@makeswift/runtime/react/builtins/video' import { ReactRuntimeCore } from '@makeswift/runtime/react/core'; const runtime = new ReactRuntimeCore({ + apiOrigin: process.env.NEXT_PUBLIC_MAKESWIFT_API_ORIGIN, + appOrigin: process.env.NEXT_PUBLIC_MAKESWIFT_APP_ORIGIN, breakpoints: { small: { width: 640, viewport: 390, label: 'Small' }, medium: { width: 768, viewport: 765, label: 'Medium' }, large: { width: 1024, viewport: 1000, label: 'Large' }, screen: { width: 1280, label: 'XL' }, }, + fetch, }); // Only register necessary built-in components. Omitted components are: diff --git a/core/package.json b/core/package.json index 6d7a2be8f3..995f7d8229 100644 --- a/core/package.json +++ b/core/package.json @@ -22,7 +22,7 @@ "@conform-to/react": "^1.6.1", "@conform-to/zod": "^1.6.1", "@icons-pack/react-simple-icons": "^11.2.0", - "@makeswift/runtime": "0.26.4", + "@makeswift/runtime": "0.28.5", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/instrumentation": "^0.208.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98cf69df69..f5377351b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,8 +54,8 @@ importers: specifier: ^11.2.0 version: 11.2.0(react@19.1.7) '@makeswift/runtime': - specifier: 0.26.4 - version: 0.26.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + specifier: 0.28.5 + version: 0.28.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -2555,19 +2555,19 @@ packages: resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} engines: {node: '>=8'} - '@makeswift/controls@0.1.16': - resolution: {integrity: sha512-9e4jssZ/gglmOwxraqaCnwpLHQf8/7fPsq+zhTkWd4IPHtWE0hbkEhFyIZed7w5tRhdHRkqql6YHuNUUrkr7hg==} + '@makeswift/controls@0.1.18': + resolution: {integrity: sha512-U50K3ZvqbkFfsMMeqQcjrEYfK9/K2jZ2oo+RJCxQbydqS3+m6xXYfZQvgBKhAqE8gTzv4YN6GYFubE5AsF4BAA==} '@makeswift/next-plugin@0.6.1': resolution: {integrity: sha512-B8T7/69ENWf5ikaEcCP5xSAxoqEnK9kg1mEi51HczVsv7eQGQCHCqKy3Kyfv49IgxICXTPNt9ABmPr5zhE5NVw==} peerDependencies: next: ^13.4.0 || ^14.0.0 || ^15.0.0 - '@makeswift/prop-controllers@0.4.10': - resolution: {integrity: sha512-rqiUsEKe+3I0eyJNosTuef3Z0FG3UnPz6rOXY2G7SbKk1h+jyKGVulNPVmsafM9SzuMTfzZNtNL0ouDRsQym+Q==} + '@makeswift/prop-controllers@0.4.12': + resolution: {integrity: sha512-EJa4w+e2Q5WUR8NsLcyuCi38bJ6XJ1KBXBRaOFQnsJ9qMHrMY/01Lh5LtFrrlITwLzsxMzMMpGgv3M3tNkIPYQ==} - '@makeswift/runtime@0.26.4': - resolution: {integrity: sha512-hCmXv5h94j+MMOgPh7F0yteFvGOmSztlr6tTjrPEfxr9OCwr+gmE3crlXgO/SQW34NtQXVZLtg5ZcCoD6kFzlw==} + '@makeswift/runtime@0.28.5': + resolution: {integrity: sha512-l/MIyb6VXFYKm2F4f/qwCVOmL8qCMHFWboCDIuZJQt3pyO4H4hYVs6OxHaf3cdNMtPWV8D2Vr97Wkg3CWbmFoA==} engines: {node: '>=20.0.0'} peerDependencies: '@types/react': ^18.0.0 || ^19.0.0 @@ -9924,8 +9924,8 @@ packages: third-party-web@0.26.6: resolution: {integrity: sha512-GsjP92xycMK8qLTcQCacgzvffYzEqe29wyz3zdKVXlfRD5Kz1NatCTOZEeDaSd6uCZXvGd2CNVtQ89RNIhJWvA==} - third-party-web@0.29.0: - resolution: {integrity: sha512-nBDSJw5B7Sl1YfsATG2XkW5qgUPODbJhXw++BKygi9w6O/NKS98/uY/nR/DxDq2axEjL6halHW1v+jhm/j1DBQ==} + third-party-web@0.29.2: + resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==} through2@0.4.2: resolution: {integrity: sha512-45Llu+EwHKtAZYTPPVn3XZHBgakWMN3rokhEv5hu596XP+cNgplMg+Gj+1nmAvj+L0K7+N49zBKx5rah5u0QIQ==} @@ -13726,7 +13726,7 @@ snapshots: dependencies: '@lukeed/csprng': 1.1.0 - '@makeswift/controls@0.1.16': + '@makeswift/controls@0.1.18': dependencies: color: 3.2.1 css-box-model: 1.2.1 @@ -13743,13 +13743,13 @@ snapshots: next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) semver: 7.7.4 - '@makeswift/prop-controllers@0.4.10': + '@makeswift/prop-controllers@0.4.12': dependencies: - '@makeswift/controls': 0.1.16 + '@makeswift/controls': 0.1.18 ts-pattern: 5.9.0 zod: 3.25.51 - '@makeswift/runtime@0.26.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@makeswift/runtime@0.28.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@emotion/cache': 11.14.0 '@emotion/css': 11.13.5 @@ -13757,9 +13757,9 @@ snapshots: '@emotion/server': 11.11.0(@emotion/css@11.13.5) '@emotion/sheet': 1.4.0 '@emotion/utils': 1.4.2 - '@makeswift/controls': 0.1.16 + '@makeswift/controls': 0.1.18 '@makeswift/next-plugin': 0.6.1(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)) - '@makeswift/prop-controllers': 0.4.10 + '@makeswift/prop-controllers': 0.4.12 '@reduxjs/toolkit': 2.11.2(react@19.1.7) '@use-gesture/react': 10.3.1(react@19.1.7) color: 3.2.1 @@ -14446,7 +14446,7 @@ snapshots: '@paulirish/trace_engine@0.0.53': dependencies: legacy-javascript: 0.0.1 - third-party-web: 0.29.0 + third-party-web: 0.29.2 '@pkgjs/parseargs@0.11.0': optional: true @@ -22551,7 +22551,7 @@ snapshots: third-party-web@0.26.6: {} - third-party-web@0.29.0: {} + third-party-web@0.29.2: {} through2@0.4.2: dependencies: From 874e332b73b8a36c2d93ae4ec99d5dc00d7fb3e1 Mon Sep 17 00:00:00 2001 From: Tharaa <36555311+Tharaae@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:08:56 +1000 Subject: [PATCH 05/18] fix(product): BACK-400 Fix BO data on PDP for complex products (#3031) --- .changeset/fix-variant-bo-info-on-pdp.md | 5 +++ .../(default)/product/[slug]/page-data.ts | 14 ++++++-- .../(default)/product/[slug]/page.tsx | 35 +++++++------------ 3 files changed, 29 insertions(+), 25 deletions(-) create mode 100644 .changeset/fix-variant-bo-info-on-pdp.md diff --git a/.changeset/fix-variant-bo-info-on-pdp.md b/.changeset/fix-variant-bo-info-on-pdp.md new file mode 100644 index 0000000000..1c23426d31 --- /dev/null +++ b/.changeset/fix-variant-bo-info-on-pdp.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Display backorder information for variants on PDP. diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 02a8293735..1d65655b6c 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -337,9 +337,17 @@ export const getStreamableProduct = cache( const StreamableProductInventoryQuery = graphql( ` - query StreamableProductInventoryQuery($entityId: Int!) { + query StreamableProductInventoryQuery( + $entityId: Int! + $optionValueIds: [OptionValueId!] + $useDefaultOptionSelections: Boolean + ) { site { - product(entityId: $entityId) { + product( + entityId: $entityId + optionValueIds: $optionValueIds + useDefaultOptionSelections: $useDefaultOptionSelections + ) { sku inventory { hasVariantInventory @@ -363,7 +371,7 @@ const StreamableProductInventoryQuery = graphql( [ProductVariantsInventoryFragment], ); -type ProductInventoryVariables = VariablesOf; +type ProductInventoryVariables = VariablesOf; export const getStreamableProductInventory = cache( async (variables: ProductInventoryVariables, customerAccessToken?: string) => { diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index fab77ab2fc..6b4914234f 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -68,6 +68,17 @@ export async function generateMetadata({ params }: Props): Promise { export default async function Product({ params, searchParams }: Props) { const { locale, slug } = await params; + const options = await searchParams; + + const optionValueIds = Object.keys(options) + .map((option) => ({ + optionEntityId: Number(option), + valueEntityId: Number(options[option]), + })) + .filter( + (option) => !Number.isNaN(option.optionEntityId) && !Number.isNaN(option.valueEntityId), + ); + const customerAccessToken = await getSessionCustomerAccessToken(); const detachedWishlistFormId = 'product-add-to-wishlist-form'; @@ -91,17 +102,6 @@ export default async function Product({ params, searchParams }: Props) { } const streamableProduct = Streamable.from(async () => { - const options = await searchParams; - - const optionValueIds = Object.keys(options) - .map((option) => ({ - optionEntityId: Number(option), - valueEntityId: Number(options[option]), - })) - .filter( - (option) => !Number.isNaN(option.optionEntityId) && !Number.isNaN(option.valueEntityId), - ); - const variables = { entityId: Number(productId), optionValueIds, @@ -122,6 +122,8 @@ export default async function Product({ params, searchParams }: Props) { const streamableProductInventory = Streamable.from(async () => { const variables = { entityId: Number(productId), + optionValueIds, + useDefaultOptionSelections: true, }; const product = await getStreamableProductInventory(variables, customerAccessToken); @@ -155,17 +157,6 @@ export default async function Product({ params, searchParams }: Props) { }); const streamableProductPricingAndRelatedProducts = Streamable.from(async () => { - const options = await searchParams; - - const optionValueIds = Object.keys(options) - .map((option) => ({ - optionEntityId: Number(option), - valueEntityId: Number(options[option]), - })) - .filter( - (option) => !Number.isNaN(option.optionEntityId) && !Number.isNaN(option.valueEntityId), - ); - const currencyCode = await getPreferredCurrencyCode(); const variables = { From 4d511dc40e577b6c1a4b6bbbc72a02f66b0ef8d4 Mon Sep 17 00:00:00 2001 From: Aleksey Gurtovoy Date: Thu, 11 Jun 2026 13:40:49 -0500 Subject: [PATCH 06/18] fix(core): strip trailing slash when looking up localized Makeswift page (#2950) fixes locale switcher issue when running with TRAILING_SLASH=true --- .changeset/forty-trees-type.md | 5 +++++ .../primitives/navigation/_actions/localized-pathname.ts | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/forty-trees-type.md diff --git a/.changeset/forty-trees-type.md b/.changeset/forty-trees-type.md new file mode 100644 index 0000000000..b759676b25 --- /dev/null +++ b/.changeset/forty-trees-type.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-makeswift": patch +--- + +Fix 404 error on switching between localized Makeswift pages with locale-specific paths when TRAILING_SLASH=true diff --git a/core/vibes/soul/primitives/navigation/_actions/localized-pathname.ts b/core/vibes/soul/primitives/navigation/_actions/localized-pathname.ts index 0d74d3c1fe..4911fdf43e 100644 --- a/core/vibes/soul/primitives/navigation/_actions/localized-pathname.ts +++ b/core/vibes/soul/primitives/navigation/_actions/localized-pathname.ts @@ -25,8 +25,11 @@ const getPageInfo = async ({ const getPathname = (variants: Array<{ locale: string; path: string }>, locale: string) => variants.find((v) => v.locale === locale)?.path; +const stripTrailingSlash = (pathname: string) => + pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname; + export async function getLocalizedPathname({ - pathname, + pathname: inputPathname, activeLocale, targetLocale, }: { @@ -34,6 +37,9 @@ export async function getLocalizedPathname({ activeLocale: string | undefined; targetLocale: string; }) { + // Makeswift page pathnames are always stored without a trailing slash + const pathname = stripTrailingSlash(inputPathname); + // fallback to page info for default locale if there is no page info for active locale const fallbackPageInfo = activeLocale === defaultLocale From 3cec674f8fcbcf563e9c2d8015c35d96f4cd56e0 Mon Sep 17 00:00:00 2001 From: mfaris9 Date: Thu, 11 Jun 2026 15:39:13 -0400 Subject: [PATCH 07/18] feat(core): TRAC-188 out-of-the-box inclusive and exclusive tax price display (#3024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * TRAC-188: Out of the box Catalyst inclusive and exclusive of tax price display 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) * TRAC-188: trim changeset migration to high-level summary 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/trac-188-tax-display.md | 9 + .../(faceted)/brand/[slug]/page-data.ts | 3 + .../(default)/(faceted)/brand/[slug]/page.tsx | 3 + .../[slug]/_components/category-viewed.tsx | 17 +- .../(faceted)/category/[slug]/page-data.ts | 3 + .../(faceted)/category/[slug]/page.tsx | 11 +- .../(default)/(faceted)/search/page-data.ts | 3 + .../(default)/(faceted)/search/page.tsx | 3 + .../account/wishlists/[id]/page-data.ts | 9 +- .../(default)/account/wishlists/[id]/page.tsx | 23 +- .../(default)/account/wishlists/page-data.ts | 9 +- .../(default)/account/wishlists/page.tsx | 10 +- core/app/[locale]/(default)/cart/page-data.ts | 206 ++++++++++-------- core/app/[locale]/(default)/cart/page.tsx | 50 +++-- .../[locale]/(default)/compare/page-data.ts | 12 +- core/app/[locale]/(default)/compare/page.tsx | 21 +- core/app/[locale]/(default)/page-data.ts | 3 + core/app/[locale]/(default)/page.tsx | 4 + .../_components/product-schema/index.tsx | 19 +- .../_components/product-viewed/index.tsx | 15 +- .../(default)/product/[slug]/page-data.ts | 3 + .../(default)/product/[slug]/page.tsx | 23 +- .../(default)/wishlist/[token]/page-data.ts | 7 +- .../(default)/wishlist/[token]/page.tsx | 31 +-- core/client/fragments/pricing.ts | 30 ++- core/components/header/_actions/search.ts | 8 +- core/data-transformers/prices-transformer.ts | 81 ++++--- .../product-card-transformer.ts | 14 +- .../search-results-transformer.ts | 5 +- .../wishlists-transformer.ts | 24 +- core/lib/tax-pricing.ts | 22 ++ core/messages/en.json | 8 +- .../soul/primitives/price-label/index.tsx | 174 +++++++++------ .../soul/primitives/product-card/index.tsx | 8 +- core/vibes/soul/sections/cart/client.tsx | 44 ++-- 35 files changed, 608 insertions(+), 307 deletions(-) create mode 100644 .changeset/trac-188-tax-display.md create mode 100644 core/lib/tax-pricing.ts diff --git a/.changeset/trac-188-tax-display.md b/.changeset/trac-188-tax-display.md new file mode 100644 index 0000000000..479d1d4a79 --- /dev/null +++ b/.changeset/trac-188-tax-display.md @@ -0,0 +1,9 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Honor the merchant's Tax Display setting (`Inc.`, `Ex.`, or `Both`) from the BigCommerce control panel across PDP, PLP, search, compare, and home. When set to `Both`, prices render stacked with `(Inc. Tax)` and `(Ex. Tax)` labels, including sale strike-throughs per line. + +## Migration + +For forks that can't rebase cleanly: pricing was refactored end-to-end to support inc/ex tax variants and a `Both` mode (`PricingFragment`, `pricesTransformer`, `Price` types, page-data settings queries, analytics helpers). See PR #3024 for the full diff. diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts index 9bb605d215..f116f103fd 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts @@ -33,6 +33,9 @@ const BrandPageQuery = graphql(` reviews { enabled } + tax { + plp + } } } } diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx index 235b80b6c9..97142c4136 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -110,6 +110,8 @@ export default async function Brand(props: Props) { const productComparisonsEnabled = settings?.storefront.catalog?.productComparisonsEnabled ?? false; + const taxDisplay = settings?.tax?.plp; + const streamableFacetedSearch = Streamable.from(async () => { const searchParams = await props.searchParams; const currencyCode = await getPreferredCurrencyCode(); @@ -146,6 +148,7 @@ export default async function Brand(props: Props) { format, showOutOfStockMessage ? defaultOutOfStockMessage : undefined, showBackorderMessage, + taxDisplay, ); }); diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/_components/category-viewed.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/_components/category-viewed.tsx index 91d53aead5..eefe00b04b 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/_components/category-viewed.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/_components/category-viewed.tsx @@ -5,7 +5,9 @@ import { useEffect, useRef } from 'react'; import { FragmentOf } from '~/client/graphql'; import { ProductCardFragment } from '~/components/product-card/fragment'; +import { TaxDisplay } from '~/data-transformers/prices-transformer'; import { useAnalytics } from '~/lib/analytics/react'; +import { pickPricesForTaxDisplay } from '~/lib/tax-pricing'; import { getCategoryPageData } from '../page-data'; @@ -15,9 +17,10 @@ type productSearchItem = FragmentOf; interface Props { category: NonNullable; products: productSearchItem[]; + taxDisplay?: TaxDisplay | null; } -export const CategoryViewed = ({ category, products }: Props) => { +export const CategoryViewed = ({ category, products, taxDisplay }: Props) => { const isMounted = useRef(false); const analytics = useAnalytics(); @@ -28,21 +31,27 @@ export const CategoryViewed = ({ category, products }: Props) => { isMounted.current = true; + const firstProductPrices = products[0] + ? pickPricesForTaxDisplay(products[0], taxDisplay) + : undefined; + analytics?.navigation.categoryViewed({ id: category.entityId, name: category.name, - currency: products[0]?.prices?.price.currencyCode || 'USD', + currency: firstProductPrices?.price.currencyCode || 'USD', items: products.map((p) => { + const prices = pickPricesForTaxDisplay(p, taxDisplay); + return { id: p.entityId.toString(), name: p.name, brand: p.brand?.name, - price: p.prices?.price.value, + price: prices?.price.value, categories: removeEdgesAndNodes(category.breadcrumbs).map(({ name }) => name), }; }), }); - }, [analytics, category, products]); + }, [analytics, category, products, taxDisplay]); return null; }; diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts index 6c2c4633fe..98102fc633 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts @@ -51,6 +51,9 @@ const CategoryPageQuery = graphql( reviews { enabled } + tax { + plp + } } } } diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index ee143281b5..3c3c963080 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -125,6 +125,8 @@ export default async function Category(props: Props) { const productComparisonsEnabled = settings?.storefront.catalog?.productComparisonsEnabled ?? false; + const taxDisplay = settings?.tax?.plp; + const streamableFacetedSearch = Streamable.from(async () => { const searchParams = await props.searchParams; const currencyCode = await getPreferredCurrencyCode(); @@ -162,6 +164,7 @@ export default async function Category(props: Props) { format, showOutOfStockMessage ? defaultOutOfStockMessage : undefined, showBackorderMessage, + taxDisplay, ); }); @@ -284,7 +287,13 @@ export default async function Category(props: Props) { totalCount={streamableTotalCount} /> - {(search) => } + {(search) => ( + + )} ); diff --git a/core/app/[locale]/(default)/(faceted)/search/page-data.ts b/core/app/[locale]/(default)/(faceted)/search/page-data.ts index 37f571c49b..d81561a54a 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/search/page-data.ts @@ -24,6 +24,9 @@ const SearchPageQuery = graphql(` reviews { enabled } + tax { + plp + } } } } diff --git a/core/app/[locale]/(default)/(faceted)/search/page.tsx b/core/app/[locale]/(default)/(faceted)/search/page.tsx index bc86471c32..ef775462cf 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/search/page.tsx @@ -83,6 +83,8 @@ export default async function Search(props: Props) { const productComparisonsEnabled = settings?.storefront.catalog?.productComparisonsEnabled ?? false; + const taxDisplay = settings?.tax?.plp; + const streamableFacetedSearch = Streamable.from(async () => { const searchParams = await props.searchParams; const customerAccessToken = await getSessionCustomerAccessToken(); @@ -127,6 +129,7 @@ export default async function Search(props: Props) { format, showOutOfStockMessage ? defaultOutOfStockMessage : undefined, showBackorderMessage, + taxDisplay, ); }); diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts b/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts index ae14ab4173..6849ff0e55 100644 --- a/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts +++ b/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts @@ -26,6 +26,13 @@ const WishlistDetailsQuery = graphql( } } } + site { + settings { + tax { + plp + } + } + } } `, [WishlistPaginatedItemsFragment], @@ -55,5 +62,5 @@ export const getCustomerWishlist = cache(async (entityId: number, pagination: Pa return null; } - return wishlist; + return { wishlist, taxDisplay: response.data.site.settings?.tax?.plp }; }); diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx b/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx index 6ea2baa869..d92f963341 100644 --- a/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx +++ b/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx @@ -10,6 +10,7 @@ import { ExistingResultType } from '~/client/util'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { wishlistDetailsTransformer } from '~/data-transformers/wishlists-transformer'; import { redirect } from '~/i18n/routing'; +import { pickPricesForTaxDisplay } from '~/lib/tax-pricing'; import { isMobileUser } from '~/lib/user-agent'; import { removeWishlistItem } from '../_actions/remove-wishlist-item'; @@ -43,35 +44,37 @@ async function getWishlist( const entityId = Number(id); const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); const formatter = await getFormatter(); - const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + const result = await getCustomerWishlist(entityId, searchParamsParsed); - if (!wishlist) { + if (!result) { return redirect({ href: '/account/wishlists/', locale }); } - return wishlistDetailsTransformer(wishlist, t, pt, formatter); + return wishlistDetailsTransformer(result.wishlist, t, pt, formatter, result.taxDisplay); } const getAnalyticsData = async (id: string, searchParamsPromise: Promise) => { const entityId = Number(id); const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + const result = await getCustomerWishlist(entityId, searchParamsParsed); - if (!wishlist) { + if (!result) { return []; } - return removeEdgesAndNodes(wishlist.items) + return removeEdgesAndNodes(result.wishlist.items) .map(({ product }) => product) .filter((product) => product !== null) .map((product) => { + const prices = pickPricesForTaxDisplay(product, result.taxDisplay); + 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 ?? '', + price: prices?.price.value ?? 0, + currency: prices?.price.currencyCode ?? '', }; }); }; @@ -82,9 +85,9 @@ async function getPaginationInfo( ): Promise { const entityId = Number(id); const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + const result = await getCustomerWishlist(entityId, searchParamsParsed); - return pageInfoTransformer(wishlist?.items.pageInfo ?? defaultPageInfo); + return pageInfoTransformer(result?.wishlist.items.pageInfo ?? defaultPageInfo); } export default async function WishlistPage({ params, searchParams }: Props) { diff --git a/core/app/[locale]/(default)/account/wishlists/page-data.ts b/core/app/[locale]/(default)/account/wishlists/page-data.ts index 02a1c3f7b1..cf12685aaf 100644 --- a/core/app/[locale]/(default)/account/wishlists/page-data.ts +++ b/core/app/[locale]/(default)/account/wishlists/page-data.ts @@ -22,6 +22,13 @@ const WishlistsPageQuery = graphql( ...WishlistsFragment } } + site { + settings { + tax { + plp + } + } + } } `, [WishlistsFragment], @@ -50,5 +57,5 @@ export const getCustomerWishlists = cache(async ({ limit = 9, before, after }: P return null; } - return wishlists; + return { wishlists, taxDisplay: response.data.site.settings?.tax?.plp }; }); diff --git a/core/app/[locale]/(default)/account/wishlists/page.tsx b/core/app/[locale]/(default)/account/wishlists/page.tsx index 5fba791cfa..a8a09e4e81 100644 --- a/core/app/[locale]/(default)/account/wishlists/page.tsx +++ b/core/app/[locale]/(default)/account/wishlists/page.tsx @@ -41,22 +41,22 @@ async function listWishlists( ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); const formatter = await getFormatter(); - const wishlists = await getCustomerWishlists(searchParamsParsed); + const result = await getCustomerWishlists(searchParamsParsed); - if (!wishlists) { + if (!result) { return []; } - return wishlistsTransformer(wishlists, t, formatter); + return wishlistsTransformer(result.wishlists, t, formatter, result.taxDisplay); } async function getPaginationInfo( searchParamsPromise: Promise, ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlists = await getCustomerWishlists(searchParamsParsed); + const result = await getCustomerWishlists(searchParamsParsed); - return pageInfoTransformer(wishlists?.pageInfo ?? defaultPageInfo); + return pageInfoTransformer(result?.wishlists.pageInfo ?? defaultPageInfo); } export default async function Wishlists({ params, searchParams }: Props) { diff --git a/core/app/[locale]/(default)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts index 1d88b721e9..5ce2e3d50c 100644 --- a/core/app/[locale]/(default)/cart/page-data.ts +++ b/core/app/[locale]/(default)/cart/page-data.ts @@ -2,129 +2,142 @@ import { cache } from 'react'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; +import { PricingFragment } from '~/client/fragments/pricing'; import { graphql, VariablesOf } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; import { TAGS } from '~/client/tags'; -export const PhysicalItemFragment = graphql(` - fragment PhysicalItemFragment on CartPhysicalItem { - __typename - name - brand - sku - image { - url: urlTemplate(lossy: true) - } - entityId - quantity - productEntityId - variantEntityId - parentEntityId - listPrice { - currencyCode - value - } - salePrice { - currencyCode - value - } - discountedAmount { - currencyCode - value - } - selectedOptions { +export const PhysicalItemFragment = graphql( + ` + fragment PhysicalItemFragment on CartPhysicalItem { __typename - entityId name - ... on CartSelectedMultipleChoiceOption { - value - valueEntityId + brand + sku + image { + url: urlTemplate(lossy: true) } - ... on CartSelectedCheckboxOption { + entityId + quantity + productEntityId + variantEntityId + parentEntityId + listPrice { + currencyCode value - valueEntityId } - ... on CartSelectedNumberFieldOption { - number + salePrice { + currencyCode + value } - ... on CartSelectedMultiLineTextFieldOption { - text + discountedAmount { + currencyCode + value } - ... on CartSelectedTextFieldOption { - text + catalogProductWithOptionSelections { + ...PricingFragment } - ... on CartSelectedDateFieldOption { - date { - utc + selectedOptions { + __typename + entityId + name + ... on CartSelectedMultipleChoiceOption { + value + valueEntityId + } + ... on CartSelectedCheckboxOption { + value + valueEntityId + } + ... on CartSelectedNumberFieldOption { + number + } + ... on CartSelectedMultiLineTextFieldOption { + text + } + ... on CartSelectedTextFieldOption { + text + } + ... on CartSelectedDateFieldOption { + date { + utc + } } } + url + stockPosition { + backorderMessage + quantityOnHand + quantityBackordered + quantityOutOfStock + } } - url - stockPosition { - backorderMessage - quantityOnHand - quantityBackordered - quantityOutOfStock - } - } -`); + `, + [PricingFragment], +); -export const DigitalItemFragment = graphql(` - fragment DigitalItemFragment on CartDigitalItem { - __typename - name - brand - sku - image { - url: urlTemplate(lossy: true) - } - entityId - quantity - productEntityId - variantEntityId - parentEntityId - listPrice { - currencyCode - value - } - salePrice { - currencyCode - value - } - discountedAmount { - currencyCode - value - } - selectedOptions { +export const DigitalItemFragment = graphql( + ` + fragment DigitalItemFragment on CartDigitalItem { __typename - entityId name - ... on CartSelectedMultipleChoiceOption { - value - valueEntityId + brand + sku + image { + url: urlTemplate(lossy: true) } - ... on CartSelectedCheckboxOption { + entityId + quantity + productEntityId + variantEntityId + parentEntityId + listPrice { + currencyCode value - valueEntityId } - ... on CartSelectedNumberFieldOption { - number + salePrice { + currencyCode + value } - ... on CartSelectedMultiLineTextFieldOption { - text + discountedAmount { + currencyCode + value } - ... on CartSelectedTextFieldOption { - text + catalogProductWithOptionSelections { + ...PricingFragment } - ... on CartSelectedDateFieldOption { - date { - utc + selectedOptions { + __typename + entityId + name + ... on CartSelectedMultipleChoiceOption { + value + valueEntityId + } + ... on CartSelectedCheckboxOption { + value + valueEntityId + } + ... on CartSelectedNumberFieldOption { + number + } + ... on CartSelectedMultiLineTextFieldOption { + text + } + ... on CartSelectedTextFieldOption { + text + } + ... on CartSelectedDateFieldOption { + date { + utc + } } } + url } - url - } -`); + `, + [PricingFragment], +); export const CartGiftCertificateFragment = graphql(` fragment CartGiftCertificateFragment on CartGiftCertificate { @@ -238,6 +251,7 @@ const CartPageQuery = graphql( entityId version currencyCode + isTaxIncluded discountedAmount { ...MoneyFieldsFragment } diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index 22e2d295e5..6afbc1dde8 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -2,8 +2,10 @@ import { Metadata } from 'next'; import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; import { Streamable } from '@/vibes/soul/lib/streamable'; +import { Price } from '@/vibes/soul/primitives/price-label'; import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart'; import { CartAnalyticsProvider } from '~/app/[locale]/(default)/cart/_components/cart-analytics-provider'; +import { pricesTransformer } from '~/data-transformers/prices-transformer'; import { getCartId } from '~/lib/cart'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { exists } from '~/lib/utils'; @@ -97,6 +99,8 @@ export default async function Cart({ params }: Props) { ); } + const taxIncluded = cart.isTaxIncluded; + const lineItems = [ ...cart.lineItems.giftCertificates, ...cart.lineItems.physicalItems, @@ -166,18 +170,19 @@ export default async function Cart({ params }: Props) { } } + const catalogProduct = item.catalogProductWithOptionSelections; + const price: Price = + (catalogProduct && pricesTransformer(catalogProduct, format, taxIncluded ? 'INC' : 'EX')) ?? + format.number(item.listPrice.value, { + style: 'currency', + currency: item.listPrice.currencyCode, + }); + return { typename: item.__typename, id: item.entityId, quantity: item.quantity, - price: format.number(item.listPrice.value, { - style: 'currency', - currency: item.listPrice.currencyCode, - }), - salePrice: format.number(item.salePrice.value, { - style: 'currency', - currency: item.salePrice.currencyCode, - }), + price, subtitle: item.selectedOptions .map((option) => { switch (option.__typename) { @@ -277,6 +282,15 @@ export default async function Cart({ params }: Props) { currency: cart.currencyCode, }), totalLabel: t('CheckoutSummary.total'), + totalSubtitle: + taxIncluded && checkout?.taxTotal + ? t('CheckoutSummary.totalIncludesTax', { + tax: format.number(checkout.taxTotal.value, { + style: 'currency', + currency: cart.currencyCode, + }), + }) + : undefined, summaryItems: [ { label: t('CheckoutSummary.subTotal'), @@ -310,13 +324,15 @@ export default async function Cart({ params }: Props) { currency: cart.currencyCode, })}`, })), - checkout?.taxTotal && { - label: t('CheckoutSummary.tax'), - value: format.number(checkout.taxTotal.value, { - style: 'currency', - currency: cart.currencyCode, - }), - }, + !taxIncluded && checkout?.taxTotal + ? { + label: t('CheckoutSummary.tax'), + value: format.number(checkout.taxTotal.value, { + style: 'currency', + currency: cart.currencyCode, + }), + } + : null, ].filter(exists), }} checkoutAction={CHECKOUT_URL} @@ -393,7 +409,9 @@ export default async function Cart({ params }: Props) { } : undefined, showShippingForm, - shippingLabel: t('CheckoutSummary.Shipping.shipping'), + shippingLabel: taxIncluded + ? t('CheckoutSummary.Shipping.shippingExcludingTax') + : t('CheckoutSummary.Shipping.shipping'), addLabel: t('CheckoutSummary.Shipping.add'), changeLabel: t('CheckoutSummary.Shipping.change'), countryLabel: t('CheckoutSummary.Shipping.country'), diff --git a/core/app/[locale]/(default)/compare/page-data.ts b/core/app/[locale]/(default)/compare/page-data.ts index 9b0acac0f7..8afde0047a 100644 --- a/core/app/[locale]/(default)/compare/page-data.ts +++ b/core/app/[locale]/(default)/compare/page-data.ts @@ -13,6 +13,11 @@ const ComparedProductsQuery = graphql( ` query ComparedProductsQuery($entityIds: [Int!], $first: Int, $currencyCode: currencyCode) { site { + settings { + tax { + plp + } + } products(entityIds: $entityIds, first: $first) { edges { node { @@ -58,7 +63,7 @@ const ComparedProductsQuery = graphql( export const getComparedProducts = cache( async (productIds: number[] = [], currencyCode?: CurrencyCode, customerAccessToken?: string) => { if (productIds.length === 0) { - return []; + return { products: [], taxDisplay: undefined }; } const { data } = await client.fetch({ @@ -72,6 +77,9 @@ export const getComparedProducts = cache( fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, }); - return removeEdgesAndNodes(data.site.products); + return { + products: removeEdgesAndNodes(data.site.products), + taxDisplay: data.site.settings?.tax?.plp, + }; }, ); diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index 6a063f7dec..93c5635d92 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -9,6 +9,7 @@ import { getSessionCustomerAccessToken } from '~/auth'; import { pricesTransformer } from '~/data-transformers/prices-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMetadataAlternates } from '~/lib/seo/canonical'; +import { pickPricesForTaxDisplay } from '~/lib/tax-pricing'; import { addToCart } from './_actions/add-to-cart'; import { CompareAnalyticsProvider } from './_components/compare-analytics-provider'; @@ -64,7 +65,11 @@ export default async function Compare(props: Props) { const parsed = CompareParamsSchema.parse(searchParams); const productIds = parsed.ids?.filter((id) => !Number.isNaN(id)); - const products = await getComparedProducts(productIds, currencyCode, customerAccessToken); + const { products, taxDisplay } = await getComparedProducts( + productIds, + currencyCode, + customerAccessToken, + ); const format = await getFormatter(); return products.map((product) => ({ @@ -74,7 +79,7 @@ export default async function Compare(props: Props) { image: product.defaultImage ? { src: product.defaultImage.url, alt: product.defaultImage.altText } : undefined, - price: pricesTransformer(product.prices, format), + price: pricesTransformer(product, format, taxDisplay), subtitle: product.brand?.name ?? undefined, rating: product.reviewSummary.averageRating, description:
, @@ -97,16 +102,22 @@ export default async function Compare(props: Props) { const parsed = CompareParamsSchema.parse(searchParams); const productIds = parsed.ids?.filter((id) => !Number.isNaN(id)); - const products = await getComparedProducts(productIds, currencyCode, customerAccessToken); + const { products, taxDisplay } = await getComparedProducts( + productIds, + currencyCode, + customerAccessToken, + ); return products.map((product) => { + const prices = pickPricesForTaxDisplay(product, taxDisplay); + 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 ?? '', + price: prices?.price.value ?? 0, + currency: prices?.price.currencyCode ?? '', }; }); }); diff --git a/core/app/[locale]/(default)/page-data.ts b/core/app/[locale]/(default)/page-data.ts index ab78d520a6..d8c3bae445 100644 --- a/core/app/[locale]/(default)/page-data.ts +++ b/core/app/[locale]/(default)/page-data.ts @@ -70,6 +70,9 @@ const HomePageQuery = graphql( newsletter { showNewsletterSignup } + tax { + plp + } } } } diff --git a/core/app/[locale]/(default)/page.tsx b/core/app/[locale]/(default)/page.tsx index 77cf4db613..a9789c91a1 100644 --- a/core/app/[locale]/(default)/page.tsx +++ b/core/app/[locale]/(default)/page.tsx @@ -48,12 +48,14 @@ export default async function Home({ params }: Props) { const { defaultOutOfStockMessage, showOutOfStockMessage, showBackorderMessage } = data.site.settings?.inventory ?? {}; + const taxDisplay = data.site.settings?.tax?.plp; return productCardTransformer( featuredProducts, format, showOutOfStockMessage ? defaultOutOfStockMessage : undefined, showBackorderMessage, + taxDisplay, ); }); @@ -64,12 +66,14 @@ export default async function Home({ params }: Props) { const { defaultOutOfStockMessage, showOutOfStockMessage, showBackorderMessage } = data.site.settings?.inventory ?? {}; + const taxDisplay = data.site.settings?.tax?.plp; return productCardTransformer( newestProducts, format, showOutOfStockMessage ? defaultOutOfStockMessage : undefined, showBackorderMessage, + taxDisplay, ); }); diff --git a/core/app/[locale]/(default)/product/[slug]/_components/product-schema/index.tsx b/core/app/[locale]/(default)/product/[slug]/_components/product-schema/index.tsx index 161f7455be..8ebb0220d1 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/product-schema/index.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/product-schema/index.tsx @@ -2,14 +2,17 @@ import { Product as ProductSchemaType, WithContext } from 'schema-dts'; import { PricingFragment } from '~/client/fragments/pricing'; import { FragmentOf } from '~/client/graphql'; +import { TaxDisplay } from '~/data-transformers/prices-transformer'; +import { pickPricesForTaxDisplay } from '~/lib/tax-pricing'; import { ProductSchemaFragment } from './fragment'; interface Props { product: FragmentOf & FragmentOf; + taxDisplay?: TaxDisplay | null; } -export const ProductSchema = ({ product }: Props) => { +export const ProductSchema = ({ product, taxDisplay }: Props) => { /* TODO: use common default image when product has no images */ const image = product.defaultImage ? { image: product.defaultImage.url } : null; @@ -34,15 +37,17 @@ export const ProductSchema = ({ product }: Props) => { } : null; - const priceSpecification = product.prices + const prices = pickPricesForTaxDisplay(product, taxDisplay); + + const priceSpecification = prices ? { '@type': 'PriceSpecification' as const, - price: product.prices.price.value, - priceCurrency: product.prices.price.currencyCode, - ...(product.prices.priceRange.min.value !== product.prices.priceRange.max.value + price: prices.price.value, + priceCurrency: prices.price.currencyCode, + ...(prices.priceRange.min.value !== prices.priceRange.max.value ? { - minPrice: product.prices.priceRange.min.value, - maxPrice: product.prices.priceRange.max.value, + minPrice: prices.priceRange.min.value, + maxPrice: prices.priceRange.max.value, } : null), } diff --git a/core/app/[locale]/(default)/product/[slug]/_components/product-viewed/index.tsx b/core/app/[locale]/(default)/product/[slug]/_components/product-viewed/index.tsx index 84cb8b55cc..3a4f763741 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/product-viewed/index.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/product-viewed/index.tsx @@ -4,15 +4,18 @@ import { useEffect, useRef } from 'react'; import { PricingFragment } from '~/client/fragments/pricing'; import { FragmentOf } from '~/client/graphql'; +import { TaxDisplay } from '~/data-transformers/prices-transformer'; import { useAnalytics } from '~/lib/analytics/react'; +import { pickPricesForTaxDisplay } from '~/lib/tax-pricing'; import { ProductViewedFragment } from './fragment'; interface Props { product: FragmentOf & FragmentOf; + taxDisplay?: TaxDisplay | null; } -export const ProductViewed = ({ product }: Props) => { +export const ProductViewed = ({ product, taxDisplay }: Props) => { const isMounted = useRef(false); const analytics = useAnalytics(); @@ -23,20 +26,22 @@ export const ProductViewed = ({ product }: Props) => { isMounted.current = true; + const prices = pickPricesForTaxDisplay(product, taxDisplay); + analytics?.navigation.productViewed({ - value: product.prices?.price.value ?? 0, - currency: product.prices?.price.currencyCode ?? 'USD', + value: prices?.price.value ?? 0, + currency: prices?.price.currencyCode ?? 'USD', items: [ { id: product.entityId.toString(), name: product.name, brand: product.brand?.name, sku: product.sku, - price: product.prices?.salePrice?.value, + price: prices?.salePrice?.value, }, ], }); - }, [analytics, product]); + }, [analytics, product, taxDisplay]); return null; }; diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 1d65655b6c..c464df2cf5 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -178,6 +178,9 @@ const ProductQuery = graphql( display { showProductRating } + tax { + pdp + } } product(entityId: $entityId) { entityId diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 6b4914234f..aae6e565a0 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -96,6 +96,7 @@ export default async function Product({ params, searchParams }: Props) { const reviewsEnabled = Boolean(settings?.reviews.enabled && !settings.display.showProductRating); const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + const taxDisplay = settings?.tax?.pdp; if (!baseProduct) { return notFound(); @@ -176,7 +177,7 @@ export default async function Product({ params, searchParams }: Props) { return null; } - return pricesTransformer(product.prices, format) ?? null; + return pricesTransformer(product, format, taxDisplay) ?? null; }); const streamableImages = Streamable.from(async () => { @@ -496,7 +497,7 @@ export default async function Product({ params, searchParams }: Props) { const relatedProducts = removeEdgesAndNodes(product.relatedProducts); - return productCardTransformer(relatedProducts, format); + return productCardTransformer(relatedProducts, format, undefined, undefined, taxDisplay); }); const streamableMinQuantity = Streamable.from(async () => { @@ -522,8 +523,8 @@ export default async function Product({ params, searchParams }: Props) { name: extendedProduct.name, sku: extendedProduct.sku, brand: extendedProduct.brand?.name ?? '', - price: pricingProduct?.prices?.price.value ?? 0, - currency: pricingProduct?.prices?.price.currencyCode ?? '', + price: pricingProduct?.pricesIncludingTax?.price.value ?? 0, + currency: pricingProduct?.pricesIncludingTax?.price.currencyCode ?? '', }; }); @@ -627,10 +628,20 @@ export default async function Product({ params, searchParams }: Props) { {([extendedProduct, pricingProduct]) => ( <> )} diff --git a/core/app/[locale]/(default)/wishlist/[token]/page-data.ts b/core/app/[locale]/(default)/wishlist/[token]/page-data.ts index 4c3e56de3d..6c880c05d9 100644 --- a/core/app/[locale]/(default)/wishlist/[token]/page-data.ts +++ b/core/app/[locale]/(default)/wishlist/[token]/page-data.ts @@ -38,6 +38,11 @@ const PublicWishlistQuery = graphql( } } } + settings { + tax { + plp + } + } } } `, @@ -67,5 +72,5 @@ export const getPublicWishlist = cache(async (token: string, pagination: Paginat return null; } - return wishlist; + return { wishlist, taxDisplay: response.data.site.settings?.tax?.plp }; }); diff --git a/core/app/[locale]/(default)/wishlist/[token]/page.tsx b/core/app/[locale]/(default)/wishlist/[token]/page.tsx index e6fe52378f..866442e938 100644 --- a/core/app/[locale]/(default)/wishlist/[token]/page.tsx +++ b/core/app/[locale]/(default)/wishlist/[token]/page.tsx @@ -20,6 +20,7 @@ import { import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { publicWishlistDetailsTransformer } from '~/data-transformers/wishlists-transformer'; import { getMetadataAlternates } from '~/lib/seo/canonical'; +import { pickPricesForTaxDisplay } from '~/lib/tax-pricing'; import { isMobileUser } from '~/lib/user-agent'; import { getPublicWishlist } from './page-data'; @@ -45,13 +46,13 @@ async function getWishlist( ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParams); const formatter = await getFormatter(); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const result = await getPublicWishlist(token, searchParamsParsed); - if (!wishlist) { + if (!result) { return notFound(); } - return publicWishlistDetailsTransformer(wishlist, t, pt, formatter); + return publicWishlistDetailsTransformer(result.wishlist, t, pt, formatter, result.taxDisplay); } async function getPaginationInfo( @@ -59,9 +60,9 @@ async function getPaginationInfo( searchParams: Promise, ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParams); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const result = await getPublicWishlist(token, searchParamsParsed); - return pageInfoTransformer(wishlist?.items.pageInfo ?? defaultPageInfo); + return pageInfoTransformer(result?.wishlist.items.pageInfo ?? defaultPageInfo); } export async function generateMetadata({ params, searchParams }: Props): Promise { @@ -70,33 +71,37 @@ export async function generateMetadata({ params, searchParams }: Props): Promise // to make sure we aren't bypassing an existing cache just for the metadata generation. const searchParamsParsed = searchParamsCache.parse(await searchParams); const t = await getTranslations({ locale, namespace: 'PublicWishlist' }); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const result = await getPublicWishlist(token, searchParamsParsed); return { - title: wishlist?.name ?? t('title'), + title: result?.wishlist.name ?? t('title'), alternates: await getMetadataAlternates({ path: `/wishlist/${token}`, locale }), }; } const getAnalyticsData = async (token: string, searchParamsPromise: Promise) => { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const result = await getPublicWishlist(token, searchParamsParsed); - if (!wishlist) { + if (!result) { return []; } + const { wishlist, taxDisplay } = result; + return removeEdgesAndNodes(wishlist.items) .map(({ product }) => product) .filter((product) => product !== null) .map((product) => { + const prices = pickPricesForTaxDisplay(product, taxDisplay); + 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 ?? '', + price: prices?.price.value ?? 0, + currency: prices?.price.currencyCode ?? '', }; }); }; @@ -107,11 +112,11 @@ async function getBreadcrumbs( ): Promise { const t = await getTranslations('PublicWishlist'); const searchParamsParsed = searchParamsCache.parse(await searchParams); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const result = await getPublicWishlist(token, searchParamsParsed); return [ { href: '/', label: 'Home' }, - { href: '#', label: wishlist?.name ?? t('defaultName') }, + { href: '#', label: result?.wishlist.name ?? t('defaultName') }, ]; } diff --git a/core/client/fragments/pricing.ts b/core/client/fragments/pricing.ts index b9eb389807..73bdc06fac 100644 --- a/core/client/fragments/pricing.ts +++ b/core/client/fragments/pricing.ts @@ -2,7 +2,35 @@ import { graphql } from '../graphql'; export const PricingFragment = graphql(` fragment PricingFragment on Product { - prices(currencyCode: $currencyCode) { + pricesIncludingTax: prices(currencyCode: $currencyCode, includeTax: true) { + price { + value + currencyCode + } + basePrice { + value + currencyCode + } + retailPrice { + value + currencyCode + } + salePrice { + value + currencyCode + } + priceRange { + min { + value + currencyCode + } + max { + value + currencyCode + } + } + } + pricesExcludingTax: prices(currencyCode: $currencyCode, includeTax: false) { price { value currencyCode diff --git a/core/components/header/_actions/search.ts b/core/components/header/_actions/search.ts index 2918cfd556..5710f0ae03 100644 --- a/core/components/header/_actions/search.ts +++ b/core/components/header/_actions/search.ts @@ -23,6 +23,11 @@ const GetQuickSearchResultsQuery = graphql( $currencyCode: currencyCode ) { site { + settings { + tax { + plp + } + } search { searchProducts(filters: $filters) { products(first: 5) { @@ -92,10 +97,11 @@ export async function search( }); const { products } = response.data.site.search.searchProducts; + const taxDisplay = response.data.site.settings?.tax?.plp; return { lastResult: submission.reply(), - searchResults: await searchResultsTransformer(removeEdgesAndNodes(products)), + searchResults: await searchResultsTransformer(removeEdgesAndNodes(products), taxDisplay), emptyStateTitle, emptyStateSubtitle, }; diff --git a/core/data-transformers/prices-transformer.ts b/core/data-transformers/prices-transformer.ts index b175f1b996..d8e9f08ae0 100644 --- a/core/data-transformers/prices-transformer.ts +++ b/core/data-transformers/prices-transformer.ts @@ -1,51 +1,78 @@ import { ResultOf } from 'gql.tada'; import { getFormatter } from 'next-intl/server'; -import { Price } from '@/vibes/soul/primitives/price-label'; +import { Money, Price } from '@/vibes/soul/primitives/price-label'; import { PricingFragment } from '~/client/fragments/pricing'; import { ExistingResultType } from '~/client/util'; +type Pricing = ResultOf; +type Format = ExistingResultType; + +export type TaxDisplay = 'INC' | 'EX' | 'BOTH'; + +const toMoney = ( + inc: { value: number; currencyCode: string }, + ex: { value: number; currencyCode: string }, + format: Format, +): Money => ({ + inc: format.number(inc.value, { style: 'currency', currency: inc.currencyCode }), + ex: format.number(ex.value, { style: 'currency', currency: ex.currencyCode }), +}); + export const pricesTransformer = ( - prices: ResultOf['prices'], - format: ExistingResultType, + pricing: Pricing, + format: Format, + taxDisplay: TaxDisplay | null | undefined = 'EX', ): Price | undefined => { - if (!prices) { + const inc = pricing.pricesIncludingTax; + const ex = pricing.pricesExcludingTax; + + if (!inc || !ex) { return undefined; } - const isPriceRange = prices.priceRange.min.value !== prices.priceRange.max.value; - const isSalePrice = prices.salePrice?.value !== prices.basePrice?.value; + let mode: TaxDisplay = taxDisplay ?? 'EX'; + + // Tax-disabled stores, tax-exempt customer groups, and some B2B contexts return identical + // values for inc and ex. Degrade BOTH to a single-price render so we don't duplicate the + // same number on two lines. + if (mode === 'BOTH') { + const hasTaxDifference = + inc.price.value !== ex.price.value || + inc.basePrice?.value !== ex.basePrice?.value || + inc.salePrice?.value !== ex.salePrice?.value || + inc.priceRange.min.value !== ex.priceRange.min.value || + inc.priceRange.max.value !== ex.priceRange.max.value; + + if (!hasTaxDifference) { + mode = 'EX'; + } + } + + const isPriceRange = inc.priceRange.min.value !== inc.priceRange.max.value; + const isSalePrice = inc.salePrice?.value !== inc.basePrice?.value; if (isPriceRange) { return { type: 'range', - minValue: format.number(prices.priceRange.min.value, { - style: 'currency', - currency: prices.price.currencyCode, - }), - maxValue: format.number(prices.priceRange.max.value, { - style: 'currency', - currency: prices.price.currencyCode, - }), + min: toMoney(inc.priceRange.min, ex.priceRange.min, format), + max: toMoney(inc.priceRange.max, ex.priceRange.max, format), + mode, }; } - if (isSalePrice && prices.salePrice && prices.basePrice) { + if (isSalePrice && inc.salePrice && inc.basePrice && ex.salePrice && ex.basePrice) { return { type: 'sale', - previousValue: format.number(prices.basePrice.value, { - style: 'currency', - currency: prices.price.currencyCode, - }), - currentValue: format.number(prices.price.value, { - style: 'currency', - currency: prices.price.currencyCode, - }), + previous: toMoney(inc.basePrice, ex.basePrice, format), + current: toMoney(inc.price, ex.price, format), + mode, }; } - return format.number(prices.price.value, { - style: 'currency', - currency: prices.price.currencyCode, - }); + return { + type: 'plain', + money: toMoney(inc.price, ex.price, format), + mode, + }; }; diff --git a/core/data-transformers/product-card-transformer.ts b/core/data-transformers/product-card-transformer.ts index 719cec9c04..2b57e100ad 100644 --- a/core/data-transformers/product-card-transformer.ts +++ b/core/data-transformers/product-card-transformer.ts @@ -7,7 +7,7 @@ import { ExistingResultType } from '~/client/util'; import { ProductCardFragment } from '~/components/product-card/fragment'; import { WishlistItemProductFragment } from '~/components/wishlist/fragment'; -import { pricesTransformer } from './prices-transformer'; +import { pricesTransformer, TaxDisplay } from './prices-transformer'; const getInventoryMessage = ( product: ResultOf, @@ -51,6 +51,7 @@ export const singleProductCardTransformer = ( format: ExistingResultType, outOfStockMessage?: string, showBackorderMessage?: boolean, + taxDisplay?: TaxDisplay | null, ): Product => { return { id: product.entityId.toString(), @@ -59,7 +60,7 @@ export const singleProductCardTransformer = ( image: product.defaultImage ? { src: product.defaultImage.url, alt: product.defaultImage.altText } : undefined, - price: pricesTransformer(product.prices, format), + price: pricesTransformer(product, format, taxDisplay), subtitle: product.brand?.name ?? undefined, rating: product.reviewSummary.averageRating, numberOfReviews: product.reviewSummary.numberOfReviews, @@ -75,8 +76,15 @@ export const productCardTransformer = ( format: ExistingResultType, outOfStockMessage?: string, showBackorderMessage?: boolean, + taxDisplay?: TaxDisplay | null, ): Product[] => { return products.map((product) => - singleProductCardTransformer(product, format, outOfStockMessage, showBackorderMessage), + singleProductCardTransformer( + product, + format, + outOfStockMessage, + showBackorderMessage, + taxDisplay, + ), ); }; diff --git a/core/data-transformers/search-results-transformer.ts b/core/data-transformers/search-results-transformer.ts index c3832d5a8b..58eca1f553 100644 --- a/core/data-transformers/search-results-transformer.ts +++ b/core/data-transformers/search-results-transformer.ts @@ -4,10 +4,11 @@ import { getFormatter, getTranslations } from 'next-intl/server'; import { SearchResult } from '@/vibes/soul/primitives/navigation'; import { SearchProductFragment } from '~/components/header/_actions/fragment'; -import { pricesTransformer } from './prices-transformer'; +import { pricesTransformer, TaxDisplay } from './prices-transformer'; export async function searchResultsTransformer( searchProducts: Array>, + taxDisplay?: TaxDisplay | null, ): Promise { const format = await getFormatter(); const t = await getTranslations('Components.Header.Search'); @@ -16,7 +17,7 @@ export async function searchResultsTransformer( type: 'products', title: t('products'), products: searchProducts.map((product) => { - const price = pricesTransformer(product.prices, format); + const price = pricesTransformer(product, format, taxDisplay); return { id: product.entityId.toString(), diff --git a/core/data-transformers/wishlists-transformer.ts b/core/data-transformers/wishlists-transformer.ts index 855bc5a301..af79605763 100644 --- a/core/data-transformers/wishlists-transformer.ts +++ b/core/data-transformers/wishlists-transformer.ts @@ -13,6 +13,7 @@ import { WishlistsFragment, } from '~/components/wishlist/fragment'; +import { TaxDisplay } from './prices-transformer'; import { singleProductCardTransformer } from './product-card-transformer'; const getCtaLabel = ( @@ -54,6 +55,7 @@ function wishlistItemsTransformer( wishlistItems: ResultOf['items'], formatter: ExistingResultType, pt?: ExistingResultType>, + taxDisplay?: TaxDisplay | null, ): WishlistItem[] { return removeEdgesAndNodes(wishlistItems) .filter( @@ -70,7 +72,13 @@ function wishlistItemsTransformer( disabled: getCtaDisabled(item.product), } : undefined, - product: singleProductCardTransformer(item.product, formatter), + product: singleProductCardTransformer( + item.product, + formatter, + undefined, + undefined, + taxDisplay, + ), })); } @@ -79,6 +87,7 @@ function wishlistTransformer( t: ExistingResultType>, formatter: ExistingResultType, pt?: ExistingResultType>, + taxDisplay?: TaxDisplay | null, ): Wishlist { const totalItems = wishlist.items.collectionInfo?.totalItems ?? 0; @@ -93,7 +102,7 @@ function wishlistTransformer( privateLabel: t('Visibility.private'), }, href: `/account/wishlists/${wishlist.entityId}`, - items: wishlistItemsTransformer(wishlist.items, formatter, pt), + items: wishlistItemsTransformer(wishlist.items, formatter, pt, taxDisplay), totalItems: { value: totalItems, label: t('items', { count: totalItems }), @@ -105,19 +114,24 @@ export const wishlistsTransformer = ( wishlists: ResultOf, t: ExistingResultType>, formatter: ExistingResultType, + taxDisplay?: TaxDisplay | null, ): Wishlist[] => - removeEdgesAndNodes(wishlists).map((wishlist) => wishlistTransformer(wishlist, t, formatter)); + removeEdgesAndNodes(wishlists).map((wishlist) => + wishlistTransformer(wishlist, t, formatter, undefined, taxDisplay), + ); export const wishlistDetailsTransformer = ( wishlist: ResultOf, t: ExistingResultType>, pt: ExistingResultType>, formatter: ExistingResultType, -): Wishlist => wishlistTransformer(wishlist, t, formatter, pt); + taxDisplay?: TaxDisplay | null, +): Wishlist => wishlistTransformer(wishlist, t, formatter, pt, taxDisplay); export const publicWishlistDetailsTransformer = ( wishlist: ResultOf, t: ExistingResultType>, pt: ExistingResultType>, formatter: ExistingResultType, -): Wishlist => wishlistTransformer({ ...wishlist, isPublic: true }, t, formatter, pt); + taxDisplay?: TaxDisplay | null, +): Wishlist => wishlistTransformer({ ...wishlist, isPublic: true }, t, formatter, pt, taxDisplay); diff --git a/core/lib/tax-pricing.ts b/core/lib/tax-pricing.ts new file mode 100644 index 0000000000..594cc9a3bd --- /dev/null +++ b/core/lib/tax-pricing.ts @@ -0,0 +1,22 @@ +import { TaxDisplay } from '~/data-transformers/prices-transformer'; + +// Picks the prices field (`pricesIncludingTax` or `pricesExcludingTax`) that matches the +// merchant's Tax Display CP setting. Used by analytics, SEO/schema, and any other non-visual +// consumer that needs one canonical price per product. +// +// - `INC` -> `pricesIncludingTax` +// - `EX` -> `pricesExcludingTax` +// - `BOTH` -> `pricesIncludingTax` (headline convention) +// - undefined / null -> `pricesExcludingTax` (matches BC `prices(currencyCode)` default) +export function pickPricesForTaxDisplay< + T extends { pricesIncludingTax?: unknown; pricesExcludingTax?: unknown }, +>( + pricing: T, + taxDisplay: TaxDisplay | null | undefined, +): T['pricesIncludingTax'] | T['pricesExcludingTax'] { + if (taxDisplay === 'INC' || taxDisplay === 'BOTH') { + return pricing.pricesIncludingTax; + } + + return pricing.pricesExcludingTax; +} diff --git a/core/messages/en.json b/core/messages/en.json index 56a54ac058..f289eaf65a 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Discounts", "tax": "Tax", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Apply", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Shipping", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Add", "change": "Change", "cancel": "Cancel", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "Original price was {price}.", "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "range": "Price from {minValue} to {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Including Tax", + "excludingTaxFull": "Excluding Tax" } }, "GiftCertificates": { diff --git a/core/vibes/soul/primitives/price-label/index.tsx b/core/vibes/soul/primitives/price-label/index.tsx index bc20ee1393..f4eacfa4e9 100644 --- a/core/vibes/soul/primitives/price-label/index.tsx +++ b/core/vibes/soul/primitives/price-label/index.tsx @@ -1,19 +1,34 @@ import { clsx } from 'clsx'; import { useTranslations } from 'next-intl'; +export type TaxMode = 'INC' | 'EX' | 'BOTH'; + +export interface Money { + inc: string; + ex: string; +} + +export interface PricePlain { + type: 'plain'; + money: Money; + mode?: TaxMode; +} + export interface PriceRange { type: 'range'; - minValue: string; - maxValue: string; + min: Money; + max: Money; + mode?: TaxMode; } export interface PriceSale { type: 'sale'; - previousValue: string; - currentValue: string; + previous: Money; + current: Money; + mode?: TaxMode; } -export type Price = string | PriceRange | PriceSale; +export type Price = string | PricePlain | PriceRange | PriceSale; interface Props { className?: string; @@ -38,77 +53,98 @@ interface Props { export function PriceLabel({ className, colorScheme = 'light', price }: Props) { const t = useTranslations('Components.Price'); + const baseColorClass = { + light: 'text-[var(--price-light-text,hsl(var(--foreground)))]', + dark: 'text-[var(--price-dark-text,hsl(var(--background)))]', + }[colorScheme]; + if (typeof price === 'string') { + return {price}; + } + + const mode: TaxMode = price.mode ?? 'EX'; + + if (mode === 'BOTH') { + const includingTaxLabel = t('includingTax'); + const excludingTaxLabel = t('excludingTax'); + const includingTaxTooltip = t('includingTaxFull'); + const excludingTaxTooltip = t('excludingTaxFull'); + return ( - - {price} + + + {' '} + + {includingTaxLabel} + + + + {' '} + + {excludingTaxLabel} + + ); } - switch (price.type) { - case 'range': - return ( - - - {t('range', { minValue: price.minValue, maxValue: price.maxValue })} - - + const tax: 'inc' | 'ex' = mode === 'EX' ? 'ex' : 'inc'; + + return ( + + + + ); +} + +function PriceLine({ + price, + tax, + t, + colorScheme, + dim, +}: { + price: PricePlain | PriceRange | PriceSale; + tax: 'inc' | 'ex'; + t: ReturnType>; + colorScheme: 'light' | 'dark'; + dim?: boolean; +}) { + const dimClass = dim ? 'opacity-60' : undefined; + const pick = (m: Money) => m[tax]; + + if (price.type === 'plain') { + return {pick(price.money)}; + } + + if (price.type === 'range') { + return ( + <> + + {t('range', { minValue: pick(price.min), maxValue: pick(price.max) })} - ); - - case 'sale': - return ( - - {t('originalPrice', { price: price.previousValue })} - {' '} - {t('currentPrice', { price: price.currentValue })} - + - ); - - default: - return null; + + ); } + + const saleColorClass = { + light: 'text-[var(--price-light-sale-text,hsl(var(--foreground)))]', + dark: 'text-[var(--price-dark-sale-text,hsl(var(--background)))]', + }[colorScheme]; + + return ( + <> + {t('originalPrice', { price: pick(price.previous) })} + {' '} + {t('currentPrice', { price: pick(price.current) })} + + + ); } diff --git a/core/vibes/soul/primitives/product-card/index.tsx b/core/vibes/soul/primitives/product-card/index.tsx index 1571440471..d0632f020d 100644 --- a/core/vibes/soul/primitives/product-card/index.tsx +++ b/core/vibes/soul/primitives/product-card/index.tsx @@ -164,7 +164,13 @@ export function ProductCard({ {subtitle} )} - {price != null && } + {price != null && ( + + )} {showRating && typeof rating === 'number' && rating > 0 && ( )} diff --git a/core/vibes/soul/sections/cart/client.tsx b/core/vibes/soul/sections/cart/client.tsx index 8825d06b3b..6c803c2567 100644 --- a/core/vibes/soul/sections/cart/client.tsx +++ b/core/vibes/soul/sections/cart/client.tsx @@ -4,7 +4,6 @@ import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform import { parseWithZod } from '@conform-to/zod'; import { clsx } from 'clsx'; import { ArrowRight, GiftIcon, Minus, Plus, Trash2 } from 'lucide-react'; -import { useTranslations } from 'next-intl'; import { ComponentPropsWithoutRef, startTransition, @@ -16,6 +15,7 @@ import { import { useFormStatus } from 'react-dom'; import { Button } from '@/vibes/soul/primitives/button'; +import { Price, PriceLabel } from '@/vibes/soul/primitives/price-label'; import * as Skeleton from '@/vibes/soul/primitives/skeleton'; import { toast } from '@/vibes/soul/primitives/toaster'; import { @@ -49,8 +49,7 @@ export interface CartLineItem { image?: { alt: string; src: string }; subtitle: string; quantity: number; - price: string; - salePrice?: string; + price: Price; href?: string; inventoryMessages?: CartLineIteminventoryMessages; } @@ -82,6 +81,7 @@ export interface Cart { summaryItems: CartSummaryItem[]; total: string; totalLabel?: string; + totalSubtitle?: string; } interface CouponCode { @@ -405,12 +405,19 @@ export function CartClient({ removeLabel={giftCertificate.removeLabel} /> )} -
-
{cart.totalLabel ?? 'Total'}
- {isLineItemActionPending ? ( - - ) : ( -
{cart.total}
+
+
+
{cart.totalLabel ?? 'Total'}
+ {isLineItemActionPending ? ( + + ) : ( +
{cart.total}
+ )} +
+ {cart.totalSubtitle != null && ( +
+ {cart.totalSubtitle} +
)}
@@ -522,8 +529,6 @@ function CounterForm({ action: (payload: FormData) => void; onSubmit: (formData: FormData) => void; }) { - const t = useTranslations('Cart'); - const [form, fields] = useForm({ defaultValue: { id: lineItem.id }, shouldValidate: 'onBlur', @@ -543,7 +548,9 @@ function CounterForm({
- {lineItem.price} + {typeof lineItem.price === 'string' && ( + {lineItem.price} + )} {lineItem.quantity} @@ -571,18 +578,7 @@ function CounterForm({
- {lineItem.salePrice && lineItem.salePrice !== lineItem.price ? ( - - {t('originalPrice', { price: lineItem.price })} - {' '} - {t('currentPrice', { price: lineItem.salePrice })} - - - ) : ( - {lineItem.price} - )} +
{/* Counter */} From 5034ea32b684acf2a074c2e2d8876b35aeef0a15 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Fri, 12 Jun 2026 11:29:29 -0600 Subject: [PATCH 08/18] feat(core): TRAC-813 Gate analytics cookies behind shopper consent (#3046) When the store's cookie consent setting is enabled, the catalyst.visitorId and catalyst.visitId analytics cookies are no longer set or refreshed until the shopper grants measurement consent, and leftover cookies are deleted once consent is withdrawn. The currencyCode preference cookie now requires functionality consent. With no consent decision recorded yet, cookies are only allowed when the store does not require cookie consent. The proxy only starts visits on full page navigations, so granting consent mid-session now triggers a startVisit server action that sets the cookies and fires the server-side visitStartedEvent immediately. Server action POSTs no longer start visits in the proxy, which would otherwise duplicate the event fired by the action. Fixes TRAC-813 Co-authored-by: Claude --- ...6-gate-analytics-cookies-behind-consent.md | 5 ++ .../consent-manager/_actions/start-visit.ts | 52 ++++++++++++++++ core/components/consent-manager/index.tsx | 2 + .../start-visit-on-consent.tsx | 55 ++++++++++++++++ core/lib/analytics/bigcommerce/index.ts | 12 ++++ core/lib/consent-manager/has-consent-for.ts | 18 ++++++ core/lib/currency.ts | 7 +++ core/proxies/with-analytics-cookies.ts | 28 ++++++++- core/tests/ui/e2e/analytics-session.spec.ts | 62 ++++++++++++++++++- 9 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 .changeset/ltrac-846-gate-analytics-cookies-behind-consent.md create mode 100644 core/components/consent-manager/_actions/start-visit.ts create mode 100644 core/components/consent-manager/start-visit-on-consent.tsx create mode 100644 core/lib/consent-manager/has-consent-for.ts diff --git a/.changeset/ltrac-846-gate-analytics-cookies-behind-consent.md b/.changeset/ltrac-846-gate-analytics-cookies-behind-consent.md new file mode 100644 index 0000000000..c9705acfcc --- /dev/null +++ b/.changeset/ltrac-846-gate-analytics-cookies-behind-consent.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Gate `catalyst.visitorId`, `catalyst.visitId`, and `currencyCode` cookies behind shopper consent. The visitor and visit cookies now require measurement consent and the currency preference cookie requires functionality consent. When consent is absent, existing analytics cookies are deleted on the next request. When measurement consent is granted mid-session, a new `startVisit` server action sets the cookies and fires the server-side `visitStartedEvent` immediately rather than waiting for the next full-page navigation. diff --git a/core/components/consent-manager/_actions/start-visit.ts b/core/components/consent-manager/_actions/start-visit.ts new file mode 100644 index 0000000000..fcee1f1945 --- /dev/null +++ b/core/components/consent-manager/_actions/start-visit.ts @@ -0,0 +1,52 @@ +'use server'; + +import { headers } from 'next/headers'; +import { validate as isUuid, v4 as uuidv4 } from 'uuid'; + +import { + getVisitIdCookie, + getVisitorIdCookie, + setVisitIdCookie, + setVisitorIdCookie, +} from '~/lib/analytics/bigcommerce'; +import { sendVisitStartedEvent } from '~/lib/analytics/bigcommerce/data-events'; +import { getConsentCookie } from '~/lib/consent-manager/cookies/server'; + +// Starts an analytics visit after the shopper grants measurement consent. +// The proxy only starts visits on full-page navigations, so without this a +// visit granted mid-session wouldn't be recorded until the next hard reload. +// Idempotent: validates consent against the cookie and no-ops if a visit is +// already active (the proxy may have started one while handling this request). +export async function startVisit(): Promise { + const consent = await getConsentCookie(); + + if (!consent?.['c.measurement']) { + return; + } + + const existingVisitId = await getVisitIdCookie(); + + if (existingVisitId != null && isUuid(existingVisitId)) { + return; + } + + const existingVisitorId = await getVisitorIdCookie(); + const visitorId = existingVisitorId && isUuid(existingVisitorId) ? existingVisitorId : uuidv4(); + const visitId = uuidv4(); + + await setVisitorIdCookie(visitorId); + await setVisitIdCookie(visitId); + + const requestHeaders = await headers(); + + await sendVisitStartedEvent({ + initiator: { visitId, visitorId }, + request: { + // The action is invoked from the page the shopper accepted consent on, + // so the referer is the URL of the visit being started. + url: requestHeaders.get('referer') ?? '', + refererUrl: '', + userAgent: requestHeaders.get('user-agent') ?? '', + }, + }); +} diff --git a/core/components/consent-manager/index.tsx b/core/components/consent-manager/index.tsx index a6b6d21f03..f8ee4b555c 100644 --- a/core/components/consent-manager/index.tsx +++ b/core/components/consent-manager/index.tsx @@ -3,6 +3,7 @@ import type { PropsWithChildren } from 'react'; import { ConsentManagerDialog } from './consent-manager-dialog'; import { type C15tScripts, ConsentManagerProvider } from './consent-providers'; import { CookieBanner } from './cookie-banner'; +import { StartVisitOnConsent } from './start-visit-on-consent'; interface ConsentManagerProps extends PropsWithChildren { scripts: C15tScripts; @@ -18,6 +19,7 @@ export function ConsentManager({ }: ConsentManagerProps) { return ( + {children} diff --git a/core/components/consent-manager/start-visit-on-consent.tsx b/core/components/consent-manager/start-visit-on-consent.tsx new file mode 100644 index 0000000000..3f60546125 --- /dev/null +++ b/core/components/consent-manager/start-visit-on-consent.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useConsentManager } from '@c15t/nextjs/client'; +import { useEffect, useRef } from 'react'; + +import { getConsentCookie } from '~/lib/consent-manager/cookies/client'; + +import { startVisit } from './_actions/start-visit'; + +export function StartVisitOnConsent() { + const { consents, hasConsented } = useConsentManager(); + const isMeasurementGranted = hasConsented() && consents.measurement; + const wasMeasurementGranted = useRef(isMeasurementGranted); + + useEffect(() => { + const shouldStartVisit = isMeasurementGranted && !wasMeasurementGranted.current; + + wasMeasurementGranted.current = isMeasurementGranted; + + if (!shouldStartVisit) { + return; + } + + // c15t updates its React state before persisting the consent cookie. The + // startVisit action validates consent from the cookie server-side, so poll + // document.cookie directly — only dispatch once the cookie is readable. + const tryStartVisit = () => { + if (getConsentCookie()?.['c.measurement']) { + // eslint-disable-next-line no-console + void startVisit().catch(console.error); + + return true; + } + + return false; + }; + + if (tryStartVisit()) { + return; + } + + let attempts = 0; + const interval = setInterval(() => { + attempts += 1; + + if (tryStartVisit() || attempts >= 20) { + clearInterval(interval); + } + }, 100); + + return () => clearInterval(interval); + }, [isMeasurementGranted]); + + return null; +} diff --git a/core/lib/analytics/bigcommerce/index.ts b/core/lib/analytics/bigcommerce/index.ts index 4ca5eaa84b..1ca07f1bc3 100644 --- a/core/lib/analytics/bigcommerce/index.ts +++ b/core/lib/analytics/bigcommerce/index.ts @@ -22,6 +22,12 @@ export async function setVisitorIdCookie(visitorId: string): Promise { }); } +export async function deleteVisitorIdCookie(): Promise { + const cookieStore = await cookies(); + + cookieStore.delete(VISITOR_COOKIE_NAME); +} + export async function getVisitIdCookie(): Promise { const cookieStore = await cookies(); @@ -38,3 +44,9 @@ export async function setVisitIdCookie(visitId: string): Promise { maxAge: VISIT_DURATION, }); } + +export async function deleteVisitIdCookie(): Promise { + const cookieStore = await cookies(); + + cookieStore.delete(VISIT_COOKIE_NAME); +} diff --git a/core/lib/consent-manager/has-consent-for.ts b/core/lib/consent-manager/has-consent-for.ts new file mode 100644 index 0000000000..f2fde90c5a --- /dev/null +++ b/core/lib/consent-manager/has-consent-for.ts @@ -0,0 +1,18 @@ +import { getConsentCookie } from './cookies/server'; + +type ConsentCategory = 'functionality' | 'marketing' | 'measurement'; + +// Server-side check for whether cookies of a given consent category may be stored. +// The shopper's consent cookie is the source of truth. Absent a consent cookie, +// treats consent as not given — the client-side consent manager will write the +// cookie once the shopper decides (or c15t auto-grants when consent is disabled), +// and startVisit handles recording the visit at that point. +export async function hasConsentFor(category: ConsentCategory) { + const consent = await getConsentCookie(); + + if (consent) { + return consent[`c.${category}`]; + } + + return false; +} diff --git a/core/lib/currency.ts b/core/lib/currency.ts index 9a7ff6d593..1b765cbbae 100644 --- a/core/lib/currency.ts +++ b/core/lib/currency.ts @@ -4,6 +4,7 @@ import { cookies } from 'next/headers'; import type { CurrencyCode } from '~/components/header/fragment'; import { CurrencyCodeSchema } from '~/components/header/schema'; +import { hasConsentFor } from '~/lib/consent-manager/has-consent-for'; export async function getPreferredCurrencyCode(): Promise { const cookieStore = await cookies(); @@ -19,6 +20,12 @@ export async function getPreferredCurrencyCode(): Promise { + // The currency preference is a functionality cookie; without consent the + // selected currency still applies to the cart but the preference isn't stored. + if (!(await hasConsentFor('functionality'))) { + return; + } + const cookieStore = await cookies(); cookieStore.set('currencyCode', currencyCode, { diff --git a/core/proxies/with-analytics-cookies.ts b/core/proxies/with-analytics-cookies.ts index f77e43b609..b22ca88a00 100644 --- a/core/proxies/with-analytics-cookies.ts +++ b/core/proxies/with-analytics-cookies.ts @@ -1,12 +1,15 @@ import { validate as isUuid, v4 as uuidv4 } from 'uuid'; import { + deleteVisitIdCookie, + deleteVisitorIdCookie, getVisitIdCookie, getVisitorIdCookie, setVisitIdCookie, setVisitorIdCookie, } from '~/lib/analytics/bigcommerce'; import { sendVisitStartedEvent } from '~/lib/analytics/bigcommerce/data-events'; +import { hasConsentFor } from '~/lib/consent-manager/has-consent-for'; import { ProxyFactory } from './compose-proxies'; @@ -15,8 +18,25 @@ export const withAnalyticsCookies: ProxyFactory = (next) => { const existingVisitorId = await getVisitorIdCookie(); const existingVisitId = await getVisitIdCookie(); + if (!(await hasConsentFor('measurement'))) { + // No measurement consent: never set or refresh analytics cookies, and + // remove any left over from before consent was withdrawn. Once the + // shopper grants consent, the startVisit server action (triggered by the + // consent manager) creates the cookies and fires the visit event. + if (existingVisitorId != null) { + await deleteVisitorIdCookie(); + } + + if (existingVisitId != null) { + await deleteVisitIdCookie(); + } + + return next(request, event); + } + const isPrefetch = request.headers.get('Next-Router-Prefetch') === '1'; const isRSC = request.headers.get('RSC') === '1'; + const isServerAction = request.headers.get('Next-Action') !== null; const visitorId = existingVisitorId && isUuid(existingVisitorId) ? existingVisitorId : uuidv4(); @@ -27,15 +47,17 @@ export const withAnalyticsCookies: ProxyFactory = (next) => { if (hasValidVisit) { // Sliding window: refresh the TTL on every request await setVisitIdCookie(existingVisitId); - } else if (!isPrefetch && !isRSC) { + } else if (!isPrefetch && !isRSC && !isServerAction) { // New visit on a real navigation: create cookie and fire event const visitId = uuidv4(); await setVisitIdCookie(visitId); event.waitUntil(recordNewVisit(request, visitorId, visitId)); } - // Prefetch/RSC with no valid visit: skip entirely so the - // subsequent real navigation properly detects a new visit. + // Prefetch/RSC/server-action with no valid visit: skip entirely so the + // subsequent real navigation properly detects a new visit. Server actions + // must not start visits: cookies set here aren't visible to the action + // handler, so the startVisit action would otherwise fire a duplicate event. return next(request, event); }; diff --git a/core/tests/ui/e2e/analytics-session.spec.ts b/core/tests/ui/e2e/analytics-session.spec.ts index e2e37f9b76..2502ab38de 100644 --- a/core/tests/ui/e2e/analytics-session.spec.ts +++ b/core/tests/ui/e2e/analytics-session.spec.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { testEnv } from '~/tests/environment'; import { expect, test } from '~/tests/fixtures'; const CookieSchema = z.object({ @@ -8,8 +9,25 @@ const CookieSchema = z.object({ expires: z.number().optional(), }); +// The consent cookie uses c15t's compact format; only granted categories are present. +const acceptedConsentCookie = () => ({ + name: 'c15t-consent', + value: `i.t:${Date.now()},c.necessary:1,c.functionality:1,c.marketing:1,c.measurement:1`, + url: testEnv.PLAYWRIGHT_TEST_BASE_URL, +}); + +const declinedConsentCookie = () => ({ + name: 'c15t-consent', + value: `i.t:${Date.now()},c.necessary:1`, + url: testEnv.PLAYWRIGHT_TEST_BASE_URL, +}); + test.describe('Analytics cookies proxy', () => { - test('sets visitorId and visitId cookies on first visit', async ({ page, context }) => { + test('sets visitorId and visitId cookies on first visit with measurement consent', async ({ + page, + context, + }) => { + await context.addCookies([acceptedConsentCookie()]); await page.goto('/'); const cookies = await context.cookies(); @@ -23,10 +41,11 @@ test.describe('Analytics cookies proxy', () => { }); test('visitId cookie has correct expiry', async ({ page, context }) => { + await context.addCookies([acceptedConsentCookie()]); await page.goto('/'); const cookies = await context.cookies(); - const visitId = cookies.find((c) => c.name === 'visitId'); + const visitId = cookies.find((c) => c.name === 'catalyst.visitId'); const parsed = CookieSchema.safeParse(visitId); if (parsed.success && parsed.data.expires) { @@ -39,10 +58,11 @@ test.describe('Analytics cookies proxy', () => { }); test('visitorId cookie has correct expiry', async ({ page, context }) => { + await context.addCookies([acceptedConsentCookie()]); await page.goto('/'); const cookies = await context.cookies(); - const visitorId = cookies.find((c) => c.name === 'visitorId'); + const visitorId = cookies.find((c) => c.name === 'catalyst.visitorId'); const parsed = CookieSchema.safeParse(visitorId); if (parsed.success && parsed.data.expires) { @@ -55,6 +75,7 @@ test.describe('Analytics cookies proxy', () => { }); test('creates a new visitId after expiry', async ({ page, context }) => { + await context.addCookies([acceptedConsentCookie()]); await page.goto('/'); let cookies = await context.cookies(); @@ -62,6 +83,7 @@ test.describe('Analytics cookies proxy', () => { // Simulate expiry by clearing the visitId cookie await context.clearCookies(); + await context.addCookies([acceptedConsentCookie()]); await page.reload(); cookies = await context.cookies(); @@ -71,4 +93,38 @@ test.describe('Analytics cookies proxy', () => { expect(newVisitId).toBeDefined(); expect(newVisitId).not.toBe(oldVisitId); }); + + test('does not set analytics cookies when measurement consent is declined', async ({ + page, + context, + }) => { + await context.addCookies([declinedConsentCookie()]); + await page.goto('/'); + + const cookies = await context.cookies(); + + expect(cookies.find((c) => c.name === 'catalyst.visitorId')).toBeUndefined(); + expect(cookies.find((c) => c.name === 'catalyst.visitId')).toBeUndefined(); + }); + + test('removes analytics cookies when measurement consent is withdrawn', async ({ + page, + context, + }) => { + await context.addCookies([acceptedConsentCookie()]); + await page.goto('/'); + + let cookies = await context.cookies(); + + expect(cookies.find((c) => c.name === 'catalyst.visitorId')).toBeDefined(); + expect(cookies.find((c) => c.name === 'catalyst.visitId')).toBeDefined(); + + await context.addCookies([declinedConsentCookie()]); + await page.reload(); + + cookies = await context.cookies(); + + expect(cookies.find((c) => c.name === 'catalyst.visitorId')).toBeUndefined(); + expect(cookies.find((c) => c.name === 'catalyst.visitId')).toBeUndefined(); + }); }); From 1ab2c821b72ab8c2ad5db67f72bd139195945abb Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Tue, 16 Jun 2026 16:11:33 -0600 Subject: [PATCH 09/18] fix(core): TRAC-814 Make session token cookies expire with the session (#3047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authjs.session-token and authjs.anonymous-session-token must be session cookies (no Expires attribute) to comply with Essential cookie classification requirements. - Remove maxAge from authjs.anonymous-session-token in anonymousSignIn - Strip Expires from authjs.session-token in the withAuth middleware, where Auth.js refreshes the JWT on every non-API request - Strip Expires from authjs.session-token in the auth route handler, where Auth.js sets the cookie on sign-in (not covered by middleware) - Wrap signIn and updateSession in auth/index.ts with patchSessionTokenCookies, which re-sets session token cookies via cookies().set() without Expires after Auth.js sets them — this covers the server action code path where Auth.js calls cookies().set(name, value, { expires: ... }) directly Auth.js v5 unconditionally sets Expires on session cookies with no config option to disable it. signIn/updateSession use the Next.js cookies() API rather than response headers, so those must be intercepted separately. Fixes TRAC-814 Co-authored-by: Claude --- .../trac-814-session-cookie-compliance.md | 33 +++++++++++++ core/auth/anonymous-session.ts | 3 -- core/auth/index.ts | 49 ++++++++++++++++++- core/proxies/with-auth.ts | 26 +++++++++- 4 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 .changeset/trac-814-session-cookie-compliance.md diff --git a/.changeset/trac-814-session-cookie-compliance.md b/.changeset/trac-814-session-cookie-compliance.md new file mode 100644 index 0000000000..4d8808ae1c --- /dev/null +++ b/.changeset/trac-814-session-cookie-compliance.md @@ -0,0 +1,33 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Make `authjs.session-token` and `authjs.anonymous-session-token` browser-session cookies (no `Expires` attribute) to satisfy Essential cookie classification requirements. + +## What changed + +**Anonymous session token:** `anonymousSignIn` no longer sets `maxAge` on the cookie. Without it, Next.js omits `Max-Age`/`Expires` and the cookie becomes a session cookie that the browser drops when it closes. + +**Auth session token:** Auth.js v5 unconditionally writes `Expires` on the session token cookie and provides no config option to suppress it. Two post-processing steps strip the attribute: + +- `proxies/with-auth.ts` — strips `Expires` from `Set-Cookie` response headers on every page request. +- `auth/index.ts` — wraps `signIn` and `updateSession` to re-set the cookie via `cookies().set()` without `Expires` immediately after Auth.js writes it, covering the sign-in and session-update paths that middleware cannot reach. + +`Max-Age=0` (used by Auth.js for cookie deletion on sign-out) is intentionally left intact. + +## Migration + +**If you have a custom `maxAge` on `anonymousSignIn`:** The default 7-day `maxAge` has been removed. If your app relies on anonymous sessions persisting across browser restarts, add it back in your own `anonymousSignIn` call: + +```ts +cookieJar.set(anonymousCookieName, jwt, { + httpOnly: true, + sameSite: 'lax', + secure: useSecureCookies, + maxAge: 60 * 60 * 24 * 7, // restore 7-day persistence if needed +}); +``` + +**If you already have your own `Expires`-stripping workaround:** Remove it. The middleware regex in `with-auth.ts` and the `patchSessionTokenCookies` wrapper in `auth/index.ts` now handle this centrally. Leaving both in place will cause redundant cookie writes. + +**If you import `signIn` or `updateSession` directly from `auth/index.ts`:** No change needed — the signatures are identical. The exports are now thin async wrappers that call the Auth.js originals and then patch any session token cookies written during the call. diff --git a/core/auth/anonymous-session.ts b/core/auth/anonymous-session.ts index c5a4f98ca9..3ed7fee2b6 100644 --- a/core/auth/anonymous-session.ts +++ b/core/auth/anonymous-session.ts @@ -31,9 +31,6 @@ export const anonymousSignIn = async (user: Partial = { cartId: n cookieJar.set(`${cookiePrefix}${anonymousCookieName}`, jwt, { secure: useSecureCookies, sameSite: 'lax', - // We set the maxAge to 7 days as a good default for anonymous sessions. - // This can be adjusted based on your application's needs. - maxAge: 60 * 60 * 24 * 7, // 7 days httpOnly: true, }); }; diff --git a/core/auth/index.ts b/core/auth/index.ts index bf902fa2a8..e7a7aa8a6e 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -1,4 +1,5 @@ import { decodeJwt } from 'jose'; +import { cookies } from 'next/headers'; import NextAuth, { type NextAuthConfig, User } from 'next-auth'; import 'next-auth/jwt'; import CredentialsProvider from 'next-auth/providers/credentials'; @@ -184,6 +185,7 @@ const config = { session: { strategy: 'jwt', }, + cookies: {}, pages: { signIn: '/login', signOut: '/logout', @@ -318,7 +320,52 @@ const config = { ], } satisfies NextAuthConfig; -export const { handlers, auth, signIn, signOut, unstable_update: updateSession } = NextAuth(config); +const SESSION_TOKEN_NAME_RE = /^(__Secure-)?authjs\.session-token(\.\d+)?$/; + +// Auth.js sets Expires on session token cookies via cookies().set() when signIn/updateSession +// are called from server actions. Re-set those cookies without Expires so they comply with +// Essential classification (session cookies that expire when the browser closes). +async function patchSessionTokenCookies() { + const cookieJar = await cookies(); + + cookieJar.getAll().forEach(({ name, value }) => { + if (SESSION_TOKEN_NAME_RE.test(name) && value) { + cookieJar.set(name, value, { + httpOnly: true, + sameSite: 'lax' as const, + path: '/', + secure: name.startsWith('__Secure-'), + }); + } + }); +} + +const { + handlers, + auth, + signIn: authSignIn, + signOut, + unstable_update: authUpdateSession, +} = NextAuth(config); + +export { handlers, auth, signOut }; + +export const signIn = async (...args: Parameters) => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return await authSignIn(...args); + } finally { + await patchSessionTokenCookies(); + } +}; + +export const updateSession = async (...args: Parameters) => { + try { + return await authUpdateSession(...args); + } finally { + await patchSessionTokenCookies(); + } +}; export const getSessionCustomerAccessToken = async () => { try { diff --git a/core/proxies/with-auth.ts b/core/proxies/with-auth.ts index 2371adcffb..472f48a2f5 100644 --- a/core/proxies/with-auth.ts +++ b/core/proxies/with-auth.ts @@ -6,14 +6,36 @@ import { type ProxyFactory } from './compose-proxies'; // Path matcher for any routes that require authentication const protectedPathPattern = new URLPattern({ pathname: `{/:locale}?/(account)/*` }); +const SESSION_TOKEN_COOKIE_RE = /^(__Secure-)?authjs\.session-token(\.\d+)?=/; function redirectToLogin(url: string) { return NextResponse.redirect(new URL('/login', url), { status: 302 }); } +// Auth.js always sets Expires on session cookies based on session.maxAge. We strip it here +// so the cookie becomes a session cookie (expires when the browser closes), which is required +// for Essential cookie classification compliance. +function stripSessionCookieExpiry(response: Response): Response { + const setCookies = response.headers.getSetCookie(); + const stripped = setCookies.map((cookie) => + SESSION_TOKEN_COOKIE_RE.test(cookie) + ? cookie.replace(/;\s*(?:expires|max-age)=[^;]+/gi, '') + : cookie, + ); + + if (stripped.every((c, i) => c === setCookies[i])) return response; + + const modified = new Response(response.body, response); + + modified.headers.delete('set-cookie'); + stripped.forEach((cookie) => modified.headers.append('set-cookie', cookie)); + + return modified; +} + export const withAuth: ProxyFactory = (next) => { return async (request, event) => { - return auth(async (req) => { + const response = await auth(async (req) => { const anonymousSession = await getAnonymousSession(); const isProtectedRoute = protectedPathPattern.test(req.nextUrl.toString().toLowerCase()); const isGetRequest = req.method === 'GET'; @@ -45,5 +67,7 @@ export const withAuth: ProxyFactory = (next) => { // Continue the proxy chain return next(req, event); })(request, event); + + return response ? stripSessionCookieExpiry(response) : response; }; }; From 226f2f3b49de4d6988105b40253b46a3a083ad4a Mon Sep 17 00:00:00 2001 From: bc-svc-local <102379007+bc-svc-local@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:05:24 +0200 Subject: [PATCH 10/18] Update translations (#3048) * feat(other): LOCAL-1444 delivery translation * chore(core): create translations patch --------- Co-authored-by: bc-svc-local --- .changeset/translations-patch-9edc1998.md | 5 +++++ core/messages/da.json | 8 +++++++- core/messages/de.json | 8 +++++++- core/messages/es-419.json | 8 +++++++- core/messages/es-AR.json | 8 +++++++- core/messages/es-CL.json | 8 +++++++- core/messages/es-CO.json | 8 +++++++- core/messages/es-LA.json | 8 +++++++- core/messages/es-MX.json | 8 +++++++- core/messages/es-PE.json | 8 +++++++- core/messages/es.json | 8 +++++++- core/messages/fr.json | 8 +++++++- core/messages/it.json | 8 +++++++- core/messages/ja.json | 8 +++++++- core/messages/nl.json | 8 +++++++- core/messages/no.json | 8 +++++++- core/messages/pl.json | 8 +++++++- core/messages/pt-BR.json | 8 +++++++- core/messages/pt.json | 8 +++++++- core/messages/sv.json | 8 +++++++- 20 files changed, 138 insertions(+), 19 deletions(-) create mode 100644 .changeset/translations-patch-9edc1998.md diff --git a/.changeset/translations-patch-9edc1998.md b/.changeset/translations-patch-9edc1998.md new file mode 100644 index 0000000000..ad17b2636a --- /dev/null +++ b/.changeset/translations-patch-9edc1998.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Update translations. diff --git a/core/messages/da.json b/core/messages/da.json index 50fb258fb1..a518deed8e 100644 --- a/core/messages/da.json +++ b/core/messages/da.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Rabatter", "tax": "Moms", + "totalIncludesTax": "Includes {tax} tax", "total": "I alt", "CouponCode": { "apply": "Anvend", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Forsendelse", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Tilføj", "change": "Ændr", "cancel": "Annuller", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "Den oprindelige pris var {price}.", "currentPrice": "Den aktuelle pris er {price}.", - "range": "Pris fra {minValue} til {maxValue}." + "range": "Pris fra {minValue} til {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Inklusiv skat", + "excludingTaxFull": "Eksklusiv skat" } }, "GiftCertificates": { diff --git a/core/messages/de.json b/core/messages/de.json index ef7a39e78e..547bd599ac 100644 --- a/core/messages/de.json +++ b/core/messages/de.json @@ -379,6 +379,7 @@ "subTotal": "Zwischensumme", "discounts": "Rabatte", "tax": "Steuern", + "totalIncludesTax": "Includes {tax} tax", "total": "Summe", "CouponCode": { "apply": "Anwenden", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Versand", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Hinzufügen", "change": "Ändern", "cancel": "Abbrechen", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "Der ursprüngliche Preis war {price}.", "currentPrice": "Der aktuelle Preis beträgt {price}.", - "range": "Preis von {minValue} bis {maxValue}." + "range": "Preis von {minValue} bis {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Einschließlich Steuern", + "excludingTaxFull": "Ohne Steuern" } }, "GiftCertificates": { diff --git a/core/messages/es-419.json b/core/messages/es-419.json index 2287e9f7b4..661f2eea33 100644 --- a/core/messages/es-419.json +++ b/core/messages/es-419.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", - "range": "Precio de {minValue} a {maxValue}." + "range": "Precio de {minValue} a {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-AR.json b/core/messages/es-AR.json index 2287e9f7b4..661f2eea33 100644 --- a/core/messages/es-AR.json +++ b/core/messages/es-AR.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", - "range": "Precio de {minValue} a {maxValue}." + "range": "Precio de {minValue} a {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-CL.json b/core/messages/es-CL.json index 2287e9f7b4..661f2eea33 100644 --- a/core/messages/es-CL.json +++ b/core/messages/es-CL.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", - "range": "Precio de {minValue} a {maxValue}." + "range": "Precio de {minValue} a {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-CO.json b/core/messages/es-CO.json index 2287e9f7b4..661f2eea33 100644 --- a/core/messages/es-CO.json +++ b/core/messages/es-CO.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", - "range": "Precio de {minValue} a {maxValue}." + "range": "Precio de {minValue} a {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-LA.json b/core/messages/es-LA.json index 2287e9f7b4..661f2eea33 100644 --- a/core/messages/es-LA.json +++ b/core/messages/es-LA.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", - "range": "Precio de {minValue} a {maxValue}." + "range": "Precio de {minValue} a {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-MX.json b/core/messages/es-MX.json index 2287e9f7b4..661f2eea33 100644 --- a/core/messages/es-MX.json +++ b/core/messages/es-MX.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", - "range": "Precio de {minValue} a {maxValue}." + "range": "Precio de {minValue} a {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-PE.json b/core/messages/es-PE.json index 2287e9f7b4..661f2eea33 100644 --- a/core/messages/es-PE.json +++ b/core/messages/es-PE.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", - "range": "Precio de {minValue} a {maxValue}." + "range": "Precio de {minValue} a {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es.json b/core/messages/es.json index 7b0665c645..1f8e4d6707 100644 --- a/core/messages/es.json +++ b/core/messages/es.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Añadir", "change": "Cambiar", "cancel": "Cancelar", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", - "range": "Precios entre {minValue} y {maxValue}." + "range": "Precios entre {minValue} y {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Con impuestos", + "excludingTaxFull": "Excluye impuestos" } }, "GiftCertificates": { diff --git a/core/messages/fr.json b/core/messages/fr.json index 3f98e9eb7d..2b5e088d40 100644 --- a/core/messages/fr.json +++ b/core/messages/fr.json @@ -379,6 +379,7 @@ "subTotal": "Sous-total", "discounts": "Remises", "tax": "Taxes", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Appliquer", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Livraison", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Ajouter", "change": "Modifier", "cancel": "Annuler", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "Le prix initial était de {price}.", "currentPrice": "Le prix actuel est de {price}.", - "range": "Prix de {minValue} à {maxValue}." + "range": "Prix de {minValue} à {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Toutes taxes comprises", + "excludingTaxFull": "Hors taxes" } }, "GiftCertificates": { diff --git a/core/messages/it.json b/core/messages/it.json index 5d1ba665dd..483c245e94 100644 --- a/core/messages/it.json +++ b/core/messages/it.json @@ -379,6 +379,7 @@ "subTotal": "Subtotale", "discounts": "Sconti", "tax": "Tasse", + "totalIncludesTax": "Includes {tax} tax", "total": "Totale", "CouponCode": { "apply": "Applica", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Spedizioni", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Aggiungi", "change": "Cambia", "cancel": "Annulla", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "Il prezzo originale era {price}.", "currentPrice": "Il prezzo corrente è {price}.", - "range": "Prezzo da {minValue} a {maxValue}." + "range": "Prezzo da {minValue} a {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Tasse incluse", + "excludingTaxFull": "Tasse escluse" } }, "GiftCertificates": { diff --git a/core/messages/ja.json b/core/messages/ja.json index 67e5295889..c160fd9af9 100644 --- a/core/messages/ja.json +++ b/core/messages/ja.json @@ -379,6 +379,7 @@ "subTotal": "小計", "discounts": "割引", "tax": "税金", + "totalIncludesTax": "Includes {tax} tax", "total": "合計", "CouponCode": { "apply": "適用", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "配送", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "追加", "change": "変更する", "cancel": "キャンセル", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "元の価格は{price}でした。", "currentPrice": "現在の価格は{price}です。", - "range": "価格は{minValue}から{maxValue}までです。" + "range": "価格は{minValue}から{maxValue}までです。", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "税込", + "excludingTaxFull": "税別" } }, "GiftCertificates": { diff --git a/core/messages/nl.json b/core/messages/nl.json index 0f45743ace..6dab8372a7 100644 --- a/core/messages/nl.json +++ b/core/messages/nl.json @@ -379,6 +379,7 @@ "subTotal": "Subtotaal", "discounts": "Kortingen", "tax": "Belasting", + "totalIncludesTax": "Includes {tax} tax", "total": "Totaal", "CouponCode": { "apply": "Toepassen", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Verzending", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Toevoegen", "change": "Wijzigen", "cancel": "Annuleren", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "De oorspronkelijke prijs was {price}.", "currentPrice": "De huidige prijs is {price}.", - "range": "Prijs van {minValue} tot {maxValue}." + "range": "Prijs van {minValue} tot {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Inclusief btw", + "excludingTaxFull": "Exclusief btw" } }, "GiftCertificates": { diff --git a/core/messages/no.json b/core/messages/no.json index 7f82d8ba41..4acb6b13aa 100644 --- a/core/messages/no.json +++ b/core/messages/no.json @@ -379,6 +379,7 @@ "subTotal": "Delsum", "discounts": "Rabatter", "tax": "Avgift", + "totalIncludesTax": "Includes {tax} tax", "total": "Totalbeløp", "CouponCode": { "apply": "Bruk", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Frakt", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Legg til", "change": "Endre", "cancel": "Avbryt", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "Opprinnelig pris var {price}.", "currentPrice": "Nåværende pris er {price}.", - "range": "Pris fra {minValue} til {maxValue}." + "range": "Pris fra {minValue} til {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Inkludert avgifter", + "excludingTaxFull": "Ekskludert avgifter" } }, "GiftCertificates": { diff --git a/core/messages/pl.json b/core/messages/pl.json index f63bba4b20..fc4acbadd4 100644 --- a/core/messages/pl.json +++ b/core/messages/pl.json @@ -379,6 +379,7 @@ "subTotal": "Suma cząstkowa", "discounts": "Rabaty", "tax": "Podatek", + "totalIncludesTax": "Includes {tax} tax", "total": "Razem", "CouponCode": { "apply": "Zgłoś się na", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Wysyłka", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Dodaj", "change": "Zmiana", "cancel": "Anuluj", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "Original price was {price}.", "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "range": "Price from {minValue} to {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Z uwzględnieniem podatku", + "excludingTaxFull": "Bez podatku" } }, "GiftCertificates": { diff --git a/core/messages/pt-BR.json b/core/messages/pt-BR.json index 310d39d385..a64b00e988 100644 --- a/core/messages/pt-BR.json +++ b/core/messages/pt-BR.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descontos", "tax": "Imposto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Envio", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Adicionar", "change": "Alterar", "cancel": "Cancelar", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "Original price was {price}.", "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "range": "Price from {minValue} to {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Incluir impostos", + "excludingTaxFull": "Excluir impostos" } }, "GiftCertificates": { diff --git a/core/messages/pt.json b/core/messages/pt.json index 310d39d385..a64b00e988 100644 --- a/core/messages/pt.json +++ b/core/messages/pt.json @@ -379,6 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descontos", "tax": "Imposto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Envio", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Adicionar", "change": "Alterar", "cancel": "Cancelar", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "Original price was {price}.", "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "range": "Price from {minValue} to {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Incluir impostos", + "excludingTaxFull": "Excluir impostos" } }, "GiftCertificates": { diff --git a/core/messages/sv.json b/core/messages/sv.json index e9d72b8dce..e04ee06106 100644 --- a/core/messages/sv.json +++ b/core/messages/sv.json @@ -379,6 +379,7 @@ "subTotal": "Delsumma", "discounts": "Rabatter", "tax": "Beskatta", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Tillämpa", @@ -389,6 +390,7 @@ }, "Shipping": { "shipping": "Frakt", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Lägg till", "change": "Ändra", "cancel": "Annullera", @@ -648,7 +650,11 @@ "Price": { "originalPrice": "Ursprungligt pris var {price}.", "currentPrice": "Nuvarande pris är {price}.", - "range": "Pris från {minValue} till {maxValue}." + "range": "Pris från {minValue} till {maxValue}.", + "includingTax": "(Inc. Tax)", + "excludingTax": "(Ex. Tax)", + "includingTaxFull": "Inklusive skatt", + "excludingTaxFull": "Exklusive skatt" } }, "GiftCertificates": { From 854aab54d530c71a07360c753cc687fd3944325b Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 22 Jun 2026 09:37:56 -0500 Subject: [PATCH 11/18] LTRAC-467: chore(create-catalyst) - Deprecate the `integration` command (#3055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Print a deprecation warning when `create-catalyst integration` is invoked, and hide it from `--help` (registered with { hidden: true }). The command builds integration patches by diffing git tags, which won't work once Catalyst projects ship as tarballs (no git history); it will be replaced by `catalyst upgrade`. Still functional for now — full removal is a future major. Refs LTRAC-467 Co-authored-by: Claude --- .changeset/ltrac-467-deprecate-integration.md | 5 +++++ packages/create-catalyst/src/commands/integration.ts | 9 +++++++++ packages/create-catalyst/src/index.ts | 4 +++- 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changeset/ltrac-467-deprecate-integration.md diff --git a/.changeset/ltrac-467-deprecate-integration.md b/.changeset/ltrac-467-deprecate-integration.md new file mode 100644 index 0000000000..7f9f04081e --- /dev/null +++ b/.changeset/ltrac-467-deprecate-integration.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/create-catalyst": minor +--- + +Deprecate the `create-catalyst integration` command. It now prints a deprecation warning when invoked and is hidden from `--help`. The command builds integration patches by diffing git tags, which won't work once Catalyst projects are distributed as tarballs (no git history) — it will be replaced by the forthcoming `catalyst upgrade` command. The command still functions for now; full removal will follow in a future major version. diff --git a/packages/create-catalyst/src/commands/integration.ts b/packages/create-catalyst/src/commands/integration.ts index 4ce5780e67..6fadaa5046 100644 --- a/packages/create-catalyst/src/commands/integration.ts +++ b/packages/create-catalyst/src/commands/integration.ts @@ -1,5 +1,6 @@ import { Command } from '@commander-js/extra-typings'; import { exec as execCb } from 'child_process'; +import { colorize } from 'consola/utils'; import { parse } from 'dotenv'; import { outputFileSync, writeJsonSync } from 'fs-extra/esm'; import kebabCase from 'lodash.kebabcase'; @@ -22,6 +23,14 @@ export const integration = new Command('integration') .argument('', 'Formatted name of the integration') .option('--commit-hash ', 'Override integration source branch with a specific commit hash') .action(async (integrationNameRaw, options) => { + console.warn( + colorize( + 'yellow', + '⚠ `create-catalyst integration` is deprecated and will be replaced by the ' + + '`catalyst upgrade` command.', + ), + ); + // @todo check for integration name conflicts const integrationName = z.string().transform(kebabCase).parse(integrationNameRaw); diff --git a/packages/create-catalyst/src/index.ts b/packages/create-catalyst/src/index.ts index 7f8c31a563..5c2023287a 100644 --- a/packages/create-catalyst/src/index.ts +++ b/packages/create-catalyst/src/index.ts @@ -19,7 +19,9 @@ program .description('A command line tool to create a new Catalyst project.') .addCommand(create, { isDefault: true }) .addCommand(init) - .addCommand(integration) + // Deprecated: hidden from help so new users don't discover it; still runnable + // (with a deprecation warning) until it's removed. See LTRAC-467. + .addCommand(integration, { hidden: true }) .addCommand(telemetry) .hook('preAction', telemetryPreHook) .hook('postAction', telemetryPostHook); From 2c997312623af93f67a1d202aed3a9467bb125a0 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 22 Jun 2026 11:55:10 -0500 Subject: [PATCH 12/18] LTRAC-466: feat(core) - Track Catalyst version in a dedicated package.json field (#3056) Add a `catalyst` field to core/package.json (`catalyst.version` + `catalyst.ref`) so the true Catalyst version is tracked independently of the top-level `version`, which merchants may repurpose (deploy tags, Docker labels). The backend user-agent now reports `catalyst.version`, falling back to `version` for projects created before the field existed. A release-time sync script (.github/scripts/sync-catalyst-version.mts), wired into the Changesets `version` step via the `changeset:version` root script, keeps `catalyst.version`/`catalyst.ref` in lockstep with each version bump. Refs LTRAC-466 Co-authored-by: Claude --- .../ltrac-466-catalyst-version-field.md | 5 ++ .../__tests__/sync-catalyst-version.test.mts | 63 +++++++++++++++++++ .github/scripts/sync-catalyst-version.mts | 58 +++++++++++++++++ .github/workflows/changesets-release.yml | 3 + core/package.json | 4 ++ core/user-agent.ts | 13 +++- package.json | 3 +- 7 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 .changeset/ltrac-466-catalyst-version-field.md create mode 100644 .github/scripts/__tests__/sync-catalyst-version.test.mts create mode 100644 .github/scripts/sync-catalyst-version.mts diff --git a/.changeset/ltrac-466-catalyst-version-field.md b/.changeset/ltrac-466-catalyst-version-field.md new file mode 100644 index 0000000000..88126ac679 --- /dev/null +++ b/.changeset/ltrac-466-catalyst-version-field.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Add a `catalyst` field to `core/package.json` (`catalyst.version` and `catalyst.ref`) that tracks the true Catalyst version independently of the top-level `version`, which merchants may repurpose for their own deploy tagging. The backend user-agent now reports `catalyst.version` (falling back to `version` for projects created before the field existed), and the release pipeline keeps the field in sync on each version bump. diff --git a/.github/scripts/__tests__/sync-catalyst-version.test.mts b/.github/scripts/__tests__/sync-catalyst-version.test.mts new file mode 100644 index 0000000000..f2eb87d644 --- /dev/null +++ b/.github/scripts/__tests__/sync-catalyst-version.test.mts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { buildCatalystField, syncCatalystField } from '../sync-catalyst-version.mts'; + +describe('buildCatalystField', () => { + it('derives version and ref from name + version', () => { + assert.deepEqual( + buildCatalystField({ name: '@bigcommerce/catalyst-core', version: '1.7.0' }), + { version: '1.7.0', ref: '@bigcommerce/catalyst-core@1.7.0' }, + ); + }); +}); + +describe('syncCatalystField', () => { + it('adds the catalyst field mirroring version when absent', () => { + const input = `${JSON.stringify( + { name: '@bigcommerce/catalyst-core', version: '1.8.0' }, + null, + 2, + )}\n`; + + const output = JSON.parse(syncCatalystField(input)); + + assert.deepEqual(output.catalyst, { + version: '1.8.0', + ref: '@bigcommerce/catalyst-core@1.8.0', + }); + }); + + it('updates a stale catalyst field to the current version', () => { + const input = `${JSON.stringify( + { + name: '@bigcommerce/catalyst-core', + version: '2.0.0', + catalyst: { version: '1.9.0', ref: '@bigcommerce/catalyst-core@1.9.0' }, + }, + null, + 2, + )}\n`; + + const output = JSON.parse(syncCatalystField(input)); + + assert.deepEqual(output.catalyst, { + version: '2.0.0', + ref: '@bigcommerce/catalyst-core@2.0.0', + }); + }); + + it('is idempotent and preserves the trailing newline', () => { + const input = `${JSON.stringify( + { + name: '@bigcommerce/catalyst-core', + version: '1.7.0', + catalyst: { version: '1.7.0', ref: '@bigcommerce/catalyst-core@1.7.0' }, + }, + null, + 2, + )}\n`; + + assert.equal(syncCatalystField(input), input); + }); +}); diff --git a/.github/scripts/sync-catalyst-version.mts b/.github/scripts/sync-catalyst-version.mts new file mode 100644 index 0000000000..1043fcf7a9 --- /dev/null +++ b/.github/scripts/sync-catalyst-version.mts @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface CorePackageJson { + name: string; + version: string; + catalyst?: { version: string; ref: string }; + [key: string]: unknown; +} + +// `catalyst.version` mirrors the released version; `catalyst.ref` is the git tag +// (`@bigcommerce/catalyst-core@`) the version corresponds to, consumed +// by the future `catalyst upgrade` command. +export function buildCatalystField( + pkg: Pick, +): { version: string; ref: string } { + return { version: pkg.version, ref: `${pkg.name}@${pkg.version}` }; +} + +// Returns the package.json text with its `catalyst` field synced to `version`. +// Re-serializes with the canonical 2-space + trailing-newline format, so a run +// where nothing changed produces no diff. +export function syncCatalystField(packageJsonText: string): string { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const pkg = JSON.parse(packageJsonText) as CorePackageJson; + + pkg.catalyst = buildCatalystField(pkg); + + return `${JSON.stringify(pkg, null, 2)}\n`; +} + +function main(): void { + const corePackageJsonPath = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../core/package.json", + ); + + const updated = syncCatalystField(readFileSync(corePackageJsonPath, "utf-8")); + + writeFileSync(corePackageJsonPath, updated); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const { catalyst } = JSON.parse(updated) as CorePackageJson; + + console.log( + `Synced core/package.json catalyst field → ${catalyst?.version} (${catalyst?.ref})`, + ); +} + +const isMain = process.argv[1] === fileURLToPath(import.meta.url); + +if (isMain) { + main(); +} diff --git a/.github/workflows/changesets-release.yml b/.github/workflows/changesets-release.yml index 38d63d889c..25b5a899f3 100644 --- a/.github/workflows/changesets-release.yml +++ b/.github/workflows/changesets-release.yml @@ -43,6 +43,9 @@ jobs: id: changesets uses: changesets/action@v1 with: + # Runs `changeset version`, then syncs core/package.json's `catalyst` + # field (version + git ref) to the freshly-bumped version. + version: pnpm changeset:version publish: pnpm exec changeset publish title: "Version Packages (`${{ github.ref_name }}`)" commit: "Version Packages (`${{ github.ref_name }}`)" diff --git a/core/package.json b/core/package.json index 70f787d247..d214c9db08 100644 --- a/core/package.json +++ b/core/package.json @@ -2,6 +2,10 @@ "name": "@bigcommerce/catalyst-core", "description": "BigCommerce Catalyst is a Next.js starter kit for building headless BigCommerce storefronts.", "version": "1.7.0", + "catalyst": { + "version": "1.7.0", + "ref": "@bigcommerce/catalyst-core@1.7.0" + }, "private": true, "engines": { "node": ">=24.0.0" diff --git a/core/user-agent.ts b/core/user-agent.ts index 76dbc2272b..8aa1689d00 100644 --- a/core/user-agent.ts +++ b/core/user-agent.ts @@ -2,8 +2,17 @@ import packageInfo from './package.json'; const commitSha = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA; -const { name, version } = packageInfo; +const { name } = packageInfo; + +// Prefer the dedicated `catalyst.version` field, which always reflects the true +// Catalyst release even if a merchant repurposes the top-level `version` (Docker +// labels, deploy tags, etc.). The `?? version` fallback is intentional for +// projects whose package.json predates the `catalyst` field. TypeScript infers +// `catalyst` as always-present from this repo's package.json (so it flags the +// guard as unnecessary), but it can be absent at runtime in older projects. +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition +const catalystVersion = packageInfo.catalyst?.version ?? packageInfo.version; // Add package name and version to the user agent // Used as part of API client instantiation -export const backendUserAgent = `${name}/${version}${commitSha ? ` (${commitSha})` : ''}`; +export const backendUserAgent = `${name}/${catalystVersion}${commitSha ? ` (${commitSha})` : ''}`; diff --git a/package.json b/package.json index 74e3ef7161..64f132fd52 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "lint": "dotenv -e .env.local -- turbo lint", "test": "turbo run test", "test:scripts": "node --test .github/scripts/__tests__/*.test.mts", - "typecheck": "turbo typecheck" + "typecheck": "turbo typecheck", + "changeset:version": "changeset version && node .github/scripts/sync-catalyst-version.mts" }, "devDependencies": { "@changesets/changelog-github": "^0.5.1", From 94c503e1f5a2d51ed81c741f5d92556116dac4c9 Mon Sep 17 00:00:00 2001 From: bc-svc-local <102379007+bc-svc-local@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:05:01 +0200 Subject: [PATCH 13/18] Update translations (#3058) * feat(other): LOCAL-1444 delivery translation * chore(core): create translations patch --------- Co-authored-by: bc-svc-local --- .changeset/translations-patch-53bc0eb5.md | 5 +++++ core/messages/da.json | 8 ++++---- core/messages/de.json | 8 ++++---- core/messages/es-419.json | 8 ++++---- core/messages/es-AR.json | 8 ++++---- core/messages/es-CL.json | 8 ++++---- core/messages/es-CO.json | 8 ++++---- core/messages/es-LA.json | 8 ++++---- core/messages/es-MX.json | 8 ++++---- core/messages/es-PE.json | 8 ++++---- core/messages/es.json | 8 ++++---- core/messages/fr.json | 8 ++++---- core/messages/it.json | 8 ++++---- core/messages/ja.json | 8 ++++---- core/messages/nl.json | 8 ++++---- core/messages/no.json | 8 ++++---- core/messages/sv.json | 8 ++++---- 17 files changed, 69 insertions(+), 64 deletions(-) create mode 100644 .changeset/translations-patch-53bc0eb5.md diff --git a/.changeset/translations-patch-53bc0eb5.md b/.changeset/translations-patch-53bc0eb5.md new file mode 100644 index 0000000000..ad17b2636a --- /dev/null +++ b/.changeset/translations-patch-53bc0eb5.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Update translations. diff --git a/core/messages/da.json b/core/messages/da.json index a518deed8e..242dd7d454 100644 --- a/core/messages/da.json +++ b/core/messages/da.json @@ -379,7 +379,7 @@ "subTotal": "Subtotal", "discounts": "Rabatter", "tax": "Moms", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Inklusiv {tax} moms", "total": "I alt", "CouponCode": { "apply": "Anvend", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Forsendelse", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Forsendelse (ekskl. moms)", "add": "Tilføj", "change": "Ændr", "cancel": "Annuller", @@ -651,8 +651,8 @@ "originalPrice": "Den oprindelige pris var {price}.", "currentPrice": "Den aktuelle pris er {price}.", "range": "Pris fra {minValue} til {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Inkl. moms)", + "excludingTax": "(Ekskl. moms)", "includingTaxFull": "Inklusiv skat", "excludingTaxFull": "Eksklusiv skat" } diff --git a/core/messages/de.json b/core/messages/de.json index 547bd599ac..698a58f62b 100644 --- a/core/messages/de.json +++ b/core/messages/de.json @@ -379,7 +379,7 @@ "subTotal": "Zwischensumme", "discounts": "Rabatte", "tax": "Steuern", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Inklusive {tax} Steuern", "total": "Summe", "CouponCode": { "apply": "Anwenden", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Versand", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Versand (ohne Steuer)", "add": "Hinzufügen", "change": "Ändern", "cancel": "Abbrechen", @@ -651,8 +651,8 @@ "originalPrice": "Der ursprüngliche Preis war {price}.", "currentPrice": "Der aktuelle Preis beträgt {price}.", "range": "Preis von {minValue} bis {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Inkl. Steuer)", + "excludingTax": "(Ohne Steuer)", "includingTaxFull": "Einschließlich Steuern", "excludingTaxFull": "Ohne Steuern" } diff --git a/core/messages/es-419.json b/core/messages/es-419.json index 661f2eea33..4fac3125a3 100644 --- a/core/messages/es-419.json +++ b/core/messages/es-419.json @@ -379,7 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Envío", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -651,8 +651,8 @@ "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", "range": "Precio de {minValue} a {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Inc. Impuestos)", + "excludingTax": "(Ej. Impuestos)", "includingTaxFull": "Incluye impuestos", "excludingTaxFull": "Excluyendo impuestos" } diff --git a/core/messages/es-AR.json b/core/messages/es-AR.json index 661f2eea33..4fac3125a3 100644 --- a/core/messages/es-AR.json +++ b/core/messages/es-AR.json @@ -379,7 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Envío", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -651,8 +651,8 @@ "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", "range": "Precio de {minValue} a {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Inc. Impuestos)", + "excludingTax": "(Ej. Impuestos)", "includingTaxFull": "Incluye impuestos", "excludingTaxFull": "Excluyendo impuestos" } diff --git a/core/messages/es-CL.json b/core/messages/es-CL.json index 661f2eea33..4fac3125a3 100644 --- a/core/messages/es-CL.json +++ b/core/messages/es-CL.json @@ -379,7 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Envío", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -651,8 +651,8 @@ "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", "range": "Precio de {minValue} a {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Inc. Impuestos)", + "excludingTax": "(Ej. Impuestos)", "includingTaxFull": "Incluye impuestos", "excludingTaxFull": "Excluyendo impuestos" } diff --git a/core/messages/es-CO.json b/core/messages/es-CO.json index 661f2eea33..4fac3125a3 100644 --- a/core/messages/es-CO.json +++ b/core/messages/es-CO.json @@ -379,7 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Envío", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -651,8 +651,8 @@ "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", "range": "Precio de {minValue} a {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Inc. Impuestos)", + "excludingTax": "(Ej. Impuestos)", "includingTaxFull": "Incluye impuestos", "excludingTaxFull": "Excluyendo impuestos" } diff --git a/core/messages/es-LA.json b/core/messages/es-LA.json index 661f2eea33..4fac3125a3 100644 --- a/core/messages/es-LA.json +++ b/core/messages/es-LA.json @@ -379,7 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Envío", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -651,8 +651,8 @@ "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", "range": "Precio de {minValue} a {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Inc. Impuestos)", + "excludingTax": "(Ej. Impuestos)", "includingTaxFull": "Incluye impuestos", "excludingTaxFull": "Excluyendo impuestos" } diff --git a/core/messages/es-MX.json b/core/messages/es-MX.json index 661f2eea33..4fac3125a3 100644 --- a/core/messages/es-MX.json +++ b/core/messages/es-MX.json @@ -379,7 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Envío", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -651,8 +651,8 @@ "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", "range": "Precio de {minValue} a {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Inc. Impuestos)", + "excludingTax": "(Ej. Impuestos)", "includingTaxFull": "Incluye impuestos", "excludingTaxFull": "Excluyendo impuestos" } diff --git a/core/messages/es-PE.json b/core/messages/es-PE.json index 661f2eea33..4fac3125a3 100644 --- a/core/messages/es-PE.json +++ b/core/messages/es-PE.json @@ -379,7 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Envío", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -651,8 +651,8 @@ "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", "range": "Precio de {minValue} a {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Inc. Impuestos)", + "excludingTax": "(Ej. Impuestos)", "includingTaxFull": "Incluye impuestos", "excludingTaxFull": "Excluyendo impuestos" } diff --git a/core/messages/es.json b/core/messages/es.json index 1f8e4d6707..6f7a6bc871 100644 --- a/core/messages/es.json +++ b/core/messages/es.json @@ -379,7 +379,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Envío", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Envío (sin Impuestos)", "add": "Añadir", "change": "Cambiar", "cancel": "Cancelar", @@ -651,8 +651,8 @@ "originalPrice": "El precio original era {price}.", "currentPrice": "El precio actual es {price}.", "range": "Precios entre {minValue} y {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(con impuestos)", + "excludingTax": "(Sin Impuesto)", "includingTaxFull": "Con impuestos", "excludingTaxFull": "Excluye impuestos" } diff --git a/core/messages/fr.json b/core/messages/fr.json index 2b5e088d40..ff6756ad30 100644 --- a/core/messages/fr.json +++ b/core/messages/fr.json @@ -379,7 +379,7 @@ "subTotal": "Sous-total", "discounts": "Remises", "tax": "Taxes", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Taxes {tax} incluses", "total": "Total", "CouponCode": { "apply": "Appliquer", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Livraison", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Frais de port (hors taxes)", "add": "Ajouter", "change": "Modifier", "cancel": "Annuler", @@ -651,8 +651,8 @@ "originalPrice": "Le prix initial était de {price}.", "currentPrice": "Le prix actuel est de {price}.", "range": "Prix de {minValue} à {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(toutes taxes comprises)", + "excludingTax": "(hors taxes)", "includingTaxFull": "Toutes taxes comprises", "excludingTaxFull": "Hors taxes" } diff --git a/core/messages/it.json b/core/messages/it.json index 483c245e94..29fa015bdb 100644 --- a/core/messages/it.json +++ b/core/messages/it.json @@ -379,7 +379,7 @@ "subTotal": "Subtotale", "discounts": "Sconti", "tax": "Tasse", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Tassa di {tax} inclusa", "total": "Totale", "CouponCode": { "apply": "Applica", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Spedizioni", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Spedizione (escluse tasse)", "add": "Aggiungi", "change": "Cambia", "cancel": "Annulla", @@ -651,8 +651,8 @@ "originalPrice": "Il prezzo originale era {price}.", "currentPrice": "Il prezzo corrente è {price}.", "range": "Prezzo da {minValue} a {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(inc. tasse)", + "excludingTax": "(escluse tasse)", "includingTaxFull": "Tasse incluse", "excludingTaxFull": "Tasse escluse" } diff --git a/core/messages/ja.json b/core/messages/ja.json index c160fd9af9..71b6dffcc8 100644 --- a/core/messages/ja.json +++ b/core/messages/ja.json @@ -379,7 +379,7 @@ "subTotal": "小計", "discounts": "割引", "tax": "税金", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "税金{tax}を含む", "total": "合計", "CouponCode": { "apply": "適用", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "配送", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "送料 (例)税)", "add": "追加", "change": "変更する", "cancel": "キャンセル", @@ -651,8 +651,8 @@ "originalPrice": "元の価格は{price}でした。", "currentPrice": "現在の価格は{price}です。", "range": "価格は{minValue}から{maxValue}までです。", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(株式会社)税)", + "excludingTax": "(元。税)", "includingTaxFull": "税込", "excludingTaxFull": "税別" } diff --git a/core/messages/nl.json b/core/messages/nl.json index 6dab8372a7..158fa142ec 100644 --- a/core/messages/nl.json +++ b/core/messages/nl.json @@ -379,7 +379,7 @@ "subTotal": "Subtotaal", "discounts": "Kortingen", "tax": "Belasting", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Inclusief {tax} btw", "total": "Totaal", "CouponCode": { "apply": "Toepassen", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Verzending", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Verzending (excl. btw)", "add": "Toevoegen", "change": "Wijzigen", "cancel": "Annuleren", @@ -651,8 +651,8 @@ "originalPrice": "De oorspronkelijke prijs was {price}.", "currentPrice": "De huidige prijs is {price}.", "range": "Prijs van {minValue} tot {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Incl. btw)", + "excludingTax": "(Excl. btw)", "includingTaxFull": "Inclusief btw", "excludingTaxFull": "Exclusief btw" } diff --git a/core/messages/no.json b/core/messages/no.json index 4acb6b13aa..2982b767fc 100644 --- a/core/messages/no.json +++ b/core/messages/no.json @@ -379,7 +379,7 @@ "subTotal": "Delsum", "discounts": "Rabatter", "tax": "Avgift", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Inkludert {tax} avgift", "total": "Totalbeløp", "CouponCode": { "apply": "Bruk", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Frakt", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Frakt (ekskl. avgift)", "add": "Legg til", "change": "Endre", "cancel": "Avbryt", @@ -651,8 +651,8 @@ "originalPrice": "Opprinnelig pris var {price}.", "currentPrice": "Nåværende pris er {price}.", "range": "Pris fra {minValue} til {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Inkl. avgift)", + "excludingTax": "(Ekskl. avgift)", "includingTaxFull": "Inkludert avgifter", "excludingTaxFull": "Ekskludert avgifter" } diff --git a/core/messages/sv.json b/core/messages/sv.json index e04ee06106..5a27a1ce39 100644 --- a/core/messages/sv.json +++ b/core/messages/sv.json @@ -379,7 +379,7 @@ "subTotal": "Delsumma", "discounts": "Rabatter", "tax": "Beskatta", - "totalIncludesTax": "Includes {tax} tax", + "totalIncludesTax": "Inklusive {tax} moms", "total": "Total", "CouponCode": { "apply": "Tillämpa", @@ -390,7 +390,7 @@ }, "Shipping": { "shipping": "Frakt", - "shippingExcludingTax": "Shipping (Ex. Tax)", + "shippingExcludingTax": "Frakt (exkl. moms)", "add": "Lägg till", "change": "Ändra", "cancel": "Annullera", @@ -651,8 +651,8 @@ "originalPrice": "Ursprungligt pris var {price}.", "currentPrice": "Nuvarande pris är {price}.", "range": "Pris från {minValue} till {maxValue}.", - "includingTax": "(Inc. Tax)", - "excludingTax": "(Ex. Tax)", + "includingTax": "(Inkl. moms)", + "excludingTax": "(Exkl. moms)", "includingTaxFull": "Inklusive skatt", "excludingTaxFull": "Exklusive skatt" } From 98d5b87b2817cac5055ce183fb5d73e2b287438c Mon Sep 17 00:00:00 2001 From: Parth Shah <48393781+parthshahp@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:03:50 -0400 Subject: [PATCH 14/18] TRAC-887: fix - reset password no longer working (#3059) --- core/vibes/soul/sections/reset-password-section/schema.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/core/vibes/soul/sections/reset-password-section/schema.ts b/core/vibes/soul/sections/reset-password-section/schema.ts index 4146bfd340..4329afbf2f 100644 --- a/core/vibes/soul/sections/reset-password-section/schema.ts +++ b/core/vibes/soul/sections/reset-password-section/schema.ts @@ -38,7 +38,6 @@ export const resetPasswordSchema = ( return z .object({ - currentPassword: z.string().trim(), password: passwordSchema, confirmPassword: z.string(), }) From 81b88df3e42ff06c86bd91628d7be570579af89d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:35:38 +0000 Subject: [PATCH 15/18] Version Packages (`canary`) (#3037) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/fix-variant-bo-info-on-pdp.md | 5 -- .changeset/fix-wysiwyg-content-image-urls.md | 5 -- .../ltrac-466-catalyst-version-field.md | 5 -- .changeset/ltrac-467-deprecate-integration.md | 5 -- .../ltrac-657-consent-cookie-subdomain.md | 5 -- ...6-gate-analytics-cookies-behind-consent.md | 5 -- .changeset/trac-188-tax-display.md | 9 --- .../trac-421-custom-locale-subfolders.md | 5 -- .../trac-814-session-cookie-compliance.md | 33 ----------- .changeset/translations-patch-53bc0eb5.md | 5 -- .changeset/translations-patch-9edc1998.md | 5 -- core/CHANGELOG.md | 57 +++++++++++++++++++ core/package.json | 6 +- packages/create-catalyst/CHANGELOG.md | 6 ++ packages/create-catalyst/package.json | 2 +- 15 files changed, 67 insertions(+), 91 deletions(-) delete mode 100644 .changeset/fix-variant-bo-info-on-pdp.md delete mode 100644 .changeset/fix-wysiwyg-content-image-urls.md delete mode 100644 .changeset/ltrac-466-catalyst-version-field.md delete mode 100644 .changeset/ltrac-467-deprecate-integration.md delete mode 100644 .changeset/ltrac-657-consent-cookie-subdomain.md delete mode 100644 .changeset/ltrac-846-gate-analytics-cookies-behind-consent.md delete mode 100644 .changeset/trac-188-tax-display.md delete mode 100644 .changeset/trac-421-custom-locale-subfolders.md delete mode 100644 .changeset/trac-814-session-cookie-compliance.md delete mode 100644 .changeset/translations-patch-53bc0eb5.md delete mode 100644 .changeset/translations-patch-9edc1998.md diff --git a/.changeset/fix-variant-bo-info-on-pdp.md b/.changeset/fix-variant-bo-info-on-pdp.md deleted file mode 100644 index 1c23426d31..0000000000 --- a/.changeset/fix-variant-bo-info-on-pdp.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Display backorder information for variants on PDP. diff --git a/.changeset/fix-wysiwyg-content-image-urls.md b/.changeset/fix-wysiwyg-content-image-urls.md deleted file mode 100644 index 3106a22546..0000000000 --- a/.changeset/fix-wysiwyg-content-image-urls.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Fix broken images in WYSIWYG content (web pages, blog posts, product description and warranty). Images uploaded through the Control Panel editor are stored as store-root-relative WebDAV paths (`/content/...` and `/product_images/...`) that 404 on the headless storefront domain; they are now rewritten to absolute BigCommerce CDN URLs. diff --git a/.changeset/ltrac-466-catalyst-version-field.md b/.changeset/ltrac-466-catalyst-version-field.md deleted file mode 100644 index 88126ac679..0000000000 --- a/.changeset/ltrac-466-catalyst-version-field.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Add a `catalyst` field to `core/package.json` (`catalyst.version` and `catalyst.ref`) that tracks the true Catalyst version independently of the top-level `version`, which merchants may repurpose for their own deploy tagging. The backend user-agent now reports `catalyst.version` (falling back to `version` for projects created before the field existed), and the release pipeline keeps the field in sync on each version bump. diff --git a/.changeset/ltrac-467-deprecate-integration.md b/.changeset/ltrac-467-deprecate-integration.md deleted file mode 100644 index 7f9f04081e..0000000000 --- a/.changeset/ltrac-467-deprecate-integration.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/create-catalyst": minor ---- - -Deprecate the `create-catalyst integration` command. It now prints a deprecation warning when invoked and is hidden from `--help`. The command builds integration patches by diffing git tags, which won't work once Catalyst projects are distributed as tarballs (no git history) — it will be replaced by the forthcoming `catalyst upgrade` command. The command still functions for now; full removal will follow in a future major version. diff --git a/.changeset/ltrac-657-consent-cookie-subdomain.md b/.changeset/ltrac-657-consent-cookie-subdomain.md deleted file mode 100644 index be647e7267..0000000000 --- a/.changeset/ltrac-657-consent-cookie-subdomain.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Scope the consent manager cookie (`c15t-consent`) to the current host instead of the top-level domain. Previously `crossSubdomain: true` caused the cookie to be set on the root domain (e.g. `.example.com`) for stores running on a sub-domain, so it appeared on both the root domain and the sub-domain. Removing it makes the cookie host-only, so it now exists only on the sub-domain the store runs on. diff --git a/.changeset/ltrac-846-gate-analytics-cookies-behind-consent.md b/.changeset/ltrac-846-gate-analytics-cookies-behind-consent.md deleted file mode 100644 index c9705acfcc..0000000000 --- a/.changeset/ltrac-846-gate-analytics-cookies-behind-consent.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Gate `catalyst.visitorId`, `catalyst.visitId`, and `currencyCode` cookies behind shopper consent. The visitor and visit cookies now require measurement consent and the currency preference cookie requires functionality consent. When consent is absent, existing analytics cookies are deleted on the next request. When measurement consent is granted mid-session, a new `startVisit` server action sets the cookies and fires the server-side `visitStartedEvent` immediately rather than waiting for the next full-page navigation. diff --git a/.changeset/trac-188-tax-display.md b/.changeset/trac-188-tax-display.md deleted file mode 100644 index 479d1d4a79..0000000000 --- a/.changeset/trac-188-tax-display.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@bigcommerce/catalyst-core": minor ---- - -Honor the merchant's Tax Display setting (`Inc.`, `Ex.`, or `Both`) from the BigCommerce control panel across PDP, PLP, search, compare, and home. When set to `Both`, prices render stacked with `(Inc. Tax)` and `(Ex. Tax)` labels, including sale strike-throughs per line. - -## Migration - -For forks that can't rebase cleanly: pricing was refactored end-to-end to support inc/ex tax variants and a `Both` mode (`PricingFragment`, `pricesTransformer`, `Price` types, page-data settings queries, analytics helpers). See PR #3024 for the full diff. diff --git a/.changeset/trac-421-custom-locale-subfolders.md b/.changeset/trac-421-custom-locale-subfolders.md deleted file mode 100644 index a65f46c3a9..0000000000 --- a/.changeset/trac-421-custom-locale-subfolders.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": minor ---- - -Consume merchant-configured per-locale URL subfolders from the BigCommerce Storefront GraphQL API (`Locale.path`). The locale that sits at the bare root URL (`/`) is derived from the CP configuration: if the default locale has no path, it sits at root; otherwise, if exactly one non-default locale has no path, that one sits at root; otherwise every locale gets a prefix. Locales with a path use it; locales without a path fall back to their locale code. diff --git a/.changeset/trac-814-session-cookie-compliance.md b/.changeset/trac-814-session-cookie-compliance.md deleted file mode 100644 index 4d8808ae1c..0000000000 --- a/.changeset/trac-814-session-cookie-compliance.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Make `authjs.session-token` and `authjs.anonymous-session-token` browser-session cookies (no `Expires` attribute) to satisfy Essential cookie classification requirements. - -## What changed - -**Anonymous session token:** `anonymousSignIn` no longer sets `maxAge` on the cookie. Without it, Next.js omits `Max-Age`/`Expires` and the cookie becomes a session cookie that the browser drops when it closes. - -**Auth session token:** Auth.js v5 unconditionally writes `Expires` on the session token cookie and provides no config option to suppress it. Two post-processing steps strip the attribute: - -- `proxies/with-auth.ts` — strips `Expires` from `Set-Cookie` response headers on every page request. -- `auth/index.ts` — wraps `signIn` and `updateSession` to re-set the cookie via `cookies().set()` without `Expires` immediately after Auth.js writes it, covering the sign-in and session-update paths that middleware cannot reach. - -`Max-Age=0` (used by Auth.js for cookie deletion on sign-out) is intentionally left intact. - -## Migration - -**If you have a custom `maxAge` on `anonymousSignIn`:** The default 7-day `maxAge` has been removed. If your app relies on anonymous sessions persisting across browser restarts, add it back in your own `anonymousSignIn` call: - -```ts -cookieJar.set(anonymousCookieName, jwt, { - httpOnly: true, - sameSite: 'lax', - secure: useSecureCookies, - maxAge: 60 * 60 * 24 * 7, // restore 7-day persistence if needed -}); -``` - -**If you already have your own `Expires`-stripping workaround:** Remove it. The middleware regex in `with-auth.ts` and the `patchSessionTokenCookies` wrapper in `auth/index.ts` now handle this centrally. Leaving both in place will cause redundant cookie writes. - -**If you import `signIn` or `updateSession` directly from `auth/index.ts`:** No change needed — the signatures are identical. The exports are now thin async wrappers that call the Auth.js originals and then patch any session token cookies written during the call. diff --git a/.changeset/translations-patch-53bc0eb5.md b/.changeset/translations-patch-53bc0eb5.md deleted file mode 100644 index ad17b2636a..0000000000 --- a/.changeset/translations-patch-53bc0eb5.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Update translations. diff --git a/.changeset/translations-patch-9edc1998.md b/.changeset/translations-patch-9edc1998.md deleted file mode 100644 index ad17b2636a..0000000000 --- a/.changeset/translations-patch-9edc1998.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-core": patch ---- - -Update translations. diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 4cf244a55c..20765ecf97 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## 1.8.0 + +### Minor Changes + +- [#3024](https://github.com/bigcommerce/catalyst/pull/3024) [`3cec674`](https://github.com/bigcommerce/catalyst/commit/3cec674f8fcbcf563e9c2d8015c35d96f4cd56e0) Thanks [@mfaris9](https://github.com/mfaris9)! - Honor the merchant's Tax Display setting (`Inc.`, `Ex.`, or `Both`) from the BigCommerce control panel across PDP, PLP, search, compare, and home. When set to `Both`, prices render stacked with `(Inc. Tax)` and `(Ex. Tax)` labels, including sale strike-throughs per line. + + ## Migration + + For forks that can't rebase cleanly: pricing was refactored end-to-end to support inc/ex tax variants and a `Both` mode (`PricingFragment`, `pricesTransformer`, `Price` types, page-data settings queries, analytics helpers). See PR #3024 for the full diff. + +- [#3015](https://github.com/bigcommerce/catalyst/pull/3015) [`15e365a`](https://github.com/bigcommerce/catalyst/commit/15e365aeeb36a44901769b7831e635e9e0e7bcc1) Thanks [@mfaris9](https://github.com/mfaris9)! - Consume merchant-configured per-locale URL subfolders from the BigCommerce Storefront GraphQL API (`Locale.path`). The locale that sits at the bare root URL (`/`) is derived from the CP configuration: if the default locale has no path, it sits at root; otherwise, if exactly one non-default locale has no path, that one sits at root; otherwise every locale gets a prefix. Locales with a path use it; locales without a path fall back to their locale code. + +### Patch Changes + +- [#3031](https://github.com/bigcommerce/catalyst/pull/3031) [`874e332`](https://github.com/bigcommerce/catalyst/commit/874e332b73b8a36c2d93ae4ec99d5dc00d7fb3e1) Thanks [@Tharaae](https://github.com/Tharaae)! - Display backorder information for variants on PDP. + +- [#3033](https://github.com/bigcommerce/catalyst/pull/3033) [`8bc379d`](https://github.com/bigcommerce/catalyst/commit/8bc379df6fe0c843ba82e901e31e245f506d0cf3) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fix broken images in WYSIWYG content (web pages, blog posts, product description and warranty). Images uploaded through the Control Panel editor are stored as store-root-relative WebDAV paths (`/content/...` and `/product_images/...`) that 404 on the headless storefront domain; they are now rewritten to absolute BigCommerce CDN URLs. + +- [#3056](https://github.com/bigcommerce/catalyst/pull/3056) [`2c99731`](https://github.com/bigcommerce/catalyst/commit/2c997312623af93f67a1d202aed3a9467bb125a0) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add a `catalyst` field to `core/package.json` (`catalyst.version` and `catalyst.ref`) that tracks the true Catalyst version independently of the top-level `version`, which merchants may repurpose for their own deploy tagging. The backend user-agent now reports `catalyst.version` (falling back to `version` for projects created before the field existed), and the release pipeline keeps the field in sync on each version bump. + +- [#3035](https://github.com/bigcommerce/catalyst/pull/3035) [`b4215f0`](https://github.com/bigcommerce/catalyst/commit/b4215f06cc4fbda8694835c589c84b362fb1a8fc) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Scope the consent manager cookie (`c15t-consent`) to the current host instead of the top-level domain. Previously `crossSubdomain: true` caused the cookie to be set on the root domain (e.g. `.example.com`) for stores running on a sub-domain, so it appeared on both the root domain and the sub-domain. Removing it makes the cookie host-only, so it now exists only on the sub-domain the store runs on. + +- [#3046](https://github.com/bigcommerce/catalyst/pull/3046) [`5034ea3`](https://github.com/bigcommerce/catalyst/commit/5034ea32b684acf2a074c2e2d8876b35aeef0a15) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Gate `catalyst.visitorId`, `catalyst.visitId`, and `currencyCode` cookies behind shopper consent. The visitor and visit cookies now require measurement consent and the currency preference cookie requires functionality consent. When consent is absent, existing analytics cookies are deleted on the next request. When measurement consent is granted mid-session, a new `startVisit` server action sets the cookies and fires the server-side `visitStartedEvent` immediately rather than waiting for the next full-page navigation. + +- [#3047](https://github.com/bigcommerce/catalyst/pull/3047) [`1ab2c82`](https://github.com/bigcommerce/catalyst/commit/1ab2c821b72ab8c2ad5db67f72bd139195945abb) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Make `authjs.session-token` and `authjs.anonymous-session-token` browser-session cookies (no `Expires` attribute) to satisfy Essential cookie classification requirements. + + ## What changed + + **Anonymous session token:** `anonymousSignIn` no longer sets `maxAge` on the cookie. Without it, Next.js omits `Max-Age`/`Expires` and the cookie becomes a session cookie that the browser drops when it closes. + + **Auth session token:** Auth.js v5 unconditionally writes `Expires` on the session token cookie and provides no config option to suppress it. Two post-processing steps strip the attribute: + - `proxies/with-auth.ts` — strips `Expires` from `Set-Cookie` response headers on every page request. + - `auth/index.ts` — wraps `signIn` and `updateSession` to re-set the cookie via `cookies().set()` without `Expires` immediately after Auth.js writes it, covering the sign-in and session-update paths that middleware cannot reach. + + `Max-Age=0` (used by Auth.js for cookie deletion on sign-out) is intentionally left intact. + + ## Migration + + **If you have a custom `maxAge` on `anonymousSignIn`:** The default 7-day `maxAge` has been removed. If your app relies on anonymous sessions persisting across browser restarts, add it back in your own `anonymousSignIn` call: + + ```ts + cookieJar.set(anonymousCookieName, jwt, { + httpOnly: true, + sameSite: 'lax', + secure: useSecureCookies, + maxAge: 60 * 60 * 24 * 7, // restore 7-day persistence if needed + }); + ``` + + **If you already have your own `Expires`-stripping workaround:** Remove it. The middleware regex in `with-auth.ts` and the `patchSessionTokenCookies` wrapper in `auth/index.ts` now handle this centrally. Leaving both in place will cause redundant cookie writes. + + **If you import `signIn` or `updateSession` directly from `auth/index.ts`:** No change needed — the signatures are identical. The exports are now thin async wrappers that call the Auth.js originals and then patch any session token cookies written during the call. + +- [#3058](https://github.com/bigcommerce/catalyst/pull/3058) [`94c503e`](https://github.com/bigcommerce/catalyst/commit/94c503e1f5a2d51ed81c741f5d92556116dac4c9) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. + +- [#3048](https://github.com/bigcommerce/catalyst/pull/3048) [`226f2f3`](https://github.com/bigcommerce/catalyst/commit/226f2f3b49de4d6988105b40253b46a3a083ad4a) Thanks [@bc-svc-local](https://github.com/bc-svc-local)! - Update translations. + ## 1.7.0 ### Minor Changes diff --git a/core/package.json b/core/package.json index d214c9db08..350805736b 100644 --- a/core/package.json +++ b/core/package.json @@ -1,10 +1,10 @@ { "name": "@bigcommerce/catalyst-core", "description": "BigCommerce Catalyst is a Next.js starter kit for building headless BigCommerce storefronts.", - "version": "1.7.0", + "version": "1.8.0", "catalyst": { - "version": "1.7.0", - "ref": "@bigcommerce/catalyst-core@1.7.0" + "version": "1.8.0", + "ref": "@bigcommerce/catalyst-core@1.8.0" }, "private": true, "engines": { diff --git a/packages/create-catalyst/CHANGELOG.md b/packages/create-catalyst/CHANGELOG.md index 3b2f3c7473..d52035da62 100644 --- a/packages/create-catalyst/CHANGELOG.md +++ b/packages/create-catalyst/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.1.0 + +### Minor Changes + +- [#3055](https://github.com/bigcommerce/catalyst/pull/3055) [`854aab5`](https://github.com/bigcommerce/catalyst/commit/854aab54d530c71a07360c753cc687fd3944325b) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Deprecate the `create-catalyst integration` command. It now prints a deprecation warning when invoked and is hidden from `--help`. The command builds integration patches by diffing git tags, which won't work once Catalyst projects are distributed as tarballs (no git history) — it will be replaced by the forthcoming `catalyst upgrade` command. The command still functions for now; full removal will follow in a future major version. + ## 1.0.3 ### Patch Changes diff --git a/packages/create-catalyst/package.json b/packages/create-catalyst/package.json index 02b1357aaf..1a68ba4071 100644 --- a/packages/create-catalyst/package.json +++ b/packages/create-catalyst/package.json @@ -1,6 +1,6 @@ { "name": "@bigcommerce/create-catalyst", - "version": "1.0.3", + "version": "1.1.0", "repository": { "type": "git", "url": "https://github.com/bigcommerce/catalyst", From 6d1eafacf7ac5b793f8c1ba4476becf6baefbf34 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 26 Jun 2026 11:49:46 -0500 Subject: [PATCH 16/18] fix(makeswift): resolve sync typecheck failures --- core/auth/index.ts | 1 - .../use-bc-product-to-vibes-product.ts | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/auth/index.ts b/core/auth/index.ts index 3b6a906421..7584f81dcd 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -195,7 +195,6 @@ const config = { session: { strategy: 'jwt', }, - cookies: {}, pages: { signIn: '/login', signOut: '/logout', diff --git a/core/lib/makeswift/utils/use-bc-product-to-vibes-product/use-bc-product-to-vibes-product.ts b/core/lib/makeswift/utils/use-bc-product-to-vibes-product/use-bc-product-to-vibes-product.ts index d5c52ab39e..11b2538b05 100644 --- a/core/lib/makeswift/utils/use-bc-product-to-vibes-product/use-bc-product-to-vibes-product.ts +++ b/core/lib/makeswift/utils/use-bc-product-to-vibes-product/use-bc-product-to-vibes-product.ts @@ -27,7 +27,8 @@ export const BcProductSchema = z.object({ defaultImage: z.object({ altText: z.string(), url: string() }).nullable(), brand: z.object({ name: z.string(), path: z.string() }).nullable(), path: z.string(), - prices: PricesSchema, + pricesIncludingTax: PricesSchema.nullable(), + pricesExcludingTax: PricesSchema.nullable(), }); export type BcProductSchema = z.infer; @@ -39,8 +40,8 @@ export function useBcProductToVibesProduct(): (product: BcProductSchema) => Prod return useCallback( (product) => { - const { entityId, name, defaultImage, brand, path, prices } = product; - const price = pricesTransformer(prices, format); + const { entityId, name, defaultImage, brand, path } = product; + const price = pricesTransformer(product, format); return { id: entityId.toString(), From fce3519eb1e55f1f9e37926adceedbdb8b50243c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:25:39 -0500 Subject: [PATCH 17/18] Version Packages (`integrations/makeswift`) --- .changeset/forty-trees-type.md | 5 ----- .changeset/shiny-cougars-drop.md | 5 ----- .changeset/sync-canary-1-8-0.md | 5 ----- core/CHANGELOG.md | 12 ++++++++++++ core/package.json | 6 +++--- 5 files changed, 15 insertions(+), 18 deletions(-) delete mode 100644 .changeset/forty-trees-type.md delete mode 100644 .changeset/shiny-cougars-drop.md delete mode 100644 .changeset/sync-canary-1-8-0.md diff --git a/.changeset/forty-trees-type.md b/.changeset/forty-trees-type.md deleted file mode 100644 index b759676b25..0000000000 --- a/.changeset/forty-trees-type.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-makeswift": patch ---- - -Fix 404 error on switching between localized Makeswift pages with locale-specific paths when TRAILING_SLASH=true diff --git a/.changeset/shiny-cougars-drop.md b/.changeset/shiny-cougars-drop.md deleted file mode 100644 index 6f3eb623af..0000000000 --- a/.changeset/shiny-cougars-drop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-makeswift": minor ---- - -Enable interaction-mode site navigation in Makeswift builder diff --git a/.changeset/sync-canary-1-8-0.md b/.changeset/sync-canary-1-8-0.md deleted file mode 100644 index f72b990861..0000000000 --- a/.changeset/sync-canary-1-8-0.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst-makeswift": minor ---- - -Pulls in changes from the `@bigcommerce/catalyst-core@1.8.0` release. For more information about what was included in the `@bigcommerce/catalyst-core@1.8.0` release, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob/81b88df3e42ff06c86bd91628d7be570579af89d/core/CHANGELOG.md#180). diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 78816f60a2..8942dbe39d 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.8.0 + +### Minor Changes + +- [#2913](https://github.com/bigcommerce/catalyst/pull/2913) [`097eea7`](https://github.com/bigcommerce/catalyst/commit/097eea7b9205ce35ba0724a8c1a5c04281f18dd7) Thanks [@agurtovoy](https://github.com/agurtovoy)! - Enable interaction-mode site navigation in Makeswift builder + +- Pulls in changes from the `@bigcommerce/catalyst-core@1.8.0` release. For more information about what was included in the `@bigcommerce/catalyst-core@1.8.0` release, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob/81b88df3e42ff06c86bd91628d7be570579af89d/core/CHANGELOG.md#180). + +### Patch Changes + +- [#2950](https://github.com/bigcommerce/catalyst/pull/2950) [`4d511dc`](https://github.com/bigcommerce/catalyst/commit/4d511dc40e577b6c1a4b6bbbc72a02f66b0ef8d4) Thanks [@agurtovoy](https://github.com/agurtovoy)! - Fix 404 error on switching between localized Makeswift pages with locale-specific paths when TRAILING_SLASH=true + ## 1.7.0 ### Minor Changes diff --git a/core/package.json b/core/package.json index 686c4623c6..4a37922be5 100644 --- a/core/package.json +++ b/core/package.json @@ -1,10 +1,10 @@ { "name": "@bigcommerce/catalyst-makeswift", "description": "BigCommerce Catalyst is a Next.js starter kit for building headless BigCommerce storefronts.", - "version": "1.7.0", + "version": "1.8.0", "catalyst": { - "version": "1.7.0", - "ref": "@bigcommerce/catalyst-makeswift@1.7.0" + "version": "1.8.0", + "ref": "@bigcommerce/catalyst-makeswift@1.8.0" }, "private": true, "engines": { From 71cff26b2ac31e55dfb333360cf9e278bcf61040 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Wed, 29 Jul 2026 12:16:58 -0600 Subject: [PATCH 18/18] fix: revert silent package.json/CHANGELOG.md clobber from 1.8.0 merge Same recurring bug as every prior rung, but worse this time: the conflict-free merge inserted makeswift's own duplicate nested 'catalyst' block (wrong self-ref, @bigcommerce/catalyst-makeswift@1.8.0) right after the top-level version field, alongside b2b's own correct block at the end of the file, producing duplicate JSON keys. Removed the spurious block, reverted the top-level version to b2b's actual 1.7.0, and restored CHANGELOG.md (makeswift's own 1.8.0 entry had been pasted in wholesale). Also adds the sync changeset for this rung's bump. --- .changeset/sync-makeswift-1-8-0.md | 5 +++++ core/CHANGELOG.md | 12 ------------ core/package.json | 6 +----- 3 files changed, 6 insertions(+), 17 deletions(-) create mode 100644 .changeset/sync-makeswift-1-8-0.md diff --git a/.changeset/sync-makeswift-1-8-0.md b/.changeset/sync-makeswift-1-8-0.md new file mode 100644 index 0000000000..30d8ceec38 --- /dev/null +++ b/.changeset/sync-makeswift-1-8-0.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-b2b-makeswift": minor +--- + +Pulls in changes from the `@bigcommerce/catalyst-makeswift@1.8.0` release. For more information, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob/829c3c5cd2e6b46b39d11252a2b2e42edda23468/core/CHANGELOG.md#180). diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 900b470887..7b501b9483 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,17 +1,5 @@ # Changelog -## 1.8.0 - -### Minor Changes - -- [#2913](https://github.com/bigcommerce/catalyst/pull/2913) [`097eea7`](https://github.com/bigcommerce/catalyst/commit/097eea7b9205ce35ba0724a8c1a5c04281f18dd7) Thanks [@agurtovoy](https://github.com/agurtovoy)! - Enable interaction-mode site navigation in Makeswift builder - -- Pulls in changes from the `@bigcommerce/catalyst-core@1.8.0` release. For more information about what was included in the `@bigcommerce/catalyst-core@1.8.0` release, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob/81b88df3e42ff06c86bd91628d7be570579af89d/core/CHANGELOG.md#180). - -### Patch Changes - -- [#2950](https://github.com/bigcommerce/catalyst/pull/2950) [`4d511dc`](https://github.com/bigcommerce/catalyst/commit/4d511dc40e577b6c1a4b6bbbc72a02f66b0ef8d4) Thanks [@agurtovoy](https://github.com/agurtovoy)! - Fix 404 error on switching between localized Makeswift pages with locale-specific paths when TRAILING_SLASH=true - ## 1.7.0 ### Minor Changes diff --git a/core/package.json b/core/package.json index 9925039c51..7fdb58e9c2 100644 --- a/core/package.json +++ b/core/package.json @@ -1,11 +1,7 @@ { "name": "@bigcommerce/catalyst-b2b-makeswift", "description": "BigCommerce Catalyst is a Next.js starter kit for building headless BigCommerce storefronts.", - "version": "1.8.0", - "catalyst": { - "version": "1.8.0", - "ref": "@bigcommerce/catalyst-makeswift@1.8.0" - }, + "version": "1.7.0", "private": true, "engines": { "node": ">=24.0.0"