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/.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/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 10d78d92ed..908a02d47f 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -115,6 +115,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(); @@ -151,6 +153,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 3567a50247..ac42d67c47 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts @@ -52,6 +52,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 3742f4e96d..14ec86b128 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -131,6 +131,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(); @@ -168,6 +170,7 @@ export default async function Category(props: Props) { format, showOutOfStockMessage ? defaultOutOfStockMessage : undefined, showBackorderMessage, + taxDisplay, ); }); @@ -298,7 +301,13 @@ export default async function Category(props: Props) { snapshotId={`category-${categoryId}-bottom-content`} /> - {(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 c13ef0a71c..a6192437d4 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/search/page.tsx @@ -86,6 +86,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(); @@ -130,6 +132,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)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index df2df54e73..680f3fed5d 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 { getMakeswiftPageMetadata } from '~/lib/makeswift'; import { getMetadataAlternates } from '~/lib/seo/canonical'; @@ -63,7 +64,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)/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 9b76c814af..e6ea1232ff 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 { getMakeswiftPageMetadata } from '~/lib/makeswift'; @@ -101,6 +103,8 @@ export default async function Cart({ params }: Props) { return emptyState; } + const taxIncluded = cart.isTaxIncluded; + const lineItems = [ ...cart.lineItems.giftCertificates, ...cart.lineItems.physicalItems, @@ -170,18 +174,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) { @@ -283,6 +288,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'), @@ -316,13 +330,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} @@ -399,7 +415,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 924ca882c9..ed0f536b7c 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -10,6 +10,7 @@ import { pricesTransformer } from '~/data-transformers/prices-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMakeswiftPageMetadata } from '~/lib/makeswift'; 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'; @@ -67,7 +68,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) => ({ @@ -77,7 +82,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:
, @@ -100,16 +105,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)/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 c61334e8bc..b81698b2f1 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -179,6 +179,9 @@ const ProductQuery = graphql( display { showProductRating } + tax { + pdp + } } product(entityId: $entityId) { entityId @@ -338,9 +341,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 @@ -364,7 +375,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 59675e48c8..81457201b2 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -7,6 +7,7 @@ import { SearchParams } from 'nuqs/server'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { FeaturedProductCarousel } from '@/vibes/soul/sections/featured-product-carousel'; 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'; @@ -71,6 +72,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'; @@ -88,23 +100,13 @@ 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(); } 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, @@ -125,6 +127,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); @@ -158,17 +162,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 = { @@ -188,7 +181,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 () => { @@ -486,7 +479,12 @@ export default async function Product({ params, searchParams }: Props) { { title: t('ProductDetails.Accordions.warranty'), content: ( -
+
), }, ] @@ -503,7 +501,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 () => { @@ -529,8 +527,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 ?? '', }; }); @@ -573,7 +571,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, @@ -629,10 +633,20 @@ export default async function Product({ params, searchParams }: Props) { {([extendedProduct, pricingProduct]) => ( <> )} diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx index e1bb51eae6..05d2a3162b 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 { getMakeswiftPageMetadata } from '~/lib/makeswift'; import { getMetadataAlternates } from '~/lib/seo/canonical'; @@ -35,7 +36,7 @@ const getWebPage = cache(async (id: string, customerAccessToken?: string): Promi title: webpage.name, path: webpage.path, breadcrumbs, - content: webpage.htmlBody, + content: rewriteWysiwygContentUrls(webpage.htmlBody), seo: webpage.seo, }; }); 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/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/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 96e5927337..dca556c7fc 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'; @@ -365,7 +366,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/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/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/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/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, 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/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/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/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/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 632a7779a1..abb20dc337 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, // configure `NEXT_LOCALE` cookie to work inside of the Makeswift Builder's canvas localeCookie: { 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/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/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(), 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/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); }; /** 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/da.json b/core/messages/da.json index fd276f8ed3..c068677a1a 100644 --- a/core/messages/da.json +++ b/core/messages/da.json @@ -435,6 +435,7 @@ "subTotal": "Subtotal", "discounts": "Rabatter", "tax": "Moms", + "totalIncludesTax": "Inklusiv {tax} moms", "total": "I alt", "CouponCode": { "apply": "Anvend", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Forsendelse", + "shippingExcludingTax": "Forsendelse (ekskl. moms)", "add": "Tilføj", "change": "Ændr", "cancel": "Annuller", @@ -704,7 +706,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": "(Inkl. moms)", + "excludingTax": "(Ekskl. moms)", + "includingTaxFull": "Inklusiv skat", + "excludingTaxFull": "Eksklusiv skat" } }, "GiftCertificates": { diff --git a/core/messages/de.json b/core/messages/de.json index 1cb0aa07a3..60850a8223 100644 --- a/core/messages/de.json +++ b/core/messages/de.json @@ -435,6 +435,7 @@ "subTotal": "Zwischensumme", "discounts": "Rabatte", "tax": "Steuern", + "totalIncludesTax": "Inklusive {tax} Steuern", "total": "Summe", "CouponCode": { "apply": "Anwenden", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Versand", + "shippingExcludingTax": "Versand (ohne Steuer)", "add": "Hinzufügen", "change": "Ändern", "cancel": "Abbrechen", @@ -704,7 +706,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": "(Inkl. Steuer)", + "excludingTax": "(Ohne Steuer)", + "includingTaxFull": "Einschließlich Steuern", + "excludingTaxFull": "Ohne Steuern" } }, "GiftCertificates": { diff --git a/core/messages/en.json b/core/messages/en.json index a958caa8bf..1d4b8908a9 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -436,6 +436,7 @@ "subTotal": "Subtotal", "discounts": "Discounts", "tax": "Tax", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Apply", @@ -446,6 +447,7 @@ }, "Shipping": { "shipping": "Shipping", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Add", "change": "Change", "cancel": "Cancel", @@ -707,7 +709,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/messages/es-419.json b/core/messages/es-419.json index 4616354bca..1c81487dde 100644 --- a/core/messages/es-419.json +++ b/core/messages/es-419.json @@ -435,6 +435,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -704,7 +706,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. Impuestos)", + "excludingTax": "(Ej. Impuestos)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-AR.json b/core/messages/es-AR.json index 4616354bca..1c81487dde 100644 --- a/core/messages/es-AR.json +++ b/core/messages/es-AR.json @@ -435,6 +435,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -704,7 +706,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. Impuestos)", + "excludingTax": "(Ej. Impuestos)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-CL.json b/core/messages/es-CL.json index 4616354bca..1c81487dde 100644 --- a/core/messages/es-CL.json +++ b/core/messages/es-CL.json @@ -435,6 +435,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -704,7 +706,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. Impuestos)", + "excludingTax": "(Ej. Impuestos)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-CO.json b/core/messages/es-CO.json index 4616354bca..1c81487dde 100644 --- a/core/messages/es-CO.json +++ b/core/messages/es-CO.json @@ -435,6 +435,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -704,7 +706,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. Impuestos)", + "excludingTax": "(Ej. Impuestos)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-LA.json b/core/messages/es-LA.json index 4616354bca..1c81487dde 100644 --- a/core/messages/es-LA.json +++ b/core/messages/es-LA.json @@ -435,6 +435,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -704,7 +706,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. Impuestos)", + "excludingTax": "(Ej. Impuestos)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-MX.json b/core/messages/es-MX.json index 4616354bca..1c81487dde 100644 --- a/core/messages/es-MX.json +++ b/core/messages/es-MX.json @@ -435,6 +435,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -704,7 +706,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. Impuestos)", + "excludingTax": "(Ej. Impuestos)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es-PE.json b/core/messages/es-PE.json index 4616354bca..1c81487dde 100644 --- a/core/messages/es-PE.json +++ b/core/messages/es-PE.json @@ -435,6 +435,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Envíos (Ej. Impuestos)", "add": "Agregar", "change": "Cambiar", "cancel": "Cancelar", @@ -704,7 +706,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. Impuestos)", + "excludingTax": "(Ej. Impuestos)", + "includingTaxFull": "Incluye impuestos", + "excludingTaxFull": "Excluyendo impuestos" } }, "GiftCertificates": { diff --git a/core/messages/es.json b/core/messages/es.json index 28bb4badfd..92c04e3690 100644 --- a/core/messages/es.json +++ b/core/messages/es.json @@ -435,6 +435,7 @@ "subTotal": "Subtotal", "discounts": "Descuentos", "tax": "Impuesto", + "totalIncludesTax": "Incluye {tax} impuestos", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Envío", + "shippingExcludingTax": "Envío (sin Impuestos)", "add": "Añadir", "change": "Cambiar", "cancel": "Cancelar", @@ -704,7 +706,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": "(con impuestos)", + "excludingTax": "(Sin Impuesto)", + "includingTaxFull": "Con impuestos", + "excludingTaxFull": "Excluye impuestos" } }, "GiftCertificates": { diff --git a/core/messages/fr.json b/core/messages/fr.json index 41948b400d..6f07af7786 100644 --- a/core/messages/fr.json +++ b/core/messages/fr.json @@ -435,6 +435,7 @@ "subTotal": "Sous-total", "discounts": "Remises", "tax": "Taxes", + "totalIncludesTax": "Taxes {tax} incluses", "total": "Total", "CouponCode": { "apply": "Appliquer", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Livraison", + "shippingExcludingTax": "Frais de port (hors taxes)", "add": "Ajouter", "change": "Modifier", "cancel": "Annuler", @@ -704,7 +706,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": "(toutes taxes comprises)", + "excludingTax": "(hors taxes)", + "includingTaxFull": "Toutes taxes comprises", + "excludingTaxFull": "Hors taxes" } }, "GiftCertificates": { diff --git a/core/messages/it.json b/core/messages/it.json index 65ef9da970..4b08bd3029 100644 --- a/core/messages/it.json +++ b/core/messages/it.json @@ -435,6 +435,7 @@ "subTotal": "Subtotale", "discounts": "Sconti", "tax": "Tasse", + "totalIncludesTax": "Tassa di {tax} inclusa", "total": "Totale", "CouponCode": { "apply": "Applica", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Spedizioni", + "shippingExcludingTax": "Spedizione (escluse tasse)", "add": "Aggiungi", "change": "Cambia", "cancel": "Annulla", @@ -704,7 +706,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. tasse)", + "excludingTax": "(escluse tasse)", + "includingTaxFull": "Tasse incluse", + "excludingTaxFull": "Tasse escluse" } }, "GiftCertificates": { diff --git a/core/messages/ja.json b/core/messages/ja.json index 4554d77027..f303527124 100644 --- a/core/messages/ja.json +++ b/core/messages/ja.json @@ -435,6 +435,7 @@ "subTotal": "小計", "discounts": "割引", "tax": "税金", + "totalIncludesTax": "税金{tax}を含む", "total": "合計", "CouponCode": { "apply": "適用", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "配送", + "shippingExcludingTax": "送料 (例)税)", "add": "追加", "change": "変更する", "cancel": "キャンセル", @@ -704,7 +706,11 @@ "Price": { "originalPrice": "元の価格は{price}でした。", "currentPrice": "現在の価格は{price}です。", - "range": "価格は{minValue}から{maxValue}までです。" + "range": "価格は{minValue}から{maxValue}までです。", + "includingTax": "(株式会社)税)", + "excludingTax": "(元。税)", + "includingTaxFull": "税込", + "excludingTaxFull": "税別" } }, "GiftCertificates": { diff --git a/core/messages/nl.json b/core/messages/nl.json index 345a04ff57..5a791406df 100644 --- a/core/messages/nl.json +++ b/core/messages/nl.json @@ -435,6 +435,7 @@ "subTotal": "Subtotaal", "discounts": "Kortingen", "tax": "Belasting", + "totalIncludesTax": "Inclusief {tax} btw", "total": "Totaal", "CouponCode": { "apply": "Toepassen", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Verzending", + "shippingExcludingTax": "Verzending (excl. btw)", "add": "Toevoegen", "change": "Wijzigen", "cancel": "Annuleren", @@ -704,7 +706,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": "(Incl. btw)", + "excludingTax": "(Excl. btw)", + "includingTaxFull": "Inclusief btw", + "excludingTaxFull": "Exclusief btw" } }, "GiftCertificates": { diff --git a/core/messages/no.json b/core/messages/no.json index 9991f200a9..c59e680609 100644 --- a/core/messages/no.json +++ b/core/messages/no.json @@ -435,6 +435,7 @@ "subTotal": "Delsum", "discounts": "Rabatter", "tax": "Avgift", + "totalIncludesTax": "Inkludert {tax} avgift", "total": "Totalbeløp", "CouponCode": { "apply": "Bruk", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Frakt", + "shippingExcludingTax": "Frakt (ekskl. avgift)", "add": "Legg til", "change": "Endre", "cancel": "Avbryt", @@ -704,7 +706,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": "(Inkl. avgift)", + "excludingTax": "(Ekskl. avgift)", + "includingTaxFull": "Inkludert avgifter", + "excludingTaxFull": "Ekskludert avgifter" } }, "GiftCertificates": { diff --git a/core/messages/pl.json b/core/messages/pl.json index 03c037aa94..7eff0388c5 100644 --- a/core/messages/pl.json +++ b/core/messages/pl.json @@ -435,6 +435,7 @@ "subTotal": "Suma cząstkowa", "discounts": "Rabaty", "tax": "Podatek", + "totalIncludesTax": "Includes {tax} tax", "total": "Razem", "CouponCode": { "apply": "Zgłoś się na", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Wysyłka", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Dodaj", "change": "Zmiana", "cancel": "Anuluj", @@ -704,7 +706,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 0f0397ef70..27c3bf66ea 100644 --- a/core/messages/pt-BR.json +++ b/core/messages/pt-BR.json @@ -435,6 +435,7 @@ "subTotal": "Subtotal", "discounts": "Descontos", "tax": "Imposto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Envio", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Adicionar", "change": "Alterar", "cancel": "Cancelar", @@ -704,7 +706,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 0f0397ef70..27c3bf66ea 100644 --- a/core/messages/pt.json +++ b/core/messages/pt.json @@ -435,6 +435,7 @@ "subTotal": "Subtotal", "discounts": "Descontos", "tax": "Imposto", + "totalIncludesTax": "Includes {tax} tax", "total": "Total", "CouponCode": { "apply": "Aplicar", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Envio", + "shippingExcludingTax": "Shipping (Ex. Tax)", "add": "Adicionar", "change": "Alterar", "cancel": "Cancelar", @@ -704,7 +706,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 db953ac0f9..77237e38fd 100644 --- a/core/messages/sv.json +++ b/core/messages/sv.json @@ -435,6 +435,7 @@ "subTotal": "Delsumma", "discounts": "Rabatter", "tax": "Beskatta", + "totalIncludesTax": "Inklusive {tax} moms", "total": "Total", "CouponCode": { "apply": "Tillämpa", @@ -445,6 +446,7 @@ }, "Shipping": { "shipping": "Frakt", + "shippingExcludingTax": "Frakt (exkl. moms)", "add": "Lägg till", "change": "Ändra", "cancel": "Annullera", @@ -704,7 +706,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": "(Inkl. moms)", + "excludingTax": "(Exkl. moms)", + "includingTaxFull": "Inklusive skatt", + "excludingTaxFull": "Exklusive skatt" } }, "GiftCertificates": { diff --git a/core/next.config.ts b/core/next.config.ts index 9a69015a72..1cb12064ab 100644 --- a/core/next.config.ts +++ b/core/next.config.ts @@ -38,6 +38,7 @@ const SettingsQuery = graphql(` locales { code isDefault + path } } } diff --git a/core/package.json b/core/package.json index 178c08d434..7fdb58e9c2 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/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/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; }; }; 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; 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(); + }); }); 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/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 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 c21358d221..4e89c9314f 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 016a9da98e..fc5b8ffb8d 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 { @@ -51,8 +51,7 @@ export interface CartLineItem { image?: { alt: string; src: string }; subtitle: string; quantity: number; - price: string; - salePrice?: string; + price: Price; href?: string; inventoryMessages?: CartLineIteminventoryMessages; } @@ -85,6 +84,7 @@ export interface Cart { summaryItems: CartSummaryItem[]; total: string; totalLabel?: string; + totalSubtitle?: string; } interface CouponCode { @@ -410,12 +410,19 @@ export function CartClient({ removeLabel={giftCertificate.removeLabel} /> )} -
-
{cart.totalLabel ?? 'Total'}
- {isLineItemActionPending ? ( - - ) : ( -
{cart.total}
+
+
+
{cart.totalLabel ?? 'Total'}
+ {isLineItemActionPending ? ( + + ) : ( +
{cart.total}
+ )} +
+ {cart.totalSubtitle != null && ( +
+ {cart.totalSubtitle} +
)}
@@ -532,8 +539,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', @@ -553,7 +558,9 @@ function CounterForm({
- {lineItem.price} + {typeof lineItem.price === 'string' && ( + {lineItem.price} + )} {lineItem.quantity} @@ -581,18 +588,7 @@ function CounterForm({
- {lineItem.salePrice && lineItem.salePrice !== lineItem.price ? ( - - {t('originalPrice', { price: lineItem.price })} - {' '} - {t('currentPrice', { price: lineItem.salePrice })} - - - ) : ( - {lineItem.price} - )} +
{/* Counter */} 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(), }) 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", 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); 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: