diff --git a/.changeset/curly-hats-greet.md b/.changeset/curly-hats-greet.md new file mode 100644 index 0000000000..f9e24eed11 --- /dev/null +++ b/.changeset/curly-hats-greet.md @@ -0,0 +1,112 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +All GraphQL requests now include an `X-Correlation-ID` header containing a UUID that is stable for the duration of a single page render (via `React.cache`), making it easier to trace and correlate all requests made during a single render in server logs. + +Guest (unauthenticated) queries are now cached using `unstable_cache` with the configured revalidation interval, while authenticated requests continue to use `cache: 'no-store'`. This separates cacheable public data from session-specific data, improving performance for unauthenticated visitors. The `X-Forwarded-For` and `True-Client-IP` headers are only forwarded on uncached (`no-store`) requests since they are unavailable inside `unstable_cache`. + +## Migration + +### Step 1: Add the correlation ID helper + +Create `core/client/correlation-id.ts`: + +```ts +import { cache } from 'react'; + +/** + * Returns a stable correlation ID for the current request. + * React.cache ensures the same UUID is returned for all fetches within a + * single page render, while being unique across renders/requests. + */ +export const getCorrelationId = cache((): string => crypto.randomUUID()); +``` + +### Step 2: Update `core/client/index.ts` + +Update the `beforeRequest` hook to add the `X-Correlation-ID` header to all requests and to only forward `X-Forwarded-For` / `True-Client-IP` on uncached requests: + +```diff ++ import { getCorrelationId } from './correlation-id'; + + export const client = createClient({ + ... + beforeRequest: async (fetchOptions) => { + const requestHeaders: Record = {}; + +- try { +- const ipAddress = (await headers()).get('X-Forwarded-For'); +- if (ipAddress) { +- requestHeaders['X-Forwarded-For'] = ipAddress; +- requestHeaders['True-Client-IP'] = ipAddress; +- } +- } catch { +- // Not in a request context +- } ++ if (fetchOptions?.cache && ['no-store', 'no-cache'].includes(fetchOptions.cache)) { ++ try { ++ // headers() is a dynamic API unavailable inside unstable_cache; skip IP forwarding in that context ++ const ipAddress = (await headers()).get('X-Forwarded-For'); ++ if (ipAddress) { ++ requestHeaders['X-Forwarded-For'] = ipAddress; ++ requestHeaders['True-Client-IP'] = ipAddress; ++ } ++ } catch { ++ // Not in a request context (e.g. inside unstable_cache); IP forwarding not available ++ } ++ } ++ ++ requestHeaders['X-Correlation-ID'] = getCorrelationId(); + + return { headers: requestHeaders }; + }, + }); +``` + +### Step 3: Wrap guest queries with `unstable_cache` + +For each page data file, wrap the guest (unauthenticated) fetch in `unstable_cache` and branch on whether a `customerAccessToken` is present. Example pattern: + +```diff ++ import { unstable_cache } from 'next/cache'; + import { cache } from 'react'; ++ import { revalidate } from '~/client/revalidate-target'; + ++ const getCachedPageData = unstable_cache( ++ async (locale: string, ...args) => { ++ const { data } = await client.fetch({ ++ document: PageQuery, ++ variables: { ... }, ++ locale, ++ fetchOptions: { cache: 'no-store' }, ++ }); ++ return data; ++ }, ++ ['cache-key'], ++ { revalidate }, ++ ); + + export const getPageData = cache( +- async (locale: string, customerAccessToken?: string) => { +- const { data } = await client.fetch({ +- document: PageQuery, +- locale, +- fetchOptions: { cache: 'no-store' }, +- }); +- return data; +- }, ++ async (locale: string, customerAccessToken?: string) => { ++ if (customerAccessToken) { ++ const { data } = await client.fetch({ ++ document: PageQuery, ++ customerAccessToken, ++ locale, ++ fetchOptions: { cache: 'no-store' }, ++ }); ++ return data; ++ } ++ return getCachedPageData(locale); ++ }, + ); +``` diff --git a/.changeset/plain-results-happen.md b/.changeset/plain-results-happen.md new file mode 100644 index 0000000000..5302a96829 --- /dev/null +++ b/.changeset/plain-results-happen.md @@ -0,0 +1,40 @@ +--- +"@bigcommerce/catalyst-client": minor +--- + +`locale` is now a parameter on `client.fetch()` and is required for all queries. It is passed through to channel ID resolution so the `getChannelId` callback can return a locale-specific channel, and it is used to set the `Accept-Language` request header on each GraphQL call. + +The `getChannelId` config callback signature now accepts `locale` as an optional second argument: + +```diff +- getChannelId?: (defaultChannelId: string) => Promise | string; ++ getChannelId?: (defaultChannelId: string, locale?: string) => Promise | string; +``` + +## Migration + +### Step 1: Update all `client.fetch()` calls + +Pass `locale` as a parameter to every `client.fetch()` call across your page data files: + +```diff + const { data } = await client.fetch({ + document: PageQuery, + variables: { ... }, ++ locale, + fetchOptions: { cache: 'no-store' }, + }); +``` + +### Step 2: Update the `getChannelId` callback in `core/client/index.ts` + +Update the callback to accept and forward the `locale` parameter: + +```diff +- getChannelId: (defaultChannelId: string) => { +- return getChannelIdFromLocale() ?? defaultChannelId; +- }, ++ getChannelId: (defaultChannelId: string, locale?: string) => { ++ return getChannelIdFromLocale(locale) ?? defaultChannelId; ++ }, +``` diff --git a/core/app/[locale]/(default)/(auth)/change-password/page-data.ts b/core/app/[locale]/(default)/(auth)/change-password/page-data.ts index 43a72f2d3a..e6b1f6ed83 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/page-data.ts +++ b/core/app/[locale]/(default)/(auth)/change-password/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -24,10 +25,14 @@ const ChangePasswordQuery = graphql(` } `); -export const getChangePasswordQuery = cache(async () => { +async function getCachedChangePasswordQuery(locale: string) { + 'use cache'; + + cacheLife({ revalidate }); + const response = await client.fetch({ document: ChangePasswordQuery, - fetchOptions: { next: { revalidate } }, + locale, }); const passwordComplexitySettings = @@ -36,4 +41,8 @@ export const getChangePasswordQuery = cache(async () => { return { passwordComplexitySettings, }; +} + +export const getChangePasswordQuery = cache(async (locale: string) => { + return getCachedChangePasswordQuery(locale); }); diff --git a/core/app/[locale]/(default)/(auth)/change-password/page.tsx b/core/app/[locale]/(default)/(auth)/change-password/page.tsx index 1e7e251a6a..75ffe75706 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/change-password/page.tsx @@ -1,10 +1,10 @@ /* eslint-disable react/jsx-no-bind */ import { Metadata } from 'next'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { ResetPasswordSection } from '@/vibes/soul/sections/reset-password-section'; import { getChangePasswordQuery } from '~/app/[locale]/(default)/(auth)/change-password/page-data'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { changePassword } from './_actions/change-password'; @@ -16,10 +16,8 @@ interface Props { }>; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Auth.ChangePassword' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Auth.ChangePassword'); return { title: t('title'), @@ -29,8 +27,6 @@ export async function generateMetadata({ params }: Props): Promise { export default async function ChangePassword({ params, searchParams }: Props) { const { locale } = await params; - setRequestLocale(locale); - const { c: customerEntityId, t: token } = await searchParams; const t = await getTranslations('Auth.ChangePassword'); @@ -38,7 +34,7 @@ export default async function ChangePassword({ params, searchParams }: Props) { return redirect({ href: '/login', locale }); } - const { passwordComplexitySettings } = await getChangePasswordQuery(); + const { passwordComplexitySettings } = await getChangePasswordQuery(locale); return ( ; } -export default async function Layout({ children, params }: Props) { +async function AuthGuard({ children, params }: Props) { const loggedIn = await isLoggedIn(); const { locale } = await params; @@ -17,3 +17,11 @@ export default async function Layout({ children, params }: Props) { return children; } + +export default function Layout({ children, params }: Props) { + return ( + + {children} + + ); +} diff --git a/core/app/[locale]/(default)/(auth)/login/_actions/login.ts b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts index 640ccc5d4f..b0f1b5f37f 100644 --- a/core/app/[locale]/(default)/(auth)/login/_actions/login.ts +++ b/core/app/[locale]/(default)/(auth)/login/_actions/login.ts @@ -8,7 +8,7 @@ import { getLocale, getTranslations } from 'next-intl/server'; import { schema } from '@/vibes/soul/sections/sign-in-section/schema'; import { signIn } from '~/auth'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getCartId } from '~/lib/cart'; export const login = async ( diff --git a/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx b/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx index 58dd54691f..b5758b8033 100644 --- a/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/forgot-password/page.tsx @@ -1,29 +1,19 @@ import { Metadata } from 'next'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { ForgotPasswordSection } from '@/vibes/soul/sections/forgot-password-section'; import { resetPassword } from './_actions/reset-password'; -interface Props { - params: Promise<{ locale: string }>; -} - -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Auth.Login.ForgotPassword' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Auth.Login.ForgotPassword'); return { title: t('title'), }; } -export default async function Reset(props: Props) { - const { locale } = await props.params; - - setRequestLocale(locale); - +export default async function Reset() { const t = await getTranslations('Auth.Login.ForgotPassword'); return ( diff --git a/core/app/[locale]/(default)/(auth)/login/page.tsx b/core/app/[locale]/(default)/(auth)/login/page.tsx index dadb1eeb5c..d641a7ad73 100644 --- a/core/app/[locale]/(default)/(auth)/login/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/page.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/jsx-no-bind */ import { Metadata } from 'next'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { ButtonLink } from '@/vibes/soul/primitives/button-link'; import { SignInSection } from '@/vibes/soul/sections/sign-in-section'; @@ -17,22 +17,17 @@ interface Props { }>; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Auth.Login' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Auth.Login'); return { title: t('title'), }; } -export default async function Login({ params, searchParams }: Props) { - const { locale } = await params; +export default async function Login({ searchParams }: Props) { const { redirectTo = '/account/orders', error } = await searchParams; - setRequestLocale(locale); - const t = await getTranslations('Auth.Login'); const vanityUrl = buildConfig.get('urls').vanityUrl; diff --git a/core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts b/core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts index 16f806330a..e7e092e828 100644 --- a/core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts +++ b/core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts @@ -35,5 +35,3 @@ export async function GET(_: Request, { params }: { params: Promise<{ token: str redirect(`/login?error=InvalidToken`); } } - -export const dynamic = 'force-dynamic'; diff --git a/core/app/[locale]/(default)/(auth)/logout/route.ts b/core/app/[locale]/(default)/(auth)/logout/route.ts index 713cc99acb..a433a34278 100644 --- a/core/app/[locale]/(default)/(auth)/logout/route.ts +++ b/core/app/[locale]/(default)/(auth)/logout/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server'; import { signOut } from '~/auth'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { setForceRefreshCookie } from '~/lib/force-refresh'; export const GET = async ( diff --git a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts index 29c5fee3ed..eed974d307 100644 --- a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts +++ b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts @@ -12,7 +12,7 @@ import { signIn } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { FieldNameToFieldId } from '~/data-transformers/form-field-transformer/utils'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getCartId } from '~/lib/cart'; import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; diff --git a/core/app/[locale]/(default)/(auth)/register/page-data.ts b/core/app/[locale]/(default)/(auth)/register/page-data.ts index 461237ffa1..d0be4f875e 100644 --- a/core/app/[locale]/(default)/(auth)/register/page-data.ts +++ b/core/app/[locale]/(default)/(auth)/register/page-data.ts @@ -1,8 +1,9 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; import { FormFieldsFragment } from '~/data-transformers/form-field-transformer/fragment'; const RegisterCustomerQuery = graphql( @@ -61,8 +62,10 @@ interface Props { }; } -export const getRegisterCustomerQuery = cache(async ({ address, customer }: Props) => { - const customerAccessToken = await getSessionCustomerAccessToken(); +async function getCachedRegisterCustomerQuery(locale: string, { address, customer }: Props) { + 'use cache'; + + cacheLife({ revalidate }); const response = await client.fetch({ document: RegisterCustomerQuery, @@ -73,7 +76,7 @@ export const getRegisterCustomerQuery = cache(async ({ address, customer }: Prop customerSortBy: customer?.sortBy, }, fetchOptions: { cache: 'no-store' }, - customerAccessToken, + locale, }); const addressFields = response.data.site.settings?.formFields.shippingAddress; @@ -92,4 +95,8 @@ export const getRegisterCustomerQuery = cache(async ({ address, customer }: Prop countries, passwordComplexitySettings, }; +} + +export const getRegisterCustomerQuery = cache(async (locale: string, props: Props) => { + return getCachedRegisterCustomerQuery(locale, props); }); diff --git a/core/app/[locale]/(default)/(auth)/register/page.tsx b/core/app/[locale]/(default)/(auth)/register/page.tsx index 23004a7b8f..e96d8bc52f 100644 --- a/core/app/[locale]/(default)/(auth)/register/page.tsx +++ b/core/app/[locale]/(default)/(auth)/register/page.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { Field } from '@/vibes/soul/form/dynamic-form/schema'; import { DynamicFormSection } from '@/vibes/soul/sections/dynamic-form-section'; @@ -24,10 +24,8 @@ interface Props { params: Promise<{ locale: string }>; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Auth.Register' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Auth.Register'); return { title: t('title'), @@ -48,11 +46,9 @@ function removeExlusiveOffersField(field: Field | Field[]): boolean { export default async function Register({ params }: Props) { const { locale } = await params; - setRequestLocale(locale); - const t = await getTranslations('Auth.Register'); - const registerCustomerData = await getRegisterCustomerQuery({ + const registerCustomerData = await getRegisterCustomerQuery(locale, { address: { sortBy: 'SORT_ORDER' }, customer: { sortBy: 'SORT_ORDER' }, }); 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..16db07c61c 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -38,13 +39,35 @@ const BrandPageQuery = graphql(` } `); -export const getBrandPageData = cache(async (entityId: number, customerAccessToken?: string) => { +async function getCachedBrandPageData(locale: string, entityId: number) { + 'use cache'; + + cacheLife({ revalidate }); + const response = await client.fetch({ document: BrandPageQuery, variables: { entityId }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return response.data.site; -}); +} + +export const getBrandPageData = cache( + async (locale: string, entityId: number, customerAccessToken?: string) => { + if (customerAccessToken) { + const response = await client.fetch({ + document: BrandPageQuery, + variables: { entityId }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return response.data.site; + } + + return getCachedBrandPageData(locale, entityId); + }, +); diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx index 4278def31c..3657ddac1b 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getLocale, getTranslations } from 'next-intl/server'; import { createLoader, SearchParams } from 'nuqs/server'; import { cache } from 'react'; @@ -30,9 +30,14 @@ const getCachedBrand = cache((brandId: string) => { const compareLoader = createCompareLoader(); const createBrandSearchParamsLoader = cache( - async (brandId: string, customerAccessToken?: string) => { + async (locale: string, brandId: string, customerAccessToken?: string) => { const cachedBrand = getCachedBrand(brandId); - const brandSearch = await fetchFacetedSearch(cachedBrand, undefined, customerAccessToken); + const brandSearch = await fetchFacetedSearch( + locale, + cachedBrand, + undefined, + customerAccessToken, + ); const brandFacets = brandSearch.facets.items.filter( (facet) => facet.__typename !== 'BrandSearchFilter', ); @@ -68,12 +73,14 @@ interface Props { } export async function generateMetadata(props: Props): Promise { - const { slug, locale } = await props.params; - const customerAccessToken = await getSessionCustomerAccessToken(); + const [{ slug, locale }, customerAccessToken] = await Promise.all([ + props.params, + getSessionCustomerAccessToken(), + ]); const brandId = Number(slug); - const { brand } = await getBrandPageData(brandId, customerAccessToken); + const { brand } = await getBrandPageData(locale, brandId, customerAccessToken); if (!brand) { return notFound(); @@ -89,37 +96,55 @@ export async function generateMetadata(props: Props): Promise { }; } -export default async function Brand(props: Props) { - const { locale, slug } = await props.params; - const customerAccessToken = await getSessionCustomerAccessToken(); +export default async function Brand({ params, searchParams }: Props) { + const locale = await getLocale(); + const t = await getTranslations('Faceted'); - setRequestLocale(locale); + // Cached (guest) brand data for the static shell — always uses the cached path + // so title, settings, showRating resolve instantly from 'use cache' during PPR. + const streamableCachedBrandData = Streamable.from(async () => { + const { slug } = await params; - const t = await getTranslations('Faceted'); + return getBrandPageData(locale, Number(slug)); + }); - const brandId = Number(slug); + const streamableTitle = Streamable.from(async () => { + const { brand } = await streamableCachedBrandData; - const { brand, settings } = await getBrandPageData(brandId, customerAccessToken); + if (!brand) { + return notFound(); + } - if (!brand) { - return notFound(); - } + return brand.name; + }); + + const streamableShowRating = Streamable.from(async () => { + const { settings } = await streamableCachedBrandData; - const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + return Boolean(settings?.reviews.enabled && settings.display.showProductRating); + }); - const productComparisonsEnabled = - settings?.storefront.catalog?.productComparisonsEnabled ?? false; + const streamableShowCompare = Streamable.from(async () => { + const { settings } = await streamableCachedBrandData; + + return settings?.storefront.catalog?.productComparisonsEnabled ?? false; + }); const streamableFacetedSearch = Streamable.from(async () => { - const searchParams = await props.searchParams; - const currencyCode = await getPreferredCurrencyCode(); + const { slug } = await params; + const searchParamsResolved = await searchParams; + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); - const loadSearchParams = await createBrandSearchParamsLoader(slug, customerAccessToken); - const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; + const loadSearchParams = await createBrandSearchParamsLoader(locale, slug, customerAccessToken); + const parsedSearchParams = loadSearchParams?.(searchParamsResolved) ?? {}; const search = await fetchFacetedSearch( + locale, { - ...searchParams, + ...searchParamsResolved, ...parsedSearchParams, brand: [slug], }, @@ -131,7 +156,7 @@ export default async function Brand(props: Props) { }); const streamableProducts = Streamable.from(async () => { - const format = await getFormatter(); + const [format, { settings }] = await Promise.all([getFormatter(), streamableCachedBrandData]); const search = await streamableFacetedSearch; const products = search.products.items; @@ -161,11 +186,19 @@ export default async function Brand(props: Props) { }); const streamableFilters = Streamable.from(async () => { - const searchParams = await props.searchParams; - const loadSearchParams = await createBrandSearchParamsLoader(slug, customerAccessToken); - const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; + const { slug } = await params; + const searchParamsResolved = await searchParams; + const customerAccessToken = await getSessionCustomerAccessToken(); + + const loadSearchParams = await createBrandSearchParamsLoader(locale, slug, customerAccessToken); + const parsedSearchParams = loadSearchParams?.(searchParamsResolved) ?? {}; const cachedBrand = getCachedBrand(slug); - const categorySearch = await fetchFacetedSearch(cachedBrand, undefined, customerAccessToken); + const categorySearch = await fetchFacetedSearch( + locale, + cachedBrand, + undefined, + customerAccessToken, + ); const refinedSearch = await streamableFacetedSearch; const allFacets = categorySearch.facets.items.filter( @@ -178,24 +211,26 @@ export default async function Brand(props: Props) { const transformedFacets = await facetsTransformer({ refinedFacets, allFacets, - searchParams: { ...searchParams, ...parsedSearchParams }, + searchParams: { ...searchParamsResolved, ...parsedSearchParams }, }); return transformedFacets.filter((facet) => facet != null); }); const streamableCompareProducts = Streamable.from(async () => { - const searchParams = await props.searchParams; + const searchParamsResolved = await searchParams; + const showCompare = await streamableShowCompare; - if (!productComparisonsEnabled) { + if (!showCompare) { return []; } - const { compare } = compareLoader(searchParams); + const customerAccessToken = await getSessionCustomerAccessToken(); + const { compare } = compareLoader(searchParamsResolved); const compareIds = { entityIds: compare ? compare.map((id: string) => Number(id)) : [] }; - const products = await getCompareProductsData(compareIds, customerAccessToken); + const products = await getCompareProductsData(locale, compareIds, customerAccessToken); return products.map((product) => ({ id: product.entityId.toString(), @@ -223,8 +258,8 @@ export default async function Brand(props: Props) { rangeFilterApplyLabel={t('FacetedSearch.Range.apply')} removeLabel={t('Compare.remove')} resetFiltersLabel={t('FacetedSearch.resetFilters')} - showCompare={productComparisonsEnabled} - showRating={showRating} + showCompare={streamableShowCompare} + showRating={streamableShowRating} sortDefaultValue="featured" sortLabel={t('Search.title')} sortOptions={[ @@ -239,7 +274,7 @@ export default async function Brand(props: Props) { { value: 'relevance', label: t('SortBy.relevance') }, ]} sortParamName="sort" - title={brand.name} + title={streamableTitle} totalCount={streamableTotalCount} /> ); diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts index 6c2c4633fe..c11eb16937 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -58,13 +59,35 @@ const CategoryPageQuery = graphql( [BreadcrumbsCategoryFragment], ); -export const getCategoryPageData = cache(async (entityId: number, customerAccessToken?: string) => { +async function getCachedCategoryPageData(locale: string, entityId: number) { + 'use cache'; + + cacheLife({ revalidate }); + const response = await client.fetch({ document: CategoryPageQuery, variables: { entityId }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return response.data.site; -}); +} + +export const getCategoryPageData = cache( + async (locale: string, entityId: number, customerAccessToken?: string) => { + if (customerAccessToken) { + const response = await client.fetch({ + document: CategoryPageQuery, + variables: { entityId }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return response.data.site; + } + + return getCachedCategoryPageData(locale, entityId); + }, +); diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index ee143281b5..3e38a61c68 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -1,7 +1,7 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getLocale, getTranslations } from 'next-intl/server'; import { createLoader, SearchParams } from 'nuqs/server'; import { cache } from 'react'; @@ -32,9 +32,14 @@ const getCachedCategory = cache((categoryId: number) => { const compareLoader = createCompareLoader(); const createCategorySearchParamsLoader = cache( - async (categoryId: number, customerAccessToken?: string) => { + async (locale: string, categoryId: number, customerAccessToken?: string) => { const cachedCategory = getCachedCategory(categoryId); - const categorySearch = await fetchFacetedSearch(cachedCategory, undefined, customerAccessToken); + const categorySearch = await fetchFacetedSearch( + locale, + cachedCategory, + undefined, + customerAccessToken, + ); const categoryFacets = categorySearch.facets.items.filter( (facet) => facet.__typename !== 'CategorySearchFilter', ); @@ -70,12 +75,14 @@ interface Props { } export async function generateMetadata(props: Props): Promise { - const { slug, locale } = await props.params; - const customerAccessToken = await getSessionCustomerAccessToken(); + const [{ slug, locale }, customerAccessToken] = await Promise.all([ + props.params, + getSessionCustomerAccessToken(), + ]); const categoryId = Number(slug); - const { category } = await getCategoryPageData(categoryId, customerAccessToken); + const { category } = await getCategoryPageData(locale, categoryId, customerAccessToken); if (!category) { return notFound(); @@ -96,48 +103,82 @@ export async function generateMetadata(props: Props): Promise { }; } -export default async function Category(props: Props) { - const { slug, locale } = await props.params; - const customerAccessToken = await getSessionCustomerAccessToken(); +export default async function Category({ params, searchParams }: Props) { + const locale = await getLocale(); + const t = await getTranslations('Faceted'); - setRequestLocale(locale); + // Category data for the static shell. Uses precomputed auth flag: + // Cached (guest) category data for the static shell — always uses the cached path + // so title, breadcrumbs, settings resolve instantly from 'use cache' during PPR. + const streamableCachedCategoryData = Streamable.from(async () => { + const { slug } = await params; - const t = await getTranslations('Faceted'); + return getCategoryPageData(locale, Number(slug)); + }); - const categoryId = Number(slug); + // Auth-dependent category data for personalized content (analytics). + const streamableCategoryData = Streamable.from(async () => { + const { slug } = await params; + const customerAccessToken = await getSessionCustomerAccessToken(); - const { category, settings, categoryTree } = await getCategoryPageData( - categoryId, - customerAccessToken, - ); + return getCategoryPageData(locale, Number(slug), customerAccessToken); + }); - if (!category) { - return notFound(); - } + const streamableTitle = Streamable.from(async () => { + const { category } = await streamableCachedCategoryData; + + if (!category) { + return notFound(); + } + + return category.name; + }); + + const streamableBreadcrumbs = Streamable.from(async () => { + const { category } = await streamableCachedCategoryData; + + if (!category) { + return []; + } + + return removeEdgesAndNodes(category.breadcrumbs).map(({ name, path }) => ({ + label: name, + href: path ?? '#', + })); + }); + + const streamableShowRating = Streamable.from(async () => { + const { settings } = await streamableCachedCategoryData; - const breadcrumbs = removeEdgesAndNodes(category.breadcrumbs).map(({ name, path }) => ({ - label: name, - href: path ?? '#', - })); + return Boolean(settings?.reviews.enabled && settings.display.showProductRating); + }); - const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + const streamableShowCompare = Streamable.from(async () => { + const { settings } = await streamableCachedCategoryData; - const productComparisonsEnabled = - settings?.storefront.catalog?.productComparisonsEnabled ?? false; + return settings?.storefront.catalog?.productComparisonsEnabled ?? false; + }); const streamableFacetedSearch = Streamable.from(async () => { - const searchParams = await props.searchParams; - const currencyCode = await getPreferredCurrencyCode(); + const { slug } = await params; + const categoryId = Number(slug); + const searchParamsResolved = await searchParams; + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); const loadSearchParams = await createCategorySearchParamsLoader( + locale, categoryId, customerAccessToken, ); - const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; + const parsedSearchParams = loadSearchParams?.(searchParamsResolved) ?? {}; const search = await fetchFacetedSearch( + locale, { - ...searchParams, + ...searchParamsResolved, ...parsedSearchParams, category: categoryId, }, @@ -149,7 +190,10 @@ export default async function Category(props: Props) { }); const streamableProducts = Streamable.from(async () => { - const format = await getFormatter(); + const [format, { settings }] = await Promise.all([ + getFormatter(), + streamableCachedCategoryData, + ]); const search = await streamableFacetedSearch; const products = search.products.items; @@ -179,15 +223,25 @@ export default async function Category(props: Props) { }); const streamableFilters = Streamable.from(async () => { - const searchParams = await props.searchParams; + const { slug } = await params; + const categoryId = Number(slug); + const searchParamsResolved = await searchParams; + const customerAccessToken = await getSessionCustomerAccessToken(); + const { categoryTree } = await streamableCachedCategoryData; const loadSearchParams = await createCategorySearchParamsLoader( + locale, categoryId, customerAccessToken, ); - const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; + const parsedSearchParams = loadSearchParams?.(searchParamsResolved) ?? {}; const cachedCategory = getCachedCategory(categoryId); - const categorySearch = await fetchFacetedSearch(cachedCategory, undefined, customerAccessToken); + const categorySearch = await fetchFacetedSearch( + locale, + cachedCategory, + undefined, + customerAccessToken, + ); const refinedSearch = await streamableFacetedSearch; const allFacets = categorySearch.facets.items.filter( @@ -200,7 +254,7 @@ export default async function Category(props: Props) { const transformedFacets = await facetsTransformer({ refinedFacets, allFacets, - searchParams: { ...searchParams, ...parsedSearchParams }, + searchParams: { ...searchParamsResolved, ...parsedSearchParams }, }); const filters = transformedFacets.filter((facet) => facet != null); @@ -224,17 +278,19 @@ export default async function Category(props: Props) { }); const streamableCompareProducts = Streamable.from(async () => { - const searchParams = await props.searchParams; + const searchParamsResolved = await searchParams; + const showCompare = await streamableShowCompare; - if (!productComparisonsEnabled) { + if (!showCompare) { return []; } - const { compare } = compareLoader(searchParams); + const customerAccessToken = await getSessionCustomerAccessToken(); + const { compare } = compareLoader(searchParamsResolved); const compareIds = { entityIds: compare ? compare.map((id: string) => Number(id)) : [] }; - const products = await getCompareProducts(compareIds, customerAccessToken); + const products = await getCompareProducts(locale, compareIds, customerAccessToken); return products.map((product) => ({ id: product.entityId.toString(), @@ -249,7 +305,7 @@ export default async function Category(props: Props) { return ( <> - - {(search) => } + + {({ category }) => ( + + {(search) => + category && + } + + )} ); diff --git a/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts b/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts index 8033dc8235..de24f05319 100644 --- a/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts +++ b/core/app/[locale]/(default)/(faceted)/fetch-compare-products.ts @@ -1,5 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { VariablesOf } from 'gql.tada'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { z } from 'zod'; @@ -42,21 +43,47 @@ const CompareProductsQuery = graphql(` type Variables = VariablesOf; +async function getCachedCompareProducts(locale: string, variables: Variables) { + 'use cache'; + + cacheLife({ revalidate }); + + const parsedVariables = CompareProductsSchema.parse(variables); + + if (parsedVariables.entityIds.length === 0) { + return []; + } + + const response = await client.fetch({ + document: CompareProductsQuery, + variables: { ...parsedVariables, first: MAX_COMPARE_LIMIT }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return removeEdgesAndNodes(response.data.site.products); +} + export const getCompareProducts = cache( - async (variables: Variables, customerAccessToken?: string) => { - const parsedVariables = CompareProductsSchema.parse(variables); + async (locale: string, variables: Variables, customerAccessToken?: string) => { + if (customerAccessToken) { + const parsedVariables = CompareProductsSchema.parse(variables); - if (parsedVariables.entityIds.length === 0) { - return []; - } + if (parsedVariables.entityIds.length === 0) { + return []; + } - const response = await client.fetch({ - document: CompareProductsQuery, - variables: { ...parsedVariables, first: MAX_COMPARE_LIMIT }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); + const response = await client.fetch({ + document: CompareProductsQuery, + variables: { ...parsedVariables, first: MAX_COMPARE_LIMIT }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return removeEdgesAndNodes(response.data.site.products); + } - return removeEdgesAndNodes(response.data.site.products); + return getCachedCompareProducts(locale, variables); }, ); diff --git a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts index 115d639d1c..c909cbc3ca 100644 --- a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts +++ b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts @@ -1,4 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { z } from 'zod'; @@ -178,69 +179,146 @@ interface ProductSearch { filters: SearchProductsFiltersInput; } +async function getCachedProductSearchResults( + locale: string, + { limit = 9, after, before, sort, filters }: ProductSearch, + currencyCode?: CurrencyCode, +) { + 'use cache'; + + cacheLife({ revalidate: 300 }); + + const filterArgs = { filters, sort }; + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + + const response = await client.fetch({ + document: GetProductSearchResultsQuery, + variables: { ...filterArgs, ...paginationArgs, currencyCode }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + const { site } = response.data; + const searchResults = site.search.searchProducts; + + const items = removeEdgesAndNodes(searchResults.products).map((product) => ({ + ...product, + })); + + return { + facets: { + items: removeEdgesAndNodes(searchResults.filters).map((node) => { + switch (node.__typename) { + case 'BrandSearchFilter': + return { + ...node, + brands: removeEdgesAndNodes(node.brands), + }; + + case 'CategorySearchFilter': + return { + ...node, + categories: removeEdgesAndNodes(node.categories), + }; + + case 'ProductAttributeSearchFilter': + return { + ...node, + attributes: removeEdgesAndNodes(node.attributes), + }; + + case 'RatingSearchFilter': + return { + ...node, + ratings: removeEdgesAndNodes(node.ratings), + }; + + default: + return node; + } + }), + }, + products: { + collectionInfo: searchResults.products.collectionInfo, + pageInfo: searchResults.products.pageInfo, + items, + }, + }; +} + const getProductSearchResults = cache( + // We need to make sure the reference passed into this function is the same if we want it to be memoized. async ( + locale: string, { limit = 9, after, before, sort, filters }: ProductSearch, currencyCode?: CurrencyCode, customerAccessToken?: string, ) => { - const filterArgs = { filters, sort }; - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + if (customerAccessToken) { + const filterArgs = { filters, sort }; + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; - const response = await client.fetch({ - document: GetProductSearchResultsQuery, - variables: { ...filterArgs, ...paginationArgs, currencyCode }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 300 } }, - }); + const response = await client.fetch({ + document: GetProductSearchResultsQuery, + variables: { ...filterArgs, ...paginationArgs, currencyCode }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); - const { site } = response.data; + const { site } = response.data; + const searchResults = site.search.searchProducts; - const searchResults = site.search.searchProducts; + const items = removeEdgesAndNodes(searchResults.products).map((product) => ({ + ...product, + })); - const items = removeEdgesAndNodes(searchResults.products).map((product) => ({ - ...product, - })); + return { + facets: { + items: removeEdgesAndNodes(searchResults.filters).map((node) => { + switch (node.__typename) { + case 'BrandSearchFilter': + return { + ...node, + brands: removeEdgesAndNodes(node.brands), + }; - return { - facets: { - items: removeEdgesAndNodes(searchResults.filters).map((node) => { - switch (node.__typename) { - case 'BrandSearchFilter': - return { - ...node, - brands: removeEdgesAndNodes(node.brands), - }; - - case 'CategorySearchFilter': - return { - ...node, - categories: removeEdgesAndNodes(node.categories), - }; - - case 'ProductAttributeSearchFilter': - return { - ...node, - attributes: removeEdgesAndNodes(node.attributes), - }; - - case 'RatingSearchFilter': - return { - ...node, - ratings: removeEdgesAndNodes(node.ratings), - }; - - default: - return node; - } - }), - }, - products: { - collectionInfo: searchResults.products.collectionInfo, - pageInfo: searchResults.products.pageInfo, - items, - }, - }; + case 'CategorySearchFilter': + return { + ...node, + categories: removeEdgesAndNodes(node.categories), + }; + + case 'ProductAttributeSearchFilter': + return { + ...node, + attributes: removeEdgesAndNodes(node.attributes), + }; + + case 'RatingSearchFilter': + return { + ...node, + ratings: removeEdgesAndNodes(node.ratings), + }; + + default: + return node; + } + }), + }, + products: { + collectionInfo: searchResults.products.collectionInfo, + pageInfo: searchResults.products.pageInfo, + items, + }, + }; + } + + return getCachedProductSearchResults( + locale, + { limit, after, before, sort, filters }, + currencyCode, + ); }, ); @@ -406,6 +484,7 @@ export const PublicToPrivateParams = PublicSearchParamsSchema.catchall(SearchPar export const fetchFacetedSearch = cache( // We need to make sure the reference passed into this function is the same if we want it to be memoized. async ( + locale: string, params: z.input, currencyCode?: CurrencyCode, customerAccessToken?: string, @@ -413,6 +492,7 @@ export const fetchFacetedSearch = cache( const { after, before, limit = 9, sort, filters } = PublicToPrivateParams.parse(params); return getProductSearchResults( + locale, { after, before, diff --git a/core/app/[locale]/(default)/(faceted)/search/page-data.ts b/core/app/[locale]/(default)/(faceted)/search/page-data.ts index 37f571c49b..0c5798b03b 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/search/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -29,11 +30,20 @@ const SearchPageQuery = graphql(` } `); -export const getSearchPageData = cache(async () => { +async function getCachedSearchPageData(locale: string) { + 'use cache'; + + cacheLife({ revalidate }); + const response = await client.fetch({ document: SearchPageQuery, - fetchOptions: { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return response.data.site; +} + +export const getSearchPageData = cache(async (locale: string) => { + return getCachedSearchPageData(locale); }); diff --git a/core/app/[locale]/(default)/(faceted)/search/page.tsx b/core/app/[locale]/(default)/(faceted)/search/page.tsx index bc86471c32..469086e6a5 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/search/page.tsx @@ -1,5 +1,5 @@ import { Metadata } from 'next'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { createLoader, SearchParams } from 'nuqs/server'; import { cache } from 'react'; @@ -22,14 +22,14 @@ import { getSearchPageData } from './page-data'; const compareLoader = createCompareLoader(); const createSearchSearchParamsLoader = cache( - async (searchParams: SearchParams, customerAccessToken?: string) => { + async (locale: string, searchParams: SearchParams, customerAccessToken?: string) => { const searchTerm = typeof searchParams.term === 'string' ? searchParams.term : ''; if (!searchTerm) { return null; } - const search = await fetchFacetedSearch(searchParams, undefined, customerAccessToken); + const search = await fetchFacetedSearch(locale, searchParams, undefined, customerAccessToken); const searchFacets = search.facets.items; const transformedSearchFacets = await facetsTransformer({ refinedFacets: searchFacets, @@ -59,10 +59,8 @@ interface Props { searchParams: Promise; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Faceted.Search' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Faceted.Search'); return { title: t('title'), @@ -72,11 +70,9 @@ export async function generateMetadata({ params }: Props): Promise { export default async function Search(props: Props) { const { locale } = await props.params; - setRequestLocale(locale); - const t = await getTranslations('Faceted'); - const { settings } = await getSearchPageData(); + const { settings } = await getSearchPageData(locale); const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); @@ -89,12 +85,14 @@ export default async function Search(props: Props) { const currencyCode = await getPreferredCurrencyCode(); const loadSearchParams = await createSearchSearchParamsLoader( + locale, searchParams, customerAccessToken, ); const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; const search = await fetchFacetedSearch( + locale, { ...searchParams, ...parsedSearchParams, @@ -186,11 +184,12 @@ export default async function Search(props: Props) { } const loadSearchParams = await createSearchSearchParamsLoader( + locale, searchParams, customerAccessToken, ); const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; - const categorySearch = await fetchFacetedSearch({}, undefined, customerAccessToken); + const categorySearch = await fetchFacetedSearch(locale, {}, undefined, customerAccessToken); const refinedSearch = await streamableFacetedSearch; const allFacets = categorySearch.facets.items.filter( @@ -221,7 +220,7 @@ export default async function Search(props: Props) { const compareIds = { entityIds: compare ? compare.map((id: string) => Number(id)) : [] }; - const products = await getCompareProductsData(compareIds, customerAccessToken); + const products = await getCompareProductsData(locale, compareIds, customerAccessToken); return products.map((product) => ({ id: product.entityId.toString(), diff --git a/core/app/[locale]/(default)/account/addresses/page-data.ts b/core/app/[locale]/(default)/account/addresses/page-data.ts index 66433985ff..f44e833967 100644 --- a/core/app/[locale]/(default)/account/addresses/page-data.ts +++ b/core/app/[locale]/(default)/account/addresses/page-data.ts @@ -1,7 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql } from '~/client/graphql'; @@ -70,13 +69,17 @@ interface Pagination { } export const getCustomerAddresses = cache( - async ({ before = '', after = '', limit = 10 }: Pagination) => { - const customerAccessToken = await getSessionCustomerAccessToken(); + async ( + locale: string, + { before = '', after = '', limit = 10 }: Pagination, + customerAccessToken?: string, + ) => { const paginationArgs = before ? { last: limit, before } : { first: limit, after }; const response = await client.fetch({ document: GetCustomerAddressesQuery, variables: { ...paginationArgs }, + locale, customerAccessToken, fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, }); diff --git a/core/app/[locale]/(default)/account/addresses/page.tsx b/core/app/[locale]/(default)/account/addresses/page.tsx index 49c02451a6..7495afc27d 100644 --- a/core/app/[locale]/(default)/account/addresses/page.tsx +++ b/core/app/[locale]/(default)/account/addresses/page.tsx @@ -1,8 +1,10 @@ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; import { Address, AddressListSection } from '@/vibes/soul/sections/address-list-section'; +import { getSessionCustomerAccessToken } from '~/auth'; import { formFieldTransformer, injectCountryCodeOptions, @@ -26,28 +28,39 @@ interface Props { }>; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Account.Addresses' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Account.Addresses'); return { title: t('title'), }; } -export default async function Addresses({ params, searchParams }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - - const t = await getTranslations('Account.Addresses'); - const { before, after } = await searchParams; - - const data = await getCustomerAddresses({ - ...(after && { after }), - ...(before && { before }), - }); +async function AddressesContent({ + locale, + searchParams, +}: { + locale: string; + searchParams: Promise<{ + [key: string]: string | string[] | undefined; + before?: string; + after?: string; + }>; +}) { + const [customerAccessToken, t, { before, after }] = await Promise.all([ + getSessionCustomerAccessToken(), + getTranslations('Account.Addresses'), + searchParams, + ]); + + const data = await getCustomerAddresses( + locale, + { + ...(after && { after }), + ...(before && { before }), + }, + customerAccessToken, + ); if (!data) { notFound(); @@ -111,3 +124,13 @@ export default async function Addresses({ params, searchParams }: Props) { /> ); } + +export default async function Addresses(props: Props) { + const { locale } = await props.params; + + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/account/layout.tsx b/core/app/[locale]/(default)/account/layout.tsx index 5aa581dd47..d2713ffa42 100644 --- a/core/app/[locale]/(default)/account/layout.tsx +++ b/core/app/[locale]/(default)/account/layout.tsx @@ -1,18 +1,10 @@ -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { PropsWithChildren } from 'react'; import { SidebarMenu } from '@/vibes/soul/sections/sidebar-menu'; import { StickySidebarLayout } from '@/vibes/soul/sections/sticky-sidebar-layout'; -interface Props extends PropsWithChildren { - params: Promise<{ locale: string }>; -} - -export default async function Layout({ children, params }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - +export default async function Layout({ children }: PropsWithChildren) { const t = await getTranslations('Account.Layout'); return ( diff --git a/core/app/[locale]/(default)/account/orders/[id]/page-data.tsx b/core/app/[locale]/(default)/account/orders/[id]/page-data.tsx index 9fd6c21afc..c69981298a 100644 --- a/core/app/[locale]/(default)/account/orders/[id]/page-data.tsx +++ b/core/app/[locale]/(default)/account/orders/[id]/page-data.tsx @@ -1,7 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; @@ -155,9 +154,7 @@ const CustomerOrderDetails = graphql( [OrderItemFragment, OrderGiftCertificateItemFragment], ); -export const getCustomerOrderDetails = cache(async (id: number) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - +export const getCustomerOrderDetails = cache(async (id: number, customerAccessToken?: string) => { const response = await client.fetch({ document: CustomerOrderDetails, variables: { diff --git a/core/app/[locale]/(default)/account/orders/[id]/page.tsx b/core/app/[locale]/(default)/account/orders/[id]/page.tsx index 20e166389f..eb4d39b56e 100644 --- a/core/app/[locale]/(default)/account/orders/[id]/page.tsx +++ b/core/app/[locale]/(default)/account/orders/[id]/page.tsx @@ -1,8 +1,9 @@ import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { OrderDetailsSection } from '@/vibes/soul/sections/order-details-section'; +import { getSessionCustomerAccessToken } from '~/auth'; import { orderDetailsTransformer } from '~/data-transformers/order-details-transformer'; import { getCustomerOrderDetails } from './page-data'; @@ -14,16 +15,14 @@ interface Props { }>; } -export default async function OrderDetails(props: Props) { - const { id, locale } = await props.params; - - setRequestLocale(locale); - +export default async function OrderDetails({ params }: Props) { const t = await getTranslations('Account.Orders.Details'); const format = await getFormatter(); const streamableOrder = Streamable.from(async () => { - const order = await getCustomerOrderDetails(Number(id)); + const { id } = await params; + const customerAccessToken = await getSessionCustomerAccessToken(); + const order = await getCustomerOrderDetails(Number(id), customerAccessToken); if (!order) { notFound(); @@ -32,6 +31,12 @@ export default async function OrderDetails(props: Props) { return orderDetailsTransformer(order, t, format); }); + const streamableTitle = Streamable.from(async () => { + const { id } = await params; + + return t('title', { orderNumber: id }); + }); + return ( ); } diff --git a/core/app/[locale]/(default)/account/orders/page-data.ts b/core/app/[locale]/(default)/account/orders/page-data.ts index a2b240f4f4..85b3f32c9f 100644 --- a/core/app/[locale]/(default)/account/orders/page-data.ts +++ b/core/app/[locale]/(default)/account/orders/page-data.ts @@ -1,7 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql, VariablesOf } from '~/client/graphql'; @@ -89,14 +88,11 @@ interface CustomerOrdersArgs { } export const getCustomerOrders = cache( - async ({ - before = '', - after = '', - filterByStatus, - filterByDateRange, - limit = 5, - }: CustomerOrdersArgs) => { - const customerAccessToken = await getSessionCustomerAccessToken(); + async ( + locale: string, + { before = '', after = '', filterByStatus, filterByDateRange, limit = 5 }: CustomerOrdersArgs, + customerAccessToken?: string, + ) => { const paginationArgs = before ? { last: limit, before } : { first: limit, after }; const filtersArgs = { filters: { @@ -107,6 +103,7 @@ export const getCustomerOrders = cache( const response = await client.fetch({ document: CustomerAllOrders, variables: { ...paginationArgs, ...filtersArgs }, + locale, customerAccessToken, fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, errorPolicy: 'auth', diff --git a/core/app/[locale]/(default)/account/orders/page.tsx b/core/app/[locale]/(default)/account/orders/page.tsx index 65d4167426..20aa2b8c63 100644 --- a/core/app/[locale]/(default)/account/orders/page.tsx +++ b/core/app/[locale]/(default)/account/orders/page.tsx @@ -1,6 +1,8 @@ -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; +import { Streamable } from '@/vibes/soul/lib/streamable'; import { Order, OrderList } from '@/vibes/soul/sections/order-list'; +import { getSessionCustomerAccessToken } from '~/auth'; import { ordersTransformer } from '~/data-transformers/orders-transformer'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; @@ -15,12 +17,21 @@ interface Props { }>; } -async function getOrders(after?: string, before?: string): Promise { +async function getOrders( + locale: string, + after?: string, + before?: string, + customerAccessToken?: string, +): Promise { const format = await getFormatter(); - const customerOrdersDetails = await getCustomerOrders({ - ...(after && { after }), - ...(before && { before }), - }); + const customerOrdersDetails = await getCustomerOrders( + locale, + { + ...(after && { after }), + ...(before && { before }), + }, + customerAccessToken, + ); if (!customerOrdersDetails) { return []; @@ -31,11 +42,20 @@ async function getOrders(after?: string, before?: string): Promise { return ordersTransformer(orders, format); } -async function getPaginationInfo(after?: string, before?: string) { - const customerOrdersDetails = await getCustomerOrders({ - ...(after && { after }), - ...(before && { before }), - }); +async function getPaginationInfo( + locale: string, + after?: string, + before?: string, + customerAccessToken?: string, +) { + const customerOrdersDetails = await getCustomerOrders( + locale, + { + ...(after && { after }), + ...(before && { before }), + }, + customerAccessToken, + ); return pageInfoTransformer(customerOrdersDetails?.pageInfo ?? defaultPageInfo); } @@ -43,9 +63,6 @@ async function getPaginationInfo(after?: string, before?: string) { export default async function Orders({ params, searchParams }: Props) { const { locale } = await params; - setRequestLocale(locale); - - const { before, after } = await searchParams; const t = await getTranslations('Account.Orders'); return ( @@ -53,8 +70,22 @@ export default async function Orders({ params, searchParams }: Props) { emptyStateActionLabel={t('EmptyState.cta')} emptyStateTitle={t('EmptyState.title')} orderNumberLabel={t('orderNumber')} - orders={getOrders(after, before)} - paginationInfo={getPaginationInfo(after, before)} + orders={Streamable.from(async () => { + const [{ before, after }, customerAccessToken] = await Promise.all([ + searchParams, + getSessionCustomerAccessToken(), + ]); + + return getOrders(locale, after, before, customerAccessToken); + })} + paginationInfo={Streamable.from(async () => { + const [{ before, after }, customerAccessToken] = await Promise.all([ + searchParams, + getSessionCustomerAccessToken(), + ]); + + return getPaginationInfo(locale, after, before, customerAccessToken); + })} title={t('title')} totalLabel={t('totalPrice')} viewDetailsLabel={t('viewDetails')} diff --git a/core/app/[locale]/(default)/account/settings/page-data.tsx b/core/app/[locale]/(default)/account/settings/page-data.tsx index 136ef5c991..5c26cf569e 100644 --- a/core/app/[locale]/(default)/account/settings/page-data.tsx +++ b/core/app/[locale]/(default)/account/settings/page-data.tsx @@ -1,6 +1,5 @@ import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { TAGS } from '~/client/tags'; @@ -67,37 +66,37 @@ interface Props { }; } -export const getAccountSettingsQuery = cache(async ({ address, customer }: Props = {}) => { - const customerAccessToken = await getSessionCustomerAccessToken(); +export const getAccountSettingsQuery = cache( + async ({ address, customer }: Props = {}, customerAccessToken?: string) => { + const response = await client.fetch({ + document: AccountSettingsQuery, + variables: { + addressFilters: address?.filters, + addressSortBy: address?.sortBy, + customerFilters: customer?.filters, + customerSortBy: customer?.sortBy, + }, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, + customerAccessToken, + }); - const response = await client.fetch({ - document: AccountSettingsQuery, - variables: { - addressFilters: address?.filters, - addressSortBy: address?.sortBy, - customerFilters: customer?.filters, - customerSortBy: customer?.sortBy, - }, - fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, - customerAccessToken, - }); + const addressFields = response.data.site.settings?.formFields.shippingAddress; + const customerFields = response.data.site.settings?.formFields.customer; + const customerInfo = response.data.customer; + const newsletterSettings = response.data.site.settings?.newsletter; + const passwordComplexitySettings = + response.data.site.settings?.customers?.passwordComplexitySettings; - const addressFields = response.data.site.settings?.formFields.shippingAddress; - const customerFields = response.data.site.settings?.formFields.customer; - const customerInfo = response.data.customer; - const newsletterSettings = response.data.site.settings?.newsletter; - const passwordComplexitySettings = - response.data.site.settings?.customers?.passwordComplexitySettings; - - if (!addressFields || !customerFields || !customerInfo) { - return null; - } + if (!addressFields || !customerFields || !customerInfo) { + return null; + } - return { - addressFields, - customerFields, - customerInfo, - newsletterSettings, - passwordComplexitySettings, - }; -}); + return { + addressFields, + customerFields, + customerInfo, + newsletterSettings, + passwordComplexitySettings, + }; + }, +); diff --git a/core/app/[locale]/(default)/account/settings/page.tsx b/core/app/[locale]/(default)/account/settings/page.tsx index cad145dc6f..f0fcc19705 100644 --- a/core/app/[locale]/(default)/account/settings/page.tsx +++ b/core/app/[locale]/(default)/account/settings/page.tsx @@ -1,9 +1,11 @@ /* eslint-disable react/jsx-no-bind */ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; import { AccountSettingsSection } from '@/vibes/soul/sections/account-settings'; +import { getSessionCustomerAccessToken } from '~/auth'; import { changePassword } from './_actions/change-password'; import { updateCustomer } from './_actions/update-customer'; @@ -14,24 +16,19 @@ interface Props { params: Promise<{ locale: string }>; } -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Account.Settings' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Account.Settings'); return { title: t('title'), }; } -export default async function Settings({ params }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - +async function SettingsContent() { const t = await getTranslations('Account.Settings'); + const customerAccessToken = await getSessionCustomerAccessToken(); - const accountSettings = await getAccountSettingsQuery(); + const accountSettings = await getAccountSettingsQuery({}, customerAccessToken); if (!accountSettings) { notFound(); @@ -69,3 +66,13 @@ export default async function Settings({ params }: Props) { /> ); } + +export default async function Settings(props: Props) { + await props.params; + + return ( + + + + ); +} 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..602f3729bc 100644 --- a/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts +++ b/core/app/[locale]/(default)/account/wishlists/[id]/page-data.ts @@ -1,11 +1,10 @@ import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; +import type { CurrencyCode } from '~/components/header/fragment'; import { WishlistPaginatedItemsFragment } from '~/components/wishlist/fragment'; -import { getPreferredCurrencyCode } from '~/lib/currency'; const WishlistDetailsQuery = graphql( ` @@ -37,23 +36,30 @@ interface Pagination { after: string | null; } -export const getCustomerWishlist = cache(async (entityId: number, pagination: Pagination) => { - const { before, after, limit = 9 } = pagination; - const customerAccessToken = await getSessionCustomerAccessToken(); - const currencyCode = await getPreferredCurrencyCode(); - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; - const response = await client.fetch({ - document: WishlistDetailsQuery, - variables: { ...paginationArgs, currencyCode, entityId }, - customerAccessToken, - fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, - }); +export const getCustomerWishlist = cache( + async ( + locale: string, + entityId: number, + pagination: Pagination, + customerAccessToken?: string, + currencyCode?: CurrencyCode, + ) => { + const { before, after, limit = 9 } = pagination; + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + const response = await client.fetch({ + document: WishlistDetailsQuery, + variables: { ...paginationArgs, currencyCode, entityId }, + locale, + customerAccessToken, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, + }); - const wishlist = response.data.customer?.wishlists.edges?.[0]?.node; + const wishlist = response.data.customer?.wishlists.edges?.[0]?.node; - if (!wishlist) { - return null; - } + if (!wishlist) { + return null; + } - return wishlist; -}); + return wishlist; + }, +); diff --git a/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx b/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx index 6ea2baa869..e2115c811e 100644 --- a/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx +++ b/core/app/[locale]/(default)/account/wishlists/[id]/page.tsx @@ -1,15 +1,18 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getLocale, getTranslations } from 'next-intl/server'; import { SearchParams } from 'nuqs'; import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { CursorPaginationInfo } from '@/vibes/soul/primitives/cursor-pagination'; import { Wishlist, WishlistDetails } from '@/vibes/soul/sections/wishlist-details'; +import { getSessionCustomerAccessToken } from '~/auth'; import { ExistingResultType } from '~/client/util'; +import type { CurrencyCode } from '~/components/header/fragment'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { wishlistDetailsTransformer } from '~/data-transformers/wishlists-transformer'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; +import { getPreferredCurrencyCode } from '~/lib/currency'; import { isMobileUser } from '~/lib/user-agent'; import { removeWishlistItem } from '../_actions/remove-wishlist-item'; @@ -39,11 +42,19 @@ async function getWishlist( pt: ExistingResultType>, searchParamsPromise: Promise, locale: string, + customerAccessToken?: string, + currencyCode?: CurrencyCode, ): Promise { const entityId = Number(id); const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); const formatter = await getFormatter(); - const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + const wishlist = await getCustomerWishlist( + locale, + entityId, + searchParamsParsed, + customerAccessToken, + currencyCode, + ); if (!wishlist) { return redirect({ href: '/account/wishlists/', locale }); @@ -52,10 +63,22 @@ async function getWishlist( return wishlistDetailsTransformer(wishlist, t, pt, formatter); } -const getAnalyticsData = async (id: string, searchParamsPromise: Promise) => { +const getAnalyticsData = async ( + locale: string, + id: string, + searchParamsPromise: Promise, + customerAccessToken?: string, + currencyCode?: CurrencyCode, +) => { const entityId = Number(id); const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + const wishlist = await getCustomerWishlist( + locale, + entityId, + searchParamsParsed, + customerAccessToken, + currencyCode, + ); if (!wishlist) { return []; @@ -77,23 +100,32 @@ const getAnalyticsData = async (id: string, searchParamsPromise: Promise, + customerAccessToken?: string, + currencyCode?: CurrencyCode, ): Promise { const entityId = Number(id); const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlist = await getCustomerWishlist(entityId, searchParamsParsed); + const wishlist = await getCustomerWishlist( + locale, + entityId, + searchParamsParsed, + customerAccessToken, + currencyCode, + ); return pageInfoTransformer(wishlist?.items.pageInfo ?? defaultPageInfo); } export default async function WishlistPage({ params, searchParams }: Props) { - const { locale, id } = await params; - - setRequestLocale(locale); + const locale = await getLocale(); - const t = await getTranslations('Wishlist'); - const pt = await getTranslations('Product.ProductDetails'); + const [t, pt] = await Promise.all([ + getTranslations('Wishlist'), + getTranslations('Product.ProductDetails'), + ]); const wishlistActions = (wishlist?: Wishlist) => { if (!wishlist) { return ; @@ -102,7 +134,7 @@ export default async function WishlistPage({ params, searchParams }: Props) { return ( getAnalyticsData(id, searchParams))}> + { + const { id } = await params; + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); + + return getAnalyticsData(locale, id, searchParams, customerAccessToken, currencyCode); + })} + > getPaginationInfo(id, searchParams))} + paginationInfo={Streamable.from(async () => { + const { id } = await params; + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); + + return getPaginationInfo(locale, id, searchParams, customerAccessToken, currencyCode); + })} prevHref="/account/wishlists" removeAction={removeWishlistItem} removeButtonTitle={t('removeButtonTitle')} - wishlist={Streamable.from(() => getWishlist(id, t, pt, searchParams, locale))} + wishlist={Streamable.from(async () => { + const { id } = await params; + const [customerAccessToken, currencyCode] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); + + return getWishlist(id, t, pt, searchParams, locale, customerAccessToken, currencyCode); + })} /> ); diff --git a/core/app/[locale]/(default)/account/wishlists/page-data.ts b/core/app/[locale]/(default)/account/wishlists/page-data.ts index 02a1c3f7b1..fef85399e0 100644 --- a/core/app/[locale]/(default)/account/wishlists/page-data.ts +++ b/core/app/[locale]/(default)/account/wishlists/page-data.ts @@ -1,11 +1,10 @@ import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; +import type { CurrencyCode } from '~/components/header/fragment'; import { WishlistsFragment } from '~/components/wishlist/fragment'; -import { getPreferredCurrencyCode } from '~/lib/currency'; const WishlistsPageQuery = graphql( ` @@ -33,22 +32,28 @@ interface Pagination { after: string | null; } -export const getCustomerWishlists = cache(async ({ limit = 9, before, after }: Pagination) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - const currencyCode = await getPreferredCurrencyCode(); - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; - const response = await client.fetch({ - document: WishlistsPageQuery, - variables: { ...paginationArgs, currencyCode }, - customerAccessToken, - fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, - }); +export const getCustomerWishlists = cache( + async ( + locale: string, + { limit = 9, before, after }: Pagination, + customerAccessToken?: string, + currencyCode?: CurrencyCode, + ) => { + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + const response = await client.fetch({ + document: WishlistsPageQuery, + variables: { ...paginationArgs, currencyCode }, + locale, + customerAccessToken, + fetchOptions: { cache: 'no-store', next: { tags: [TAGS.customer] } }, + }); - const wishlists = response.data.customer?.wishlists; + const wishlists = response.data.customer?.wishlists; - if (!wishlists) { - return null; - } + if (!wishlists) { + return null; + } - return wishlists; -}); + return wishlists; + }, +); diff --git a/core/app/[locale]/(default)/account/wishlists/page.tsx b/core/app/[locale]/(default)/account/wishlists/page.tsx index 5fba791cfa..0288b7730e 100644 --- a/core/app/[locale]/(default)/account/wishlists/page.tsx +++ b/core/app/[locale]/(default)/account/wishlists/page.tsx @@ -1,15 +1,19 @@ -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { SearchParams } from 'nuqs'; import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'; +import { Suspense } from 'react'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { CursorPaginationInfo } from '@/vibes/soul/primitives/cursor-pagination'; import * as Skeleton from '@/vibes/soul/primitives/skeleton'; import { Wishlist } from '@/vibes/soul/sections/wishlist-details'; import { WishlistsSection } from '@/vibes/soul/sections/wishlists-section'; +import { getSessionCustomerAccessToken } from '~/auth'; import { ExistingResultType } from '~/client/util'; +import type { CurrencyCode } from '~/components/header/fragment'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { wishlistsTransformer } from '~/data-transformers/wishlists-transformer'; +import { getPreferredCurrencyCode } from '~/lib/currency'; import { isMobileUser } from '~/lib/user-agent'; import { NewWishlistButton } from './_components/new-wishlist-button'; @@ -36,12 +40,20 @@ const searchParamsCache = createSearchParamsCache({ }); async function listWishlists( + locale: string, searchParamsPromise: Promise, t: ExistingResultType>, + customerAccessToken?: string, + currencyCode?: CurrencyCode, ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); const formatter = await getFormatter(); - const wishlists = await getCustomerWishlists(searchParamsParsed); + const wishlists = await getCustomerWishlists( + locale, + searchParamsParsed, + customerAccessToken, + currencyCode, + ); if (!wishlists) { return []; @@ -51,21 +63,35 @@ async function listWishlists( } async function getPaginationInfo( + locale: string, searchParamsPromise: Promise, + customerAccessToken?: string, + currencyCode?: CurrencyCode, ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlists = await getCustomerWishlists(searchParamsParsed); + const wishlists = await getCustomerWishlists( + locale, + searchParamsParsed, + customerAccessToken, + currencyCode, + ); return pageInfoTransformer(wishlists?.pageInfo ?? defaultPageInfo); } -export default async function Wishlists({ params, searchParams }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - - const t = await getTranslations('Wishlist'); - const isMobile = await isMobileUser(); +async function WishlistsContent({ + locale, + searchParams, +}: { + locale: string; + searchParams: Promise; +}) { + const [t, isMobile, customerAccessToken, currencyCode] = await Promise.all([ + getTranslations('Wishlist'), + isMobileUser(), + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + ]); const newWishlistModal = getNewWishlistModal(t); return ( @@ -121,10 +147,24 @@ export default async function Wishlists({ params, searchParams }: Props) { ); }, }} - paginationInfo={Streamable.from(() => getPaginationInfo(searchParams))} + paginationInfo={Streamable.from(() => + getPaginationInfo(locale, searchParams, customerAccessToken, currencyCode), + )} title={t('title')} viewWishlistLabel={t('viewWishlist')} - wishlists={Streamable.from(() => listWishlists(searchParams, t))} + wishlists={Streamable.from(() => + listWishlists(locale, searchParams, t, customerAccessToken, currencyCode), + )} /> ); } + +export default async function Wishlists(props: Props) { + const { locale } = await props.params; + + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts index 472c44059a..99287dd477 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts +++ b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -38,11 +39,16 @@ const BlogPageQuery = graphql(` type Variables = VariablesOf; -export const getBlogPageData = cache(async (variables: Variables) => { +async function getCachedBlogPageData(locale: string, variables: Variables) { + 'use cache'; + + cacheLife({ revalidate }); + const response = await client.fetch({ document: BlogPageQuery, variables, - fetchOptions: { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); const { blog } = response.data.site.content; @@ -52,4 +58,8 @@ export const getBlogPageData = cache(async (variables: Variables) => { } return blog; +} + +export const getBlogPageData = cache(async (locale: string, variables: Variables) => { + return getCachedBlogPageData(locale, variables); }); diff --git a/core/app/[locale]/(default)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index 6b8da45019..14c8ca2cc2 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page.tsx +++ b/core/app/[locale]/(default)/blog/[blogId]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { cache } from 'react'; import { BlogPostContent, BlogPostContentBlogPost } from '@/vibes/soul/sections/blog-post-content'; @@ -23,7 +23,7 @@ export async function generateMetadata({ params }: Props): Promise { const variables = cachedBlogPageDataVariables(blogId); - const blog = await getBlogPageData(variables); + const blog = await getBlogPageData(locale, variables); const blogPost = blog?.post; if (!blogPost) { @@ -43,13 +43,12 @@ export async function generateMetadata({ params }: Props): Promise { } async function getBlogPost(props: Props): Promise { + const { blogId, locale } = await props.params; const format = await getFormatter(); - const { blogId } = await props.params; - const variables = cachedBlogPageDataVariables(blogId); - const blog = await getBlogPageData(variables); + const blog = await getBlogPageData(locale, variables); const blogPost = blog?.post; if (!blog || !blogPost) { @@ -74,13 +73,12 @@ async function getBlogPost(props: Props): Promise { } async function getBlogPostBreadcrumbs(props: Props): Promise { + const { blogId, locale } = await props.params; const t = await getTranslations('Blog'); - const { blogId } = await props.params; - const variables = cachedBlogPageDataVariables(blogId); - const blog = await getBlogPageData(variables); + const blog = await getBlogPageData(locale, variables); const blogPost = blog?.post; if (!blog || !blogPost) { @@ -103,11 +101,7 @@ async function getBlogPostBreadcrumbs(props: Props): Promise { ]; } -export default async function Blog(props: Props) { - const { locale } = await props.params; - - setRequestLocale(locale); - +export default function Blog(props: Props) { return ( ); diff --git a/core/app/[locale]/(default)/blog/page-data.ts b/core/app/[locale]/(default)/blog/page-data.ts index d51cf024cd..cac3016d25 100644 --- a/core/app/[locale]/(default)/blog/page-data.ts +++ b/core/app/[locale]/(default)/blog/page-data.ts @@ -1,4 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { cacheLife } from 'next/cache'; import { getFormatter } from 'next-intl/server'; import { cache } from 'react'; @@ -72,49 +73,82 @@ interface Pagination { after: string | null; } -export const getBlog = cache(async () => { +async function getCachedBlog(locale: string) { + 'use cache'; + + cacheLife({ revalidate }); + const response = await client.fetch({ document: BlogQuery, - fetchOptions: { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return response.data.site.content.blog; +} + +export const getBlog = cache(async (locale: string) => { + return getCachedBlog(locale); }); -export const getBlogPosts = cache( - async ({ tag, limit = 9, before, after }: BlogPostsFiltersInput & Pagination) => { - const filterArgs = tag ? { filters: { tags: [tag] } } : {}; - const paginationArgs = before ? { last: limit, before } : { first: limit, after }; +async function getCachedBlogPosts( + locale: string, + { tag, limit = 9, before, after }: BlogPostsFiltersInput & Pagination, +) { + 'use cache'; - const response = await client.fetch({ - document: BlogPostsPageQuery, - variables: { ...filterArgs, ...paginationArgs }, - fetchOptions: { next: { revalidate } }, - }); + cacheLife({ revalidate }); + + const filterArgs = tag ? { filters: { tags: [tag] } } : {}; + const paginationArgs = before ? { last: limit, before } : { first: limit, after }; + + const response = await client.fetch({ + document: BlogPostsPageQuery, + variables: { ...filterArgs, ...paginationArgs }, + locale, + fetchOptions: { cache: 'no-store' }, + }); - const { blog } = response.data.site.content; + const { blog } = response.data.site.content; - if (!blog) { + if (!blog) { + return null; + } + + return { + pageInfo: blog.posts.pageInfo, + posts: removeEdgesAndNodes(blog.posts).map((post) => ({ + id: String(post.entityId), + author: post.author, + content: post.plainTextSummary, + dateUtc: post.publishedDate.utc, + image: post.thumbnailImage + ? { + src: post.thumbnailImage.url, + alt: post.thumbnailImage.altText, + } + : undefined, + href: post.path, + title: post.name, + })), + }; +} + +export const getBlogPosts = cache( + async (locale: string, { tag, limit = 9, before, after }: BlogPostsFiltersInput & Pagination) => { + const raw = await getCachedBlogPosts(locale, { tag, limit, before, after }); + + if (!raw) { return null; } const format = await getFormatter(); return { - pageInfo: blog.posts.pageInfo, - posts: removeEdgesAndNodes(blog.posts).map((post) => ({ - id: String(post.entityId), - author: post.author, - content: post.plainTextSummary, - date: format.dateTime(new Date(post.publishedDate.utc)), - image: post.thumbnailImage - ? { - src: post.thumbnailImage.url, - alt: post.thumbnailImage.altText, - } - : undefined, - href: post.path, - title: post.name, + pageInfo: raw.pageInfo, + posts: raw.posts.map(({ dateUtc, ...post }) => ({ + ...post, + date: format.dateTime(new Date(dateUtc)), })), }; }, diff --git a/core/app/[locale]/(default)/blog/page.tsx b/core/app/[locale]/(default)/blog/page.tsx index 1b8d90cf91..73828627bf 100644 --- a/core/app/[locale]/(default)/blog/page.tsx +++ b/core/app/[locale]/(default)/blog/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { SearchParams } from 'nuqs'; import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'; @@ -28,8 +28,8 @@ const searchParamsCache = createSearchParamsCache({ export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; - const t = await getTranslations({ locale, namespace: 'Blog' }); - const blog = await getBlog(); + const t = await getTranslations('Blog'); + const blog = await getBlog(locale); const description = blog?.description && blog.description.length > 150 @@ -43,9 +43,9 @@ export async function generateMetadata({ params }: Props): Promise { }; } -async function listBlogPosts(searchParamsPromise: Promise) { +async function listBlogPosts(locale: string, searchParamsPromise: Promise) { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const blogPosts = await getBlogPosts(searchParamsParsed); + const blogPosts = await getBlogPosts(locale, searchParamsParsed); const posts = blogPosts?.posts ?? []; return posts; @@ -63,9 +63,9 @@ async function getEmptyStateSubtitle(): Promise { return t('subtitle'); } -async function getPaginationInfo(searchParamsPromise: Promise) { +async function getPaginationInfo(locale: string, searchParamsPromise: Promise) { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const blogPosts = await getBlogPosts(searchParamsParsed); + const blogPosts = await getBlogPosts(locale, searchParamsParsed); return pageInfoTransformer(blogPosts?.pageInfo ?? defaultPageInfo); } @@ -73,39 +73,41 @@ async function getPaginationInfo(searchParamsPromise: Promise) { export default async function Blog(props: Props) { const { locale } = await props.params; - setRequestLocale(locale); - const t = await getTranslations('Blog'); - const searchParamsParsed = searchParamsCache.parse(await props.searchParams); - const { tag } = searchParamsParsed; - const blog = await getBlog(); + const blog = await getBlog(locale); if (!blog) { return notFound(); } - const tagCrumb = tag ? [{ label: tag, href: '#' }] : []; + const streamableBreadcrumbs = Streamable.from(async () => { + const searchParamsParsed = searchParamsCache.parse(await props.searchParams); + const { tag } = searchParamsParsed; + const tagCrumb = tag ? [{ label: tag, href: '#' }] : []; + + return [ + { + label: t('home'), + href: '/', + }, + { + label: blog.name, + href: tag ? blog.path : '#', + }, + ...tagCrumb, + ]; + }); return ( getPaginationInfo(props.searchParams))} + emptyStateSubtitle={Streamable.from(() => getEmptyStateSubtitle())} + emptyStateTitle={Streamable.from(() => getEmptyStateTitle())} + paginationInfo={Streamable.from(() => getPaginationInfo(locale, props.searchParams))} placeholderCount={6} - posts={Streamable.from(() => listBlogPosts(props.searchParams))} + posts={Streamable.from(() => listBlogPosts(locale, props.searchParams))} title={blog.name} /> ); diff --git a/core/app/[locale]/(default)/cart/_actions/update-coupon-code.ts b/core/app/[locale]/(default)/cart/_actions/update-coupon-code.ts index 01611007f7..55ffc640dd 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-coupon-code.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-coupon-code.ts @@ -3,9 +3,10 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { SubmissionResult } from '@conform-to/react'; import { parseWithZod } from '@conform-to/zod'; -import { getTranslations } from 'next-intl/server'; +import { getLocale, getTranslations } from 'next-intl/server'; import { couponCodeActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; +import { getSessionCustomerAccessToken } from '~/auth'; import { getCartId } from '~/lib/cart'; import { getCart } from '../page-data'; @@ -34,7 +35,11 @@ export const updateCouponCode = async ( return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; } - const cart = await getCart({ cartId }); + const [locale, customerAccessToken] = await Promise.all([ + getLocale(), + getSessionCustomerAccessToken(), + ]); + const cart = await getCart(locale, { cartId }, customerAccessToken); const checkout = cart.site.checkout; if (!checkout) { diff --git a/core/app/[locale]/(default)/cart/_actions/update-gift-certificate.ts b/core/app/[locale]/(default)/cart/_actions/update-gift-certificate.ts index b7f2c52ac3..bf1aa88e42 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-gift-certificate.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-gift-certificate.ts @@ -3,9 +3,10 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { SubmissionResult } from '@conform-to/react'; import { parseWithZod } from '@conform-to/zod'; -import { getTranslations } from 'next-intl/server'; +import { getLocale, getTranslations } from 'next-intl/server'; import { giftCertificateCodeActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; +import { getSessionCustomerAccessToken } from '~/auth'; import { getCartId } from '~/lib/cart'; import { getCart } from '../page-data'; @@ -36,7 +37,11 @@ export const updateGiftCertificate = async ( return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; } - const cart = await getCart({ cartId }); + const [locale, customerAccessToken] = await Promise.all([ + getLocale(), + getSessionCustomerAccessToken(), + ]); + const cart = await getCart(locale, { cartId }, customerAccessToken); const checkout = cart.site.checkout; if (!checkout) { diff --git a/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts b/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts index 662ba0f7a6..809a3e6d43 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts @@ -2,10 +2,11 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { parseWithZod } from '@conform-to/zod'; -import { getTranslations } from 'next-intl/server'; +import { getLocale, getTranslations } from 'next-intl/server'; import { shippingActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; import { ShippingFormState } from '@/vibes/soul/sections/cart/shipping-form'; +import { getSessionCustomerAccessToken } from '~/auth'; import { getCartId } from '~/lib/cart'; import { getCart } from '../page-data'; @@ -32,7 +33,11 @@ export const updateShippingInfo = async ( return { ...prevState, lastResult: submission.reply({ formErrors: [t('cartNotFound')] }) }; } - const cart = await getCart({ cartId }); + const [locale, customerAccessToken] = await Promise.all([ + getLocale(), + getSessionCustomerAccessToken(), + ]); + const cart = await getCart(locale, { cartId }, customerAccessToken); const checkout = cart.site.checkout; if (!checkout || !cart.site.cart) { diff --git a/core/app/[locale]/(default)/cart/loading.tsx b/core/app/[locale]/(default)/cart/loading.tsx index 1f0e533b4f..7704b74980 100644 --- a/core/app/[locale]/(default)/cart/loading.tsx +++ b/core/app/[locale]/(default)/cart/loading.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useTranslations } from 'next-intl'; import { CartSkeleton } from '@/vibes/soul/sections/cart'; diff --git a/core/app/[locale]/(default)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts index c6e47636dc..b20c686a9e 100644 --- a/core/app/[locale]/(default)/cart/page-data.ts +++ b/core/app/[locale]/(default)/cart/page-data.ts @@ -1,6 +1,6 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; -import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; @@ -295,12 +295,15 @@ const CartPageQuery = graphql( type Variables = VariablesOf; -export const getCart = async (variables: Variables) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - +export const getCart = async ( + locale: string, + variables: Variables, + customerAccessToken?: string, +) => { const { data } = await client.fetch({ document: CartPageQuery, variables, + locale, customerAccessToken, fetchOptions: { cache: 'no-store', @@ -336,11 +339,20 @@ const SupportedShippingDestinationsQuery = graphql(` } `); -export const getShippingCountries = cache(async () => { +async function getCachedShippingCountries(locale: string) { + 'use cache'; + + cacheLife({ revalidate }); + const { data } = await client.fetch({ document: SupportedShippingDestinationsQuery, - fetchOptions: { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data.site.settings?.shipping?.supportedShippingDestinations.countries ?? []; +} + +export const getShippingCountries = cache(async (locale: string) => { + return getCachedShippingCountries(locale); }); diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index bc07f3d473..6dba6f7a5f 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -1,9 +1,10 @@ import { Metadata } from 'next'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart'; import { CartAnalyticsProvider } from '~/app/[locale]/(default)/cart/_components/cart-analytics-provider'; +import { getSessionCustomerAccessToken } from '~/auth'; import { getCartId } from '~/lib/cart'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { exists } from '~/lib/utils'; @@ -22,18 +23,16 @@ interface Props { const CHECKOUT_URL = process.env.TRAILING_SLASH !== 'false' ? '/checkout/' : '/checkout'; -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Cart' }); +export async function generateMetadata(): Promise { + const t = await getTranslations('Cart'); return { title: t('title'), }; } -const getAnalyticsData = async (cartId: string) => { - const data = await getCart({ cartId }); +const getAnalyticsData = async (locale: string, cartId: string, customerAccessToken?: string) => { + const data = await getCart(locale, { cartId }, customerAccessToken); const cart = data.site.cart; @@ -63,12 +62,7 @@ const getAnalyticsData = async (cartId: string) => { export default async function Cart({ params }: Props) { const { locale } = await params; - setRequestLocale(locale); - - const t = await getTranslations('Cart'); - const tGiftCertificates = await getTranslations('GiftCertificates'); - const format = await getFormatter(); - const cartId = await getCartId(); + const [t, cartId] = await Promise.all([getTranslations('Cart'), getCartId()]); if (!cartId) { return ( @@ -80,8 +74,13 @@ export default async function Cart({ params }: Props) { ); } - const currencyCode = await getPreferredCurrencyCode(); - const data = await getCart({ cartId, currencyCode }); + const [tGiftCertificates, format, currencyCode, customerAccessToken] = await Promise.all([ + getTranslations('GiftCertificates'), + getFormatter(), + getPreferredCurrencyCode(), + getSessionCustomerAccessToken(), + ]); + const data = await getCart(locale, { cartId, currencyCode }, customerAccessToken); const cart = data.site.cart; const checkout = data.site.checkout; @@ -227,7 +226,7 @@ export default async function Cart({ params }: Props) { checkout?.shippingConsignments?.find((consignment) => consignment.selectedShippingOption) || checkout?.shippingConsignments?.[0]; - const shippingCountries = await getShippingCountries(); + const shippingCountries = await getShippingCountries(locale); const countries = shippingCountries.map((country) => ({ value: country.code, @@ -260,7 +259,9 @@ export default async function Cart({ params }: Props) { return ( <> - getAnalyticsData(cartId))}> + getAnalyticsData(locale, cartId, customerAccessToken))} + > {checkoutUrl ? : null} { - if (productIds.length === 0) { - return []; - } + async ( + locale: string, + productIds: number[] = [], + currencyCode?: CurrencyCode, + customerAccessToken?: string, + ) => { + if (customerAccessToken) { + if (productIds.length === 0) { + return []; + } - const { data } = await client.fetch({ - document: ComparedProductsQuery, - variables: { - entityIds: productIds, - first: productIds.length ? MAX_COMPARE_LIMIT : 0, - currencyCode, - }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); + const { data } = await client.fetch({ + document: ComparedProductsQuery, + variables: { + entityIds: productIds, + first: productIds.length ? MAX_COMPARE_LIMIT : 0, + currencyCode, + }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return removeEdgesAndNodes(data.site.products); + } - return removeEdgesAndNodes(data.site.products); + return getCachedComparedProducts(locale, productIds, currencyCode); }, ); diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index ac851ddb1a..f44be0e483 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -1,6 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { Metadata } from 'next'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import * as z from 'zod'; import { Streamable } from '@/vibes/soul/lib/streamable'; @@ -41,7 +41,7 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; - const t = await getTranslations({ locale, namespace: 'Compare' }); + const t = await getTranslations('Compare'); return { title: t('title'), @@ -52,20 +52,24 @@ export async function generateMetadata({ params }: Props): Promise { export default async function Compare(props: Props) { const { locale } = await props.params; - setRequestLocale(locale); - const t = await getTranslations('Compare'); const streamableProducts = Streamable.from(async () => { - const customerAccessToken = await getSessionCustomerAccessToken(); - const currencyCode = await getPreferredCurrencyCode(); - - const searchParams = await props.searchParams; + const [customerAccessToken, currencyCode, searchParams, format] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + props.searchParams, + getFormatter(), + ]); const parsed = CompareParamsSchema.parse(searchParams); const productIds = parsed.ids?.filter((id) => !Number.isNaN(id)); - const products = await getComparedProducts(productIds, currencyCode, customerAccessToken); - const format = await getFormatter(); + const products = await getComparedProducts( + locale, + productIds, + currencyCode, + customerAccessToken, + ); return products.map((product) => ({ id: product.entityId.toString(), @@ -90,14 +94,20 @@ export default async function Compare(props: Props) { }); const streamableAnalyticsData = Streamable.from(async () => { - const customerAccessToken = await getSessionCustomerAccessToken(); - const currencyCode = await getPreferredCurrencyCode(); - - const searchParams = await props.searchParams; + const [customerAccessToken, currencyCode, searchParams] = await Promise.all([ + getSessionCustomerAccessToken(), + getPreferredCurrencyCode(), + props.searchParams, + ]); const parsed = CompareParamsSchema.parse(searchParams); const productIds = parsed.ids?.filter((id) => !Number.isNaN(id)); - const products = await getComparedProducts(productIds, currencyCode, customerAccessToken); + const products = await getComparedProducts( + locale, + productIds, + currencyCode, + customerAccessToken, + ); return products.map((product) => { return { diff --git a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx index 0320e48acb..a5d552590b 100644 --- a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx @@ -1,8 +1,9 @@ import type { Metadata } from 'next'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; import { GiftCertificateCheckBalanceSection } from '@/vibes/soul/sections/gift-certificate-balance-section'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMetadataAlternates } from '~/lib/seo/canonical'; @@ -17,7 +18,7 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; - const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); + const t = await getTranslations('GiftCertificates'); return { title: t('title') || 'Gift certificates - Check balance', @@ -25,14 +26,10 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function GiftCertificates(props: Props) { - const { locale } = await props.params; - - setRequestLocale(locale); - +async function GiftCertificatesBalanceContent({ locale }: { locale: string }) { const t = await getTranslations('GiftCertificates'); const currencyCode = await getPreferredCurrencyCode(); - const data = await getGiftCertificatesData(currencyCode); + const data = await getGiftCertificatesData(locale, currencyCode); if (!data.giftCertificatesEnabled) { return redirect({ href: '/', locale }); @@ -63,3 +60,13 @@ export default async function GiftCertificates(props: Props) { /> ); } + +export default async function GiftCertificates(props: Props) { + const { locale } = await props.params; + + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/gift-certificates/page-data.ts b/core/app/[locale]/(default)/gift-certificates/page-data.ts index 6905ee0b7f..50313b90e7 100644 --- a/core/app/[locale]/(default)/gift-certificates/page-data.ts +++ b/core/app/[locale]/(default)/gift-certificates/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -26,11 +27,16 @@ const GiftCertificatesRootQuery = graphql( [StoreLogoFragment], ); -export const getGiftCertificatesData = cache(async (currencyCode?: CurrencyCode) => { +async function getCachedGiftCertificatesData(locale: string, currencyCode?: CurrencyCode) { + 'use cache'; + + cacheLife({ revalidate }); + const response = await client.fetch({ document: GiftCertificatesRootQuery, variables: { currencyCode }, - fetchOptions: { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return { @@ -38,4 +44,10 @@ export const getGiftCertificatesData = cache(async (currencyCode?: CurrencyCode) defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, logo: response.data.site.settings ? logoTransformer(response.data.site.settings) : '', }; -}); +} + +export const getGiftCertificatesData = cache( + async (locale: string, currencyCode?: CurrencyCode) => { + return getCachedGiftCertificatesData(locale, currencyCode); + }, +); diff --git a/core/app/[locale]/(default)/gift-certificates/page.tsx b/core/app/[locale]/(default)/gift-certificates/page.tsx index 5c50984fb3..47fd3a3f11 100644 --- a/core/app/[locale]/(default)/gift-certificates/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/page.tsx @@ -1,8 +1,9 @@ import type { Metadata } from 'next'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; import { GiftCertificatesSection } from '@/vibes/soul/sections/gift-certificates-section'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMetadataAlternates } from '~/lib/seo/canonical'; @@ -15,7 +16,7 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; - const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); + const t = await getTranslations('GiftCertificates'); return { title: t('title') || 'Gift certificates', @@ -23,15 +24,11 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function GiftCertificates(props: Props) { - const { locale } = await props.params; - - setRequestLocale(locale); - +async function GiftCertificatesContent({ locale }: { locale: string }) { const t = await getTranslations('GiftCertificates'); const format = await getFormatter(); const currencyCode = await getPreferredCurrencyCode(); - const data = await getGiftCertificatesData(currencyCode); + const data = await getGiftCertificatesData(locale, currencyCode); if (!data.giftCertificatesEnabled) { return redirect({ href: '/', locale }); @@ -55,3 +52,13 @@ export default async function GiftCertificates(props: Props) { /> ); } + +export default async function GiftCertificates(props: Props) { + const { locale } = await props.params; + + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts b/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts index 609584722a..a3d10240b9 100644 --- a/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts +++ b/core/app/[locale]/(default)/gift-certificates/purchase/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -29,11 +30,16 @@ const GiftCertificatePurchaseSettingsQuery = graphql( [GiftCertificateSettingsFragment, StoreLogoFragment], ); -export const getGiftCertificatePurchaseData = cache(async (currencyCode?: CurrencyCode) => { +async function getCachedGiftCertificatePurchaseData(locale: string, currencyCode?: CurrencyCode) { + 'use cache'; + + cacheLife({ revalidate }); + const response = await client.fetch({ document: GiftCertificatePurchaseSettingsQuery, variables: { currencyCode }, - fetchOptions: { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return { @@ -42,4 +48,10 @@ export const getGiftCertificatePurchaseData = cache(async (currencyCode?: Curren storeName: response.data.site.settings?.storeName ?? undefined, defaultCurrency: response.data.site.settings?.currency.defaultCurrency ?? undefined, }; -}); +} + +export const getGiftCertificatePurchaseData = cache( + async (locale: string, currencyCode?: CurrencyCode) => { + return getCachedGiftCertificatePurchaseData(locale, currencyCode); + }, +); diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx index 29e4158c75..26ad4c0d65 100644 --- a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx @@ -1,12 +1,13 @@ import { ResultOf } from 'gql.tada'; import { Metadata } from 'next'; import { getFormatter, getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema'; import { GiftCertificatePurchaseSection } from '@/vibes/soul/sections/gift-certificate-purchase-section'; import { GiftCertificateSettingsFragment } from '~/app/[locale]/(default)/gift-certificates/purchase/fragment'; import { ExistingResultType } from '~/client/util'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMetadataAlternates } from '~/lib/seo/canonical'; @@ -147,13 +148,13 @@ function getExpiryDate( } } -export default async function GiftCertificatePurchasePage({ params }: Props) { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); - const format = await getFormatter(); - const currencyCode = await getPreferredCurrencyCode(); - const data = await getGiftCertificatePurchaseData(currencyCode); +async function GiftCertificatePurchaseContent({ locale }: { locale: string }) { + const [t, format, currencyCode] = await Promise.all([ + getTranslations({ locale, namespace: 'GiftCertificates' }), + getFormatter({ locale }), + getPreferredCurrencyCode(), + ]); + const data = await getGiftCertificatePurchaseData(locale, currencyCode); if (!data.giftCertificateSettings?.isEnabled) { return redirect({ href: '/', locale }); @@ -192,3 +193,13 @@ export default async function GiftCertificatePurchasePage({ params }: Props) { /> ); } + +export default async function GiftCertificatePurchasePage(props: Props) { + const { locale } = await props.params; + + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/layout.tsx b/core/app/[locale]/(default)/layout.tsx index 95522f608e..6fa856a767 100644 --- a/core/app/[locale]/(default)/layout.tsx +++ b/core/app/[locale]/(default)/layout.tsx @@ -1,18 +1,9 @@ -import { setRequestLocale } from 'next-intl/server'; import { PropsWithChildren } from 'react'; import { Footer } from '~/components/footer'; import { Header } from '~/components/header'; -interface Props extends PropsWithChildren { - params: Promise<{ locale: string }>; -} - -export default async function DefaultLayout({ params, children }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - +export default function DefaultLayout({ children }: PropsWithChildren) { return ( <>
diff --git a/core/app/[locale]/(default)/page-data.ts b/core/app/[locale]/(default)/page-data.ts index ab78d520a6..13f4985971 100644 --- a/core/app/[locale]/(default)/page-data.ts +++ b/core/app/[locale]/(default)/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -77,15 +78,35 @@ const HomePageQuery = graphql( [FeaturedProductsCarouselFragment, FeaturedProductsListFragment], ); +async function getCachedPageData(locale: string, currencyCode?: CurrencyCode) { + 'use cache'; + + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: HomePageQuery, + variables: { currencyCode }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data; +} + export const getPageData = cache( - async (currencyCode?: CurrencyCode, customerAccessToken?: string) => { - const { data } = await client.fetch({ - document: HomePageQuery, - customerAccessToken, - variables: { currencyCode }, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); + async (locale: string, currencyCode?: CurrencyCode, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: HomePageQuery, + customerAccessToken, + variables: { currencyCode }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data; + } - return data; + return getCachedPageData(locale, currencyCode); }, ); diff --git a/core/app/[locale]/(default)/page.tsx b/core/app/[locale]/(default)/page.tsx index 77cf4db613..0368114ea8 100644 --- a/core/app/[locale]/(default)/page.tsx +++ b/core/app/[locale]/(default)/page.tsx @@ -1,6 +1,6 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { Metadata } from 'next'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getTranslations } from 'next-intl/server'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { FeaturedProductCarousel } from '@/vibes/soul/sections/featured-product-carousel'; @@ -29,8 +29,6 @@ export async function generateMetadata({ params }: Props): Promise { export default async function Home({ params }: Props) { const { locale } = await params; - setRequestLocale(locale); - const t = await getTranslations('Home'); const format = await getFormatter(); @@ -38,7 +36,7 @@ export default async function Home({ params }: Props) { const customerAccessToken = await getSessionCustomerAccessToken(); const currencyCode = await getPreferredCurrencyCode(); - return getPageData(currencyCode, customerAccessToken); + return getPageData(locale, currencyCode, customerAccessToken); }); const streamableFeaturedProducts = Streamable.from(async () => { diff --git a/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts b/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts index 385ab2d74f..e08e3f9354 100644 --- a/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts +++ b/core/app/[locale]/(default)/product/[slug]/_actions/wishlist-action.ts @@ -19,7 +19,7 @@ import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; import { WishlistMutationError } from '~/components/wishlist/error'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { serverToast } from '~/lib/server-toast'; const VariantIdFromSkuQuery = graphql(` diff --git a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx index 47a533d5c9..ddfdd7a068 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx @@ -1,11 +1,12 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { cacheLife } from 'next/cache'; import { getFormatter, getTranslations } from 'next-intl/server'; import { createLoader, parseAsString, SearchParams } from 'nuqs/server'; import { cache } from 'react'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { Reviews as ReviewsSection } from '@/vibes/soul/sections/reviews'; -import { auth } from '~/auth'; +import { auth, getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql } from '~/client/graphql'; @@ -64,18 +65,47 @@ const ReviewsQuery = graphql( [ProductReviewSchemaFragment, PaginationFragment], ); -const getReviews = cache(async (productId: number, paginationArgs: object) => { +async function getCachedReviews(locale: string, productId: number, paginationArgs: object) { + 'use cache'; + + cacheLife({ revalidate }); + const { data } = await client.fetch({ document: ReviewsQuery, variables: { ...paginationArgs, entityId: productId }, - fetchOptions: { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data.site.product; -}); +} + +const getReviews = cache( + async ( + locale: string, + productId: number, + paginationArgs: object, + customerAccessToken?: string, + ) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: ReviewsQuery, + variables: { ...paginationArgs, entityId: productId }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; + } + + return getCachedReviews(locale, productId, paginationArgs); + }, +); interface Props { productId: number; + locale: string; searchParams: Promise; streamableImages: Streamable<{ images: Array<{ src: string; alt: string }>; @@ -87,6 +117,7 @@ interface Props { export const Reviews = async ({ productId, + locale, searchParams, streamableProduct, streamableImages, @@ -103,7 +134,9 @@ export const Reviews = async ({ } = paginationSearchParams; const paginationArgs = before == null ? { first: 5, after } : { last: 5, before }; - return getReviews(productId, paginationArgs); + const customerAccessToken = await getSessionCustomerAccessToken(); + + return getReviews(locale, productId, paginationArgs, customerAccessToken); }); const streamableReviews = Streamable.from(async () => { diff --git a/core/app/[locale]/(default)/product/[slug]/_components/search-params-router-refresh.tsx b/core/app/[locale]/(default)/product/[slug]/_components/search-params-router-refresh.tsx index 7e40e34e12..672b564cb1 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/search-params-router-refresh.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/search-params-router-refresh.tsx @@ -4,7 +4,7 @@ import { useSearchParams } from 'next/navigation'; import { SearchParams } from 'nuqs'; import { useEffect } from 'react'; -import { useRouter } from '~/i18n/routing'; +import { useRouter } from '~/i18n/navigation'; // Not-so-great workaround for https://github.com/vercel/next.js/issues/59407 export const SearchParamsRouterRefresh = ({ diff --git a/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/add-to-new-wishlist-modal.tsx b/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/add-to-new-wishlist-modal.tsx index 7278543728..63ddee6bde 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/add-to-new-wishlist-modal.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/add-to-new-wishlist-modal.tsx @@ -4,7 +4,7 @@ import { useSearchParams } from 'next/navigation'; import { Modal } from '~/components/modal'; import { NewWishlistModal } from '~/components/wishlist/modals/new'; -import { usePathname, useRouter } from '~/i18n/routing'; +import { usePathname, useRouter } from '~/i18n/navigation'; import { addToNewWishlist } from '../../_actions/wishlist-action'; diff --git a/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/dropdown.tsx b/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/dropdown.tsx index 2215181bdf..3dff281d0e 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/dropdown.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/wishlist-button/dropdown.tsx @@ -5,7 +5,7 @@ import { CheckIcon, PlusIcon, XIcon } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; import { DropdownMenu, DropdownMenuItem } from '@/vibes/soul/primitives/dropdown-menu'; -import { usePathname, useRouter } from '~/i18n/routing'; +import { usePathname, useRouter } from '~/i18n/navigation'; import { WishlistButtonWishlistInfo } from '.'; diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 02a8293735..9adf8663c4 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -154,16 +155,36 @@ const ProductPageMetadataQuery = graphql(` } `); +async function getCachedProductPageMetadata(locale: string, entityId: number) { + 'use cache'; + + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: ProductPageMetadataQuery, + variables: { entityId }, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; +} + export const getProductPageMetadata = cache( - async (entityId: number, customerAccessToken?: string) => { - const { data } = await client.fetch({ - document: ProductPageMetadataQuery, - variables: { entityId }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); - - return data.site.product; + async (locale: string, entityId: number, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: ProductPageMetadataQuery, + variables: { entityId }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; + } + + return getCachedProductPageMetadata(locale, entityId); }, ); @@ -200,16 +221,38 @@ const ProductQuery = graphql( [ProductOptionsFragment], ); -export const getProduct = cache(async (entityId: number, customerAccessToken?: string) => { +async function getCachedProduct(locale: string, entityId: number) { + 'use cache'; + + cacheLife({ revalidate }); + const { data } = await client.fetch({ document: ProductQuery, variables: { entityId }, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data.site; -}); +} + +export const getProduct = cache( + async (locale: string, entityId: number, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: ProductQuery, + variables: { entityId }, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site; + } + + return getCachedProduct(locale, entityId); + }, +); const StreamableProductVariantInventoryBySkuQuery = graphql(` query ProductVariantBySkuQuery($productId: Int!, $sku: String!) { @@ -249,16 +292,39 @@ const StreamableProductVariantInventoryBySkuQuery = graphql(` type VariantInventoryVariables = VariablesOf; +async function getCachedStreamableProductVariantInventory( + locale: string, + variables: VariantInventoryVariables, +) { + 'use cache'; + + cacheLife({ revalidate: 60 }); + + const { data } = await client.fetch({ + document: StreamableProductVariantInventoryBySkuQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product?.variants; +} + export const getStreamableProductVariantInventory = cache( - async (variables: VariantInventoryVariables, customerAccessToken?: string) => { - const { data } = await client.fetch({ - document: StreamableProductVariantInventoryBySkuQuery, - variables, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 60 } }, - }); - - return data.site.product?.variants; + async (locale: string, variables: VariantInventoryVariables, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: StreamableProductVariantInventoryBySkuQuery, + variables, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product?.variants; + } + + return getCachedStreamableProductVariantInventory(locale, variables); }, ); @@ -322,16 +388,36 @@ const StreamableProductQuery = graphql( type Variables = VariablesOf; +async function getCachedStreamableProduct(locale: string, variables: Variables) { + 'use cache'; + + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: StreamableProductQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; +} + export const getStreamableProduct = cache( - async (variables: Variables, customerAccessToken?: string) => { - const { data } = await client.fetch({ - document: StreamableProductQuery, - variables, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); - - return data.site.product; + async (locale: string, variables: Variables, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: StreamableProductQuery, + variables, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; + } + + return getCachedStreamableProduct(locale, variables); }, ); @@ -365,16 +451,39 @@ const StreamableProductInventoryQuery = graphql( type ProductInventoryVariables = VariablesOf; +async function getCachedStreamableProductInventory( + locale: string, + variables: ProductInventoryVariables, +) { + 'use cache'; + + cacheLife({ revalidate: 60 }); + + const { data } = await client.fetch({ + document: StreamableProductInventoryQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; +} + export const getStreamableProductInventory = cache( - async (variables: ProductInventoryVariables, customerAccessToken?: string) => { - const { data } = await client.fetch({ - document: StreamableProductInventoryQuery, - variables, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 60 } }, - }); - - return data.site.product; + async (locale: string, variables: ProductInventoryVariables, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: StreamableProductInventoryQuery, + variables, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; + } + + return getCachedStreamableProductInventory(locale, variables); }, ); @@ -409,16 +518,36 @@ const ProductPricingAndRelatedProductsQuery = graphql( [PricingFragment, FeaturedProductsCarouselFragment], ); +async function getCachedProductPricingAndRelatedProducts(locale: string, variables: Variables) { + 'use cache'; + + cacheLife({ revalidate }); + + const { data } = await client.fetch({ + document: ProductPricingAndRelatedProductsQuery, + variables, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; +} + export const getProductPricingAndRelatedProducts = cache( - async (variables: Variables, customerAccessToken?: string) => { - const { data } = await client.fetch({ - document: ProductPricingAndRelatedProductsQuery, - variables, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); - - return data.site.product; + async (locale: string, variables: Variables, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: ProductPricingAndRelatedProductsQuery, + variables, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.product; + } + + return getCachedProductPricingAndRelatedProducts(locale, variables); }, ); @@ -440,12 +569,33 @@ const InventorySettingsQuery = graphql(` } `); -export const getStreamableInventorySettingsQuery = cache(async (customerAccessToken?: string) => { +async function getCachedStreamableInventorySettingsQuery(locale: string) { + 'use cache'; + + cacheLife({ revalidate }); + const { data } = await client.fetch({ document: InventorySettingsQuery, - customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data.site.settings?.inventory; -}); +} + +export const getStreamableInventorySettingsQuery = cache( + async (locale: string, customerAccessToken?: string) => { + if (customerAccessToken) { + const { data } = await client.fetch({ + document: InventorySettingsQuery, + customerAccessToken, + locale, + fetchOptions: { cache: 'no-store' }, + }); + + return data.site.settings?.inventory; + } + + return getCachedStreamableInventorySettingsQuery(locale); + }, +); diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 8d2001ee8c..974352c268 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -1,7 +1,7 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getLocale, getTranslations } from 'next-intl/server'; import { SearchParams } from 'nuqs/server'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; @@ -40,12 +40,14 @@ interface Props { } export async function generateMetadata({ params }: Props): Promise { - const { slug, locale } = await params; - const customerAccessToken = await getSessionCustomerAccessToken(); + const [{ slug, locale }, customerAccessToken] = await Promise.all([ + params, + getSessionCustomerAccessToken(), + ]); const productId = Number(slug); - const product = await getProductPageMetadata(productId, customerAccessToken); + const product = await getProductPageMetadata(locale, productId, customerAccessToken); if (!product) { return notFound(); @@ -66,31 +68,36 @@ export async function generateMetadata({ params }: Props): Promise { } export default async function Product({ params, searchParams }: Props) { - const { locale, slug } = await params; - const customerAccessToken = await getSessionCustomerAccessToken(); + const locale = await getLocale(); + const [t, format] = await Promise.all([getTranslations('Product'), getFormatter()]); const detachedWishlistFormId = 'product-add-to-wishlist-form'; + const recaptchaSiteKey = await getRecaptchaSiteKey(); + + // Shared streamable that resolves params and fetches product data. + // Cached (guest) product data for the static shell — always uses the cached path + // so product name, options, settings resolve instantly from 'use cache' during PPR. + // Auth-dependent data uses the precompute flag in individual Streamable closures below. + const streamableBaseData = Streamable.from(async () => { + const { slug } = await params; + const productId = Number(slug); + const { product, settings } = await getProduct(locale, productId); + + return { productId, product, settings }; + }); - setRequestLocale(locale); - - const t = await getTranslations('Product'); - const format = await getFormatter(); - - const productId = Number(slug); - - const [{ product: baseProduct, settings }, recaptchaSiteKey] = await Promise.all([ - getProduct(productId, customerAccessToken), - getRecaptchaSiteKey(), - ]); - - const reviewsEnabled = Boolean(settings?.reviews.enabled && !settings.display.showProductRating); - const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + // Resolve productId from params inside a Streamable for components that need it. + const streamableProductId = Streamable.from(async () => { + const { productId } = await streamableBaseData; - if (!baseProduct) { - return notFound(); - } + return productId; + }); const streamableProduct = Streamable.from(async () => { - const options = await searchParams; + const { productId } = await streamableBaseData; + const [options, customerAccessToken] = await Promise.all([ + searchParams, + getSessionCustomerAccessToken(), + ]); const optionValueIds = Object.keys(options) .map((option) => ({ @@ -102,12 +109,12 @@ export default async function Product({ params, searchParams }: Props) { ); const variables = { - entityId: Number(productId), + entityId: productId, optionValueIds, useDefaultOptionSelections: true, }; - const product = await getStreamableProduct(variables, customerAccessToken); + const product = await getStreamableProduct(locale, variables, customerAccessToken); if (!product) { return notFound(); @@ -119,11 +126,14 @@ export default async function Product({ params, searchParams }: Props) { const streamableProductSku = Streamable.from(async () => (await streamableProduct).sku); const streamableProductInventory = Streamable.from(async () => { + const { productId } = await streamableBaseData; + const customerAccessToken = await getSessionCustomerAccessToken(); + const variables = { - entityId: Number(productId), + entityId: productId, }; - const product = await getStreamableProductInventory(variables, customerAccessToken); + const product = await getStreamableProductInventory(locale, variables, customerAccessToken); if (!product) { return notFound(); @@ -133,18 +143,25 @@ export default async function Product({ params, searchParams }: Props) { }); const streamableProductVariantInventory = Streamable.from(async () => { + const { productId } = await streamableBaseData; const product = await streamableProductInventory; if (!product.inventory.hasVariantInventory) { return undefined; } + const customerAccessToken = await getSessionCustomerAccessToken(); + const variables = { productId, sku: product.sku, }; - const variants = await getStreamableProductVariantInventory(variables, customerAccessToken); + const variants = await getStreamableProductVariantInventory( + locale, + variables, + customerAccessToken, + ); if (!variants) { return undefined; @@ -154,6 +171,7 @@ export default async function Product({ params, searchParams }: Props) { }); const streamableProductPricingAndRelatedProducts = Streamable.from(async () => { + const { productId } = await streamableBaseData; const options = await searchParams; const optionValueIds = Object.keys(options) @@ -168,13 +186,15 @@ export default async function Product({ params, searchParams }: Props) { const currencyCode = await getPreferredCurrencyCode(); const variables = { - entityId: Number(productId), + entityId: productId, optionValueIds, useDefaultOptionSelections: true, currencyCode, }; - return await getProductPricingAndRelatedProducts(variables, customerAccessToken); + const customerAccessToken = await getSessionCustomerAccessToken(); + + return await getProductPricingAndRelatedProducts(locale, variables, customerAccessToken); }); const streamablePrices = Streamable.from(async () => { @@ -242,7 +262,9 @@ export default async function Product({ params, searchParams }: Props) { }); const streamableInventorySettings = Streamable.from(async () => { - return await getStreamableInventorySettingsQuery(customerAccessToken); + const customerAccessToken = await getSessionCustomerAccessToken(); + + return await getStreamableInventorySettingsQuery(locale, customerAccessToken); }); const getBackorderAvailabilityPrompt = ({ @@ -545,45 +567,80 @@ export default async function Product({ params, searchParams }: Props) { return { email: session?.user?.email ?? '', name: obfuscatedName }; }); + const streamableFields = Streamable.from(async () => { + const { product: baseProduct } = await streamableBaseData; + + if (!baseProduct) { + return []; + } + + return productOptionsTransformer(baseProduct.productOptions); + }); + + const streamableProductDetail = Streamable.from(async () => { + const { product: baseProduct, settings } = await streamableBaseData; + + if (!baseProduct) { + return notFound(); + } + + const reviewsEnabled = Boolean( + settings?.reviews.enabled && !settings.display.showProductRating, + ); + const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); + + return { + id: baseProduct.entityId.toString(), + title: baseProduct.name, + description:
, + href: baseProduct.path, + images: streamableImages, + price: streamablePrices, + reviewsEnabled, + showRating, + numberOfReviews: baseProduct.reviewSummary.numberOfReviews, + subtitle: baseProduct.brand?.name, + rating: baseProduct.reviewSummary.averageRating, + accordions: streameableAccordions, + minQuantity: streamableMinQuantity, + maxQuantity: streamableMaxQuantity, + stockDisplayData: streamableStockDisplayData, + backorderDisplayData: streamableBackorderDisplayData, + }; + }); + + const streamableShowRating = Streamable.from(async () => { + const { settings } = await streamableBaseData; + + return Boolean(settings?.reviews.enabled && settings.display.showProductRating); + }); + return ( <> + + {(productId) => ( + + )} + } additionalInformationTitle={t('ProductDetails.additionalInformation')} ctaDisabled={streameableCtaDisabled} ctaLabel={streameableCtaLabel} decrementLabel={t('ProductDetails.decreaseQuantity')} emptySelectPlaceholder={t('ProductDetails.emptySelectPlaceholder')} - fields={productOptionsTransformer(baseProduct.productOptions)} + fields={streamableFields} incrementLabel={t('ProductDetails.increaseQuantity')} loadMoreImagesAction={getMoreProductImages} prefetch={true} - product={{ - id: baseProduct.entityId.toString(), - title: baseProduct.name, - description:
, - href: baseProduct.path, - images: streamableImages, - price: streamablePrices, - reviewsEnabled, - showRating, - numberOfReviews: baseProduct.reviewSummary.numberOfReviews, - subtitle: baseProduct.brand?.name, - rating: baseProduct.reviewSummary.averageRating, - accordions: streameableAccordions, - minQuantity: streamableMinQuantity, - maxQuantity: streamableMaxQuantity, - stockDisplayData: streamableStockDisplayData, - backorderDisplayData: streamableBackorderDisplayData, - }} + product={streamableProductDetail} quantityLabel={t('ProductDetails.quantity')} recaptchaSiteKey={recaptchaSiteKey} reviewFormAction={submitReview} @@ -603,17 +660,26 @@ export default async function Product({ params, searchParams }: Props) { title={t('RelatedProducts.title')} /> - {showRating && ( -
- -
- )} + + {(showRating) => + showRating ? ( +
+ + {(productId) => ( + + )} + +
+ ) : null + } +
- + + {(productId) => ( + + )} + ); } diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts b/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts index ed90829f9d..9432f0f223 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts +++ b/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts @@ -10,7 +10,7 @@ import { DynamicFormActionArgs } from '@/vibes/soul/form/dynamic-form'; import { Field, schema } from '@/vibes/soul/form/dynamic-form/schema'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; const inputSchema = z.object({ diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts b/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts index 191e5f56ac..3ca5db5137 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -31,12 +32,21 @@ const ContactPageQuery = graphql( type Variables = VariablesOf; -export const getWebpageData = cache(async (variables: Variables) => { +async function getCachedWebpageData(locale: string, variables: Variables) { + 'use cache'; + + cacheLife({ revalidate }); + const { data } = await client.fetch({ document: ContactPageQuery, variables, - fetchOptions: { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data; +} + +export const getWebpageData = cache(async (locale: string, variables: Variables) => { + return getCachedWebpageData(locale, variables); }); diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx index 2c8896c405..98da566b38 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { cache } from 'react'; import { DynamicForm } from '@/vibes/soul/form/dynamic-form'; @@ -41,8 +41,8 @@ const fieldMapping = { type ContactField = keyof typeof fieldMapping; -const getWebPage = cache(async (id: string): Promise => { - const data = await getWebpageData({ id: decodeURIComponent(id) }); +const getWebPage = cache(async (locale: string, id: string): Promise => { + const data = await getWebpageData(locale, { id: decodeURIComponent(id) }); const webpage = data.node?.__typename === 'ContactPage' ? data.node : null; if (!webpage) { @@ -62,10 +62,10 @@ const getWebPage = cache(async (id: string): Promise => { }; }); -async function getWebPageBreadcrumbs(id: string): Promise { +async function getWebPageBreadcrumbs(locale: string, id: string): Promise { const t = await getTranslations('WebPages.ContactUs'); - const webpage = await getWebPage(id); + const webpage = await getWebPage(locale, id); const [, ...rest] = webpage.breadcrumbs.reverse(); const breadcrumbs = [ { @@ -82,8 +82,8 @@ async function getWebPageBreadcrumbs(id: string): Promise { return truncateBreadcrumbs(breadcrumbs, 5); } -async function getWebPageWithSuccessContent(id: string, message: string) { - const webpage = await getWebPage(id); +async function getWebPageWithSuccessContent(locale: string, id: string, message: string) { + const webpage = await getWebPage(locale, id); return { ...webpage, @@ -91,9 +91,9 @@ async function getWebPageWithSuccessContent(id: string, message: string) { }; } -async function getContactFields(id: string) { +async function getContactFields(locale: string, id: string) { const t = await getTranslations('WebPages.ContactUs.Form'); - const { entityId, path, contactFields } = await getWebPage(id); + const { entityId, path, contactFields } = await getWebPage(locale, id); const toGroupsOfTwo = (fields: Field[]) => fields.reduce>>((acc, _, i) => { if (i % 2 === 0) { @@ -156,7 +156,7 @@ async function getContactFields(id: string) { export async function generateMetadata({ params }: Props): Promise { const { id, locale } = await params; - const webpage = await getWebPage(id); + const webpage = await getWebPage(locale, id); const { pageTitle, metaDescription, metaKeywords } = webpage.seo; return { @@ -173,15 +173,13 @@ export default async function ContactPage({ params, searchParams }: Props) { const { id, locale } = await params; const { success } = await searchParams; - setRequestLocale(locale); - const t = await getTranslations('WebPages.ContactUs.Form'); if (success === 'true') { return ( getWebPageBreadcrumbs(id))} - webPage={Streamable.from(() => getWebPageWithSuccessContent(id, t('success')))} + breadcrumbs={Streamable.from(() => getWebPageBreadcrumbs(locale, id))} + webPage={Streamable.from(() => getWebPageWithSuccessContent(locale, id, t('success')))} > getWebPageBreadcrumbs(id))} - webPage={Streamable.from(() => getWebPage(id))} + breadcrumbs={Streamable.from(() => getWebPageBreadcrumbs(locale, id))} + webPage={Streamable.from(() => getWebPage(locale, id))} >
diff --git a/core/app/[locale]/(default)/webpages/[id]/layout.tsx b/core/app/[locale]/(default)/webpages/[id]/layout.tsx index cde3f40ea5..7a995386fe 100644 --- a/core/app/[locale]/(default)/webpages/[id]/layout.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/layout.tsx @@ -1,7 +1,8 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; -import { setRequestLocale } from 'next-intl/server'; -import { cache } from 'react'; +import { cacheLife } from 'next/cache'; +import { cache, Suspense } from 'react'; +import { Streamable } from '@/vibes/soul/lib/streamable'; import { SidebarMenu } from '@/vibes/soul/sections/sidebar-menu'; import { StickySidebarLayout } from '@/vibes/soul/sections/sticky-sidebar-layout'; import { client } from '~/client'; @@ -45,11 +46,16 @@ interface PageLink { href: string; } -const getWebPageChildren = cache(async (id: string): Promise => { +async function getCachedWebPageChildren(locale: string, id: string): Promise { + 'use cache'; + + cacheLife({ revalidate }); + const { data } = await client.fetch({ document: WebPageChildrenQuery, variables: { id: decodeURIComponent(id) }, - fetchOptions: { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); if (!data.node) { @@ -73,19 +79,29 @@ const getWebPageChildren = cache(async (id: string): Promise => { return acc; }, []); +} + +const getWebPageChildren = cache(async (locale: string, id: string): Promise => { + return getCachedWebPageChildren(locale, id); }); -export default async function WebPageLayout({ params, children }: Props) { +async function WebPageLayoutContent({ params, children }: Props) { const { locale, id } = await params; - setRequestLocale(locale); - return ( } + sidebar={ getWebPageChildren(locale, id))} />} sidebarSize="small" > {children} ); } + +export default function WebPageLayout(props: Props) { + return ( + + + + ); +} diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts b/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts index eb87a7884d..9fe9e8b350 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -29,12 +30,21 @@ const NormalPageQuery = graphql( type Variables = VariablesOf; -export const getWebpageData = cache(async (variables: Variables) => { +async function getCachedWebpageData(locale: string, variables: Variables) { + 'use cache'; + + cacheLife({ revalidate }); + const { data } = await client.fetch({ document: NormalPageQuery, variables, - fetchOptions: { next: { revalidate } }, + locale, + fetchOptions: { cache: 'no-store' }, }); return data; +} + +export const getWebpageData = cache(async (locale: string, variables: Variables) => { + return getCachedWebpageData(locale, variables); }); diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx index a222ea18c8..c16bd37a68 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { getTranslations } from 'next-intl/server'; import { cache } from 'react'; import { Streamable } from '@/vibes/soul/lib/streamable'; @@ -19,8 +19,8 @@ interface Props { params: Promise<{ locale: string; id: string }>; } -const getWebPage = cache(async (id: string): Promise => { - const data = await getWebpageData({ id: decodeURIComponent(id) }); +const getWebPage = cache(async (locale: string, id: string): Promise => { + const data = await getWebpageData(locale, { id: decodeURIComponent(id) }); const webpage = data.node?.__typename === 'NormalPage' ? data.node : null; if (!webpage) { @@ -37,10 +37,10 @@ const getWebPage = cache(async (id: string): Promise => { }; }); -async function getWebPageBreadcrumbs(id: string): Promise { +async function getWebPageBreadcrumbs(locale: string, id: string): Promise { const t = await getTranslations('WebPages.Normal'); - const webpage = await getWebPage(id); + const webpage = await getWebPage(locale, id); const [, ...rest] = webpage.breadcrumbs.reverse(); const breadcrumbs = [ { @@ -59,7 +59,7 @@ async function getWebPageBreadcrumbs(id: string): Promise { export async function generateMetadata({ params }: Props): Promise { const { id, locale } = await params; - const webpage = await getWebPage(id); + const webpage = await getWebPage(locale, id); const { pageTitle, metaDescription, metaKeywords } = webpage.seo; // Get the path from the last breadcrumb @@ -76,12 +76,10 @@ export async function generateMetadata({ params }: Props): Promise { export default async function WebPage({ params }: Props) { const { locale, id } = await params; - setRequestLocale(locale); - return ( getWebPageBreadcrumbs(id))} - webPage={Streamable.from(() => getWebPage(id))} + breadcrumbs={Streamable.from(() => getWebPageBreadcrumbs(locale, id))} + webPage={Streamable.from(() => getWebPage(locale, id))} /> ); } diff --git a/core/app/[locale]/(default)/wishlist/[token]/page-data.ts b/core/app/[locale]/(default)/wishlist/[token]/page-data.ts index 4c3e56de3d..56494de567 100644 --- a/core/app/[locale]/(default)/wishlist/[token]/page-data.ts +++ b/core/app/[locale]/(default)/wishlist/[token]/page-data.ts @@ -1,3 +1,4 @@ +import { cacheLife, cacheTag } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -5,9 +6,9 @@ import { PaginationFragment } from '~/client/fragments/pagination'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; import { TAGS } from '~/client/tags'; +import type { CurrencyCode } from '~/components/header/fragment'; import { ProductCardFragment } from '~/components/product-card/fragment'; import { WishlistItemFragment } from '~/components/wishlist/fragment'; -import { getPreferredCurrencyCode } from '~/lib/currency'; const PublicWishlistQuery = graphql( ` @@ -50,22 +51,33 @@ interface Pagination { after?: string | null; } -export const getPublicWishlist = cache(async (token: string, pagination: Pagination) => { +async function getCachedPublicWishlist( + locale: string, + token: string, + pagination: Pagination, + currencyCode?: CurrencyCode, +) { + 'use cache'; + + cacheLife({ revalidate }); + cacheTag(TAGS.customer); + const { before, after, limit = 9 } = pagination; - const currencyCode = await getPreferredCurrencyCode(); const paginationArgs = before ? { last: limit, before } : { first: limit, after }; const response = await client.fetch({ document: PublicWishlistQuery, variables: { ...paginationArgs, currencyCode, token }, - // Since the wishlist is public, it's okay that we cache this request - fetchOptions: { next: { revalidate, tags: [TAGS.customer] } }, + locale, + fetchOptions: { cache: 'no-store' }, }); const wishlist = response.data.site.publicWishlist; - if (!wishlist) { - return null; - } - return wishlist; -}); +} + +export const getPublicWishlist = cache( + async (locale: string, token: string, pagination: Pagination, currencyCode?: CurrencyCode) => { + return getCachedPublicWishlist(locale, token, pagination, currencyCode); + }, +); diff --git a/core/app/[locale]/(default)/wishlist/[token]/page.tsx b/core/app/[locale]/(default)/wishlist/[token]/page.tsx index e6fe52378f..42bca643da 100644 --- a/core/app/[locale]/(default)/wishlist/[token]/page.tsx +++ b/core/app/[locale]/(default)/wishlist/[token]/page.tsx @@ -1,7 +1,7 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; +import { getFormatter, getLocale, getTranslations } from 'next-intl/server'; import { SearchParams } from 'nuqs'; import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'; @@ -13,12 +13,14 @@ import { Wishlist, WishlistDetails } from '@/vibes/soul/sections/wishlist-detail import { addWishlistItemToCart } from '~/app/[locale]/(default)/account/wishlists/[id]/_actions/add-to-cart'; import { WishlistAnalyticsProvider } from '~/app/[locale]/(default)/account/wishlists/[id]/_components/wishlist-analytics-provider'; import { ExistingResultType } from '~/client/util'; +import type { CurrencyCode } from '~/components/header/fragment'; import { WishlistShareButton, WishlistShareButtonSkeleton, } from '~/components/wishlist/share-button'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { publicWishlistDetailsTransformer } from '~/data-transformers/wishlists-transformer'; +import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMetadataAlternates } from '~/lib/seo/canonical'; import { isMobileUser } from '~/lib/user-agent'; @@ -38,14 +40,16 @@ const searchParamsCache = createSearchParamsCache({ }); async function getWishlist( + locale: string, token: string, t: ExistingResultType>, pt: ExistingResultType>, searchParams: Promise, + currencyCode?: CurrencyCode, ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParams); const formatter = await getFormatter(); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const wishlist = await getPublicWishlist(locale, token, searchParamsParsed, currencyCode); if (!wishlist) { return notFound(); @@ -55,11 +59,13 @@ async function getWishlist( } async function getPaginationInfo( + locale: string, token: string, searchParams: Promise, + currencyCode?: CurrencyCode, ): Promise { const searchParamsParsed = searchParamsCache.parse(await searchParams); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const wishlist = await getPublicWishlist(locale, token, searchParamsParsed, currencyCode); return pageInfoTransformer(wishlist?.items.pageInfo ?? defaultPageInfo); } @@ -69,8 +75,9 @@ export async function generateMetadata({ params, searchParams }: Props): Promise // Even though we don't need paginated data during metadata generation, we should still pass the parameters // 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 t = await getTranslations('PublicWishlist'); + const currencyCode = await getPreferredCurrencyCode(); + const wishlist = await getPublicWishlist(locale, token, searchParamsParsed, currencyCode); return { title: wishlist?.name ?? t('title'), @@ -78,9 +85,14 @@ export async function generateMetadata({ params, searchParams }: Props): Promise }; } -const getAnalyticsData = async (token: string, searchParamsPromise: Promise) => { +const getAnalyticsData = async ( + locale: string, + token: string, + searchParamsPromise: Promise, + currencyCode?: CurrencyCode, +) => { const searchParamsParsed = searchParamsCache.parse(await searchParamsPromise); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const wishlist = await getPublicWishlist(locale, token, searchParamsParsed, currencyCode); if (!wishlist) { return []; @@ -102,12 +114,14 @@ const getAnalyticsData = async (token: string, searchParamsPromise: Promise, + currencyCode?: CurrencyCode, ): Promise { const t = await getTranslations('PublicWishlist'); const searchParamsParsed = searchParamsCache.parse(await searchParams); - const wishlist = await getPublicWishlist(token, searchParamsParsed); + const wishlist = await getPublicWishlist(locale, token, searchParamsParsed, currencyCode); return [ { href: '/', label: 'Home' }, @@ -116,9 +130,7 @@ async function getBreadcrumbs( } export default async function PublicWishlist({ params, searchParams }: Props) { - const { locale, token } = await params; - - setRequestLocale(locale); + const locale = await getLocale(); const t = await getTranslations('Wishlist'); const pwt = await getTranslations('PublicWishlist'); @@ -159,17 +171,41 @@ export default async function PublicWishlist({ params, searchParams }: Props) { }; return ( - getAnalyticsData(token, searchParams))}> + { + const { token } = await params; + const currencyCode = await getPreferredCurrencyCode(); + + return getAnalyticsData(locale, token, searchParams, currencyCode); + })} + > - getBreadcrumbs(token, searchParams))} /> + { + const { token } = await params; + const currencyCode = await getPreferredCurrencyCode(); + + return getBreadcrumbs(locale, token, searchParams, currencyCode); + })} + /> getPaginationInfo(token, searchParams))} - wishlist={Streamable.from(() => getWishlist(token, t, pt, searchParams))} + paginationInfo={Streamable.from(async () => { + const { token } = await params; + const currencyCode = await getPreferredCurrencyCode(); + + return getPaginationInfo(locale, token, searchParams, currencyCode); + })} + wishlist={Streamable.from(async () => { + const { token } = await params; + const currencyCode = await getPreferredCurrencyCode(); + + return getWishlist(locale, token, t, pt, searchParams, currencyCode); + })} /> diff --git a/core/app/[locale]/layout.tsx b/core/app/[locale]/layout.tsx index 11d3d88e85..34c084b464 100644 --- a/core/app/[locale]/layout.tsx +++ b/core/app/[locale]/layout.tsx @@ -1,12 +1,17 @@ import { Analytics } from '@vercel/analytics/react'; import { SpeedInsights } from '@vercel/speed-insights/next'; +import { clsx } from 'clsx'; import type { Metadata } from 'next'; +import { cacheLife } from 'next/cache'; import { notFound } from 'next/navigation'; import { NextIntlClientProvider } from 'next-intl'; -import { setRequestLocale } from 'next-intl/server'; +import { getLocale } from 'next-intl/server'; import { NuqsAdapter } from 'nuqs/adapters/next/app'; -import { cache, PropsWithChildren } from 'react'; +import { cache, PropsWithChildren, Suspense } from 'react'; +import '~/globals.css'; + +import { fonts } from '~/app/fonts'; import { CookieNotifications } from '~/app/notifications'; import { Providers } from '~/app/providers'; import { client } from '~/client'; @@ -53,12 +58,18 @@ const RootLayoutMetadataQuery = graphql( [WebAnalyticsFragment, ScriptsFragment], ); -const fetchRootLayoutMetadata = cache(async () => { +async function getCachedRootLayoutMetadata() { + 'use cache'; + + cacheLife({ revalidate }); + return await client.fetch({ document: RootLayoutMetadataQuery, - fetchOptions: { next: { revalidate } }, + fetchOptions: { cache: 'no-store' }, }); -}); +} + +const fetchRootLayoutMetadata = cache(async () => getCachedRootLayoutMetadata()); export async function generateMetadata(): Promise { const { data } = await fetchRootLayoutMetadata(); @@ -116,57 +127,65 @@ interface Props extends PropsWithChildren { params: Promise<{ locale: string }>; } +async function ToastNotifications() { + const toastNotificationCookieData = await getToastNotification(); + + if (!toastNotificationCookieData) { + return null; + } + + return ; +} + export default async function RootLayout({ params, children }: Props) { const { locale } = await params; + const localeFromIntl = await getLocale(); const rootData = await fetchRootLayoutMetadata(); - const toastNotificationCookieData = await getToastNotification(); if (!routing.locales.includes(locale)) { notFound(); } - // need to call this method everywhere where static rendering is enabled - // https://next-intl-docs.vercel.app/docs/getting-started/app-router#add-setRequestLocale-to-all-layouts-and-pages - setRequestLocale(locale); - const scripts = scriptsTransformer(rootData.data.site.content.scripts); const isCookieConsentEnabled = rootData.data.site.settings?.privacy?.cookieConsentEnabled ?? false; const privacyPolicyUrl = rootData.data.site.settings?.privacy?.privacyPolicyUrl; return ( - <> - - - - - - {toastNotificationCookieData && ( - - )} - {children} - - - - - - - - + f.variable))} lang={localeFromIntl}> + + + + + + + + + + {children} + + + + + + + + + + + ); } export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })); } - -export const fetchCache = 'default-cache'; diff --git a/core/app/[locale]/maintenance/page.tsx b/core/app/[locale]/maintenance/page.tsx index d5bc75e4f5..340b2374ab 100644 --- a/core/app/[locale]/maintenance/page.tsx +++ b/core/app/[locale]/maintenance/page.tsx @@ -1,10 +1,12 @@ import { Metadata } from 'next'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { cacheLife } from 'next/cache'; +import { getTranslations } from 'next-intl/server'; import { ReactNode } from 'react'; import { Maintenance as MaintenanceSection } from '@/vibes/soul/sections/maintenance'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; import { StoreLogoFragment } from '~/components/store-logo/fragment'; import { logoTransformer } from '~/data-transformers/logo-transformer'; @@ -26,14 +28,21 @@ const MaintenancePageQuery = graphql( [StoreLogoFragment], ); -interface Props { - params: Promise<{ locale: string }>; -} +async function getCachedMaintenancePageData() { + 'use cache'; -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; + cacheLife({ revalidate }); - const t = await getTranslations({ locale, namespace: 'Maintenance' }); + const { data } = await client.fetch({ + document: MaintenancePageQuery, + fetchOptions: { cache: 'no-store' }, + }); + + return data; +} + +export async function generateMetadata(): Promise { + const t = await getTranslations('Maintenance'); return { title: t('title'), @@ -46,16 +55,10 @@ const Container = ({ children }: { children: ReactNode }) => ( ); -export default async function Maintenance({ params }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - +export default async function Maintenance() { const t = await getTranslations('Maintenance'); - const { data } = await client.fetch({ - document: MaintenancePageQuery, - }); + const data = await getCachedMaintenancePageData(); const storeSettings = data.site.settings; diff --git a/core/app/admin/route.ts b/core/app/admin/route.ts index f573d84ff7..53e982699e 100644 --- a/core/app/admin/route.ts +++ b/core/app/admin/route.ts @@ -1,5 +1,5 @@ import { defaultLocale } from '~/i18n/locales'; -import { redirect } from '~/i18n/routing'; +import { redirect } from '~/i18n/navigation'; const canonicalDomain: string = process.env.BIGCOMMERCE_GRAPHQL_API_DOMAIN ?? 'mybigcommerce.com'; const BIGCOMMERCE_STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH; diff --git a/core/app/favicon.ico/route.ts b/core/app/favicon.ico/route.ts index 49df80d88d..37c70d820b 100644 --- a/core/app/favicon.ico/route.ts +++ b/core/app/favicon.ico/route.ts @@ -24,7 +24,9 @@ const GetFaviconQuery = graphql(` } `); -export const GET = async () => { +async function getFaviconData() { + 'use cache'; + const { data } = await client.fetch({ document: GetFaviconQuery, channelId: getChannelIdFromLocale(defaultLocale), @@ -33,19 +35,24 @@ export const GET = async () => { const faviconUrl = data.site.settings?.faviconUrl; if (!faviconUrl) { - return new Response(null, { - status: 404, - }); + return null; } - // fetch the favicon URL and return the data directly (will be statically cached at build time) - const faviconData = await fetch(faviconUrl).then((res) => res.arrayBuffer()); + const faviconBuffer = await fetch(faviconUrl).then((res) => + res.arrayBuffer().then((buf) => Buffer.from(buf).toString('base64')), + ); + + return faviconBuffer; +} + +export const GET = async () => { + const faviconData = await getFaviconData(); + + if (!faviconData) { + return new Response(null, { status: 404 }); + } - return new Response(faviconData, { - headers: { - 'Content-Type': 'image/x-icon', - }, + return new Response(Buffer.from(faviconData, 'base64'), { + headers: { 'Content-Type': 'image/x-icon' }, }); }; - -export const dynamic = 'force-static'; diff --git a/core/app/layout.tsx b/core/app/layout.tsx deleted file mode 100644 index ea754b0fb4..0000000000 --- a/core/app/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { clsx } from 'clsx'; -import { PropsWithChildren } from 'react'; - -import '../globals.css'; - -import { fonts } from '~/app/fonts'; - -export default function RootLayout({ children }: PropsWithChildren) { - return ( - f.variable))} lang="en"> - {children} - - ); -} diff --git a/core/app/robots.txt/route.ts b/core/app/robots.txt/route.ts index 24b6b1d3c3..1abbb7482e 100644 --- a/core/app/robots.txt/route.ts +++ b/core/app/robots.txt/route.ts @@ -40,20 +40,23 @@ const baseUrl = parseUrl( process.env.NEXTAUTH_URL || process.env.VERCEL_PROJECT_PRODUCTION_URL || '', ); -export const GET = async () => { +async function getRobotsTxtContent() { + 'use cache'; + const { data } = await client.fetch({ document: RobotsTxtQuery, channelId: getChannelIdFromLocale(defaultLocale), - fetchOptions: { cache: 'no-store' }, // disable caching to get the latest robots.txt at build time + fetchOptions: { cache: 'no-store' }, }); - const robotsTxt = `${data.site.settings?.robotsTxt ?? ''}\nSitemap: ${baseUrl.origin}/sitemap.xml\n`; + return data.site.settings?.robotsTxt ?? ''; +} + +export const GET = async () => { + const robotsTxtContent = await getRobotsTxtContent(); + const robotsTxt = `${robotsTxtContent}\nSitemap: ${baseUrl.origin}/sitemap.xml\n`; return new Response(robotsTxt, { - headers: { - 'Content-Type': 'text/plain; charset=UTF-8', - }, + headers: { 'Content-Type': 'text/plain; charset=UTF-8' }, }); }; - -export const dynamic = 'force-static'; diff --git a/core/app/xmlsitemap.php/route.ts b/core/app/xmlsitemap.php/route.ts index 03c3cc2c16..231acb3139 100644 --- a/core/app/xmlsitemap.php/route.ts +++ b/core/app/xmlsitemap.php/route.ts @@ -1,6 +1,6 @@ /* eslint-disable check-file/folder-naming-convention */ import { defaultLocale } from '~/i18n/locales'; -import { permanentRedirect } from '~/i18n/routing'; +import { permanentRedirect } from '~/i18n/navigation'; /* * This route is used to redirect the legacy Stencil sitemap that lives on /xmlsitemap.php diff --git a/core/auth/handle-login-cart.ts b/core/auth/handle-login-cart.ts new file mode 100644 index 0000000000..666eab2fd2 --- /dev/null +++ b/core/auth/handle-login-cart.ts @@ -0,0 +1,20 @@ +import { getTranslations } from 'next-intl/server'; + +import { setCartId } from '~/lib/cart'; +import { serverToast } from '~/lib/server-toast'; + +export async function handleLoginCart(guestCartId?: string, loginResultCartId?: string) { + const t = await getTranslations('Cart'); + + if (guestCartId === undefined && loginResultCartId !== undefined) { + await serverToast.info(t('cartRestored'), { position: 'top-center' }); + } + + if (loginResultCartId && guestCartId && loginResultCartId !== guestCartId) { + await serverToast.info(t('cartCombined'), { position: 'top-center' }); + } + + if (loginResultCartId) { + await setCartId(loginResultCartId); + } +} diff --git a/core/auth/index.ts b/core/auth/index.ts index bf902fa2a8..ad24894f18 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -2,14 +2,12 @@ import { decodeJwt } from 'jose'; import NextAuth, { type NextAuthConfig, User } from 'next-auth'; import 'next-auth/jwt'; import CredentialsProvider from 'next-auth/providers/credentials'; -import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; import { anonymousSignIn, clearAnonymousSession } from '~/auth/anonymous-session'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { clearCartId, setCartId } from '~/lib/cart'; -import { serverToast } from '~/lib/server-toast'; const LoginMutation = graphql(` mutation LoginMutation($email: String!, $password: String!, $cartEntityId: String) { @@ -86,21 +84,18 @@ const SessionUpdate = z.object({ }), }); -async function handleLoginCart(guestCartId?: string, loginResultCartId?: string) { - const t = await getTranslations('Cart'); +// handleLoginCart is in a separate file to avoid pulling next-intl/server +// (and next/root-params) into the middleware bundle. +// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func, @typescript-eslint/consistent-type-assertions +const importHandleLoginCart = new Function('return import("./handle-login-cart")') as () => Promise< + typeof import('./handle-login-cart') +>; - if (guestCartId === undefined && loginResultCartId !== undefined) { - await serverToast.info(t('cartRestored'), { position: 'top-center' }); - } - - if (loginResultCartId && guestCartId && loginResultCartId !== guestCartId) { - await serverToast.info(t('cartCombined'), { position: 'top-center' }); - } +const handleLoginCart = async (guestCartId?: string, loginResultCartId?: string) => { + const { handleLoginCart: fn } = await importHandleLoginCart(); - if (loginResultCartId) { - await setCartId(loginResultCartId); - } -} + return fn(guestCartId, loginResultCartId); +}; async function loginWithPassword(credentials: unknown): Promise { const { email, password, cartId } = PasswordCredentials.parse(credentials); diff --git a/core/client/correlation-id.ts b/core/client/correlation-id.ts new file mode 100644 index 0000000000..d75eee25cb --- /dev/null +++ b/core/client/correlation-id.ts @@ -0,0 +1,14 @@ +let counter = 0; + +/** + * Returns a correlation ID for tracing requests. + * Uses a simple counter to avoid crypto.randomUUID() and Date.now() which + * trigger Next.js cacheComponents prerender errors for accessing dynamic + * values before uncached data. + * + * @returns {string} A unique correlation ID string. + */ +export function getCorrelationId(): string { + // eslint-disable-next-line no-plusplus + return `req-${(counter++).toString(36)}`; +} diff --git a/core/client/index.ts b/core/client/index.ts index d14bcb463b..32f2fa02c9 100644 --- a/core/client/index.ts +++ b/core/client/index.ts @@ -3,35 +3,7 @@ import { BigCommerceAuthError, createClient } from '@bigcommerce/catalyst-client import { getChannelIdFromLocale } from '../channels.config'; import { backendUserAgent } from '../user-agent'; -// next/headers, next/navigation, and next-intl/server are imported dynamically -// (via `import()`) rather than statically. Static imports cause these modules to -// be evaluated during module graph resolution when next.config.ts imports this -// file, which poisons the process-wide AsyncLocalStorage context (pnpm symlinks -// create two separate singleton instances of next/headers). Dynamic imports -// defer module loading to call time, after Next.js has fully initialized. -// -// During config resolution, the dynamic import of next-intl/server succeeds but -// getLocale() throws ("not supported in Client Components") — the try/catch -// below absorbs this gracefully, and getChannelId falls back to defaultChannelId. - -const getLocale = async () => { - try { - const { getLocale: getServerLocale } = await import('next-intl/server'); - - return await getServerLocale(); - } catch { - /** - * Next-intl `getLocale` only works on the server, and when the proxy has run. - * - * Instances when `getLocale` will not work: - * - Requests during next.config.ts resolution - * - Requests in proxies - * - Requests in `generateStaticParams` - * - Request in api routes - * - Requests in static sites without `setRequestLocale` - */ - } -}; +import { getCorrelationId } from './correlation-id'; export const client = createClient({ storefrontToken: process.env.BIGCOMMERCE_STOREFRONT_TOKEN ?? '', @@ -41,30 +13,20 @@ export const client = createClient({ logger: (process.env.NODE_ENV !== 'production' && process.env.CLIENT_LOGGER !== 'false') || process.env.CLIENT_LOGGER === 'true', - getChannelId: async (defaultChannelId: string) => { - const locale = await getLocale(); - - // We use the default channelId as a fallback, but it is not ideal in some scenarios. + getChannelId: (defaultChannelId: string, locale?: string) => { return getChannelIdFromLocale(locale) ?? defaultChannelId; }, + // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars beforeRequest: async (fetchOptions) => { // We can't serialize a `Headers` object within this method so we have to opt into using a plain object const requestHeaders: Record = {}; - const locale = await getLocale(); - if (fetchOptions?.cache && ['no-store', 'no-cache'].includes(fetchOptions.cache)) { - const { headers } = await import('next/headers'); - const ipAddress = (await headers()).get('X-Forwarded-For'); + // Note: IP forwarding via headers() was removed because headers() cannot be called inside + // 'use cache' contexts (throws an uncatchable error in Next.js 16 with cacheComponents). + // Since cached responses are shared across users, IP forwarding is not meaningful there. + // For authenticated (non-cached) requests, IP forwarding should be handled at the middleware level. - if (ipAddress) { - requestHeaders['X-Forwarded-For'] = ipAddress; - requestHeaders['True-Client-IP'] = ipAddress; - } - } - - if (locale) { - requestHeaders['Accept-Language'] = locale; - } + requestHeaders['X-Correlation-ID'] = getCorrelationId(); return { headers: requestHeaders, diff --git a/core/components/footer/index.tsx b/core/components/footer/index.tsx index 8148c2c035..ce433e88ce 100644 --- a/core/components/footer/index.tsx +++ b/core/components/footer/index.tsx @@ -6,8 +6,9 @@ import { SiX, SiYoutube, } from '@icons-pack/react-simple-icons'; +import { cacheLife } from 'next/cache'; import { getTranslations } from 'next-intl/server'; -import { cache, JSX } from 'react'; +import { cache, JSX, Suspense } from 'react'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { Footer as FooterSection } from '@/vibes/soul/sections/footer'; @@ -28,6 +29,8 @@ import { MastercardIcon } from './payment-icons/mastercard'; import { PayPalIcon } from './payment-icons/paypal'; import { VisaIcon } from './payment-icons/visa'; +const currentYear = new Date().getFullYear(); + const paymentIcons = [ , , @@ -46,30 +49,55 @@ const socialIcons: Record = { YouTube: { icon: }, }; +async function getCachedFooterSections(currencyCode?: CurrencyCode) { + 'use cache'; + + cacheLife({ revalidate }); + + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + variables: { currencyCode }, + // Since this query is needed on every page, it's a good idea not to validate the customer access token. + // The 'cache' function also caches errors, so we might get caught in a redirect loop if the cache saves an invalid token error response. + validateCustomerAccessToken: false, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(FooterSectionsFragment, response).site; +} + const getFooterSections = cache( async (customerAccessToken?: string, currencyCode?: CurrencyCode) => { - const { data: response } = await client.fetch({ - document: GetLinksAndSectionsQuery, - customerAccessToken, - variables: { currencyCode }, - // Since this query is needed on every page, it's a good idea not to validate the customer access token. - // The 'cache' function also caches errors, so we might get caught in a redirect loop if the cache saves an invalid token error response. - validateCustomerAccessToken: false, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, - }); - - return readFragment(FooterSectionsFragment, response).site; + if (customerAccessToken) { + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + customerAccessToken, + variables: { currencyCode }, + validateCustomerAccessToken: false, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(FooterSectionsFragment, response).site; + } + + return getCachedFooterSections(currencyCode); }, ); -const getFooterData = cache(async () => { +async function getCachedFooterData() { + 'use cache'; + + cacheLife({ revalidate }); + const { data: response } = await client.fetch({ document: LayoutQuery, - fetchOptions: { next: { revalidate } }, + fetchOptions: { cache: 'no-store' }, }); return readFragment(FooterFragment, response).site; -}); +} + +const getFooterData = cache(async () => getCachedFooterData()); export const Footer = async () => { const t = await getTranslations('Components.Footer'); @@ -77,7 +105,7 @@ export const Footer = async () => { const logo = data.settings ? logoTransformer(data.settings) : ''; - const copyright = `© ${new Date().getFullYear()} ${data.settings?.storeName} – Powered by BigCommerce`; + const copyright = `© ${currentYear} ${data.settings?.storeName} – Powered by BigCommerce`; const contactInformation = data.settings?.contact ? { @@ -134,16 +162,18 @@ export const Footer = async () => { }); return ( - + + + ); }; diff --git a/core/components/force-refresh/index.tsx b/core/components/force-refresh/index.tsx index d6a032546e..163eb57b1c 100644 --- a/core/components/force-refresh/index.tsx +++ b/core/components/force-refresh/index.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; -import { useRouter } from '~/i18n/routing'; +import { useRouter } from '~/i18n/navigation'; import { FORCE_REFRESH_COOKIE, getCookieValue, setCookie } from '~/lib/client-cookies'; export const ForceRefresh = () => { diff --git a/core/components/header/index.tsx b/core/components/header/index.tsx index f686f8d65d..d61c0950db 100644 --- a/core/components/header/index.tsx +++ b/core/components/header/index.tsx @@ -1,5 +1,6 @@ +import { cacheLife } from 'next/cache'; import { getLocale, getTranslations } from 'next-intl/server'; -import { cache } from 'react'; +import { cache, Suspense } from 'react'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { HeaderSection } from '@/vibes/soul/sections/header-section'; @@ -47,32 +48,57 @@ const getCartCount = cache(async (cartId: string, customerAccessToken?: string) return response.data.site.cart?.lineItems.totalQuantity ?? null; }); -const getHeaderLinks = cache(async (customerAccessToken?: string, currencyCode?: CurrencyCode) => { +async function getCachedHeaderLinks(currencyCode?: CurrencyCode) { + 'use cache'; + + cacheLife({ revalidate }); + const { data: response } = await client.fetch({ document: GetLinksAndSectionsQuery, - customerAccessToken, variables: { currencyCode }, // Since this query is needed on every page, it's a good idea not to validate the customer access token. // The 'cache' function also caches errors, so we might get caught in a redirect loop if the cache saves an invalid token error response. validateCustomerAccessToken: false, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + fetchOptions: { cache: 'no-store' }, }); return readFragment(HeaderLinksFragment, response).site; +} + +const getHeaderLinks = cache(async (customerAccessToken?: string, currencyCode?: CurrencyCode) => { + if (customerAccessToken) { + const { data: response } = await client.fetch({ + document: GetLinksAndSectionsQuery, + customerAccessToken, + variables: { currencyCode }, + validateCustomerAccessToken: false, + fetchOptions: { cache: 'no-store' }, + }); + + return readFragment(HeaderLinksFragment, response).site; + } + + return getCachedHeaderLinks(currencyCode); }); -const getHeaderData = cache(async () => { +async function getCachedHeaderData() { + 'use cache'; + + cacheLife({ revalidate }); + const { data: response } = await client.fetch({ document: LayoutQuery, - fetchOptions: { next: { revalidate } }, + fetchOptions: { cache: 'no-store' }, }); return readFragment(HeaderFragment, response).site; -}); +} + +const getHeaderData = cache(async () => getCachedHeaderData()); export const Header = async () => { - const t = await getTranslations('Components.Header'); const locale = await getLocale(); + const t = await getTranslations('Components.Header'); const data = await getHeaderData(); @@ -154,33 +180,35 @@ export const Header = async () => { }); return ( - + + + ); }; diff --git a/core/components/link/index.tsx b/core/components/link/index.tsx index 714337767f..cf1db4d4a3 100644 --- a/core/components/link/index.tsx +++ b/core/components/link/index.tsx @@ -1,8 +1,12 @@ 'use client'; -import { ComponentPropsWithRef, ComponentRef, forwardRef, useReducer } from 'react'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import NextLink from 'next/link'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { useRouter } from 'next/navigation'; +import { ComponentPropsWithRef, ComponentRef, forwardRef, Suspense, useReducer } from 'react'; -import { Link as NavLink, useRouter } from '../../i18n/routing'; +import { Link as NavLink } from '../../i18n/navigation'; type NextLinkProps = Omit, 'prefetch'>; @@ -13,17 +17,7 @@ interface PrefetchOptions { type Props = NextLinkProps & PrefetchOptions; -/** - * This custom `Link` is based on Next-Intl's `Link` component - * https://next-intl-docs.vercel.app/docs/routing/navigation#link - * which adds automatically prefixes for the href with the current locale as necessary - * and extends with additional prefetching controls, making navigation - * prefetching more adaptable to different use cases. By offering `prefetch` and `prefetchKind` - * props, it grants explicit management over when and how prefetching occurs, defaulting to 'hover' for - * prefetch behavior and 'auto' for prefetch kind. This approach provides a balance between optimizing - * page load performance and resource usage. https://nextjs.org/docs/app/api-reference/components/link#prefetch - */ -export const Link = forwardRef, Props>( +const InnerLink = forwardRef, Props>( ({ href, prefetch = 'hover', prefetchKind = 'auto', children, className, ...rest }, ref) => { const router = useRouter(); const [prefetched, setPrefetched] = useReducer(() => true, false); @@ -65,6 +59,34 @@ export const Link = forwardRef, Props>( }, ); +InnerLink.displayName = 'InnerLink'; + +/** + * This custom `Link` wraps Next-Intl's `Link` component in a Suspense boundary + * to support PPR (Partial Prerendering) with cacheComponents. During prerender, + * next-intl's Link accesses locale context which is dynamic. The Suspense boundary + * provides a static fallback using next/link directly. + */ +export const Link = forwardRef, Props>(({ children, ...props }, ref) => { + const hrefString = typeof props.href === 'string' ? props.href : (props.href.href ?? '#'); + + return ( + + {children} + + } + > + + {children} + + + ); +}); + +Link.displayName = 'Link'; + function computePrefetchProp({ prefetch, prefetchKind, diff --git a/core/i18n/navigation.ts b/core/i18n/navigation.ts new file mode 100644 index 0000000000..bc9e8ea8c1 --- /dev/null +++ b/core/i18n/navigation.ts @@ -0,0 +1,10 @@ +import { createNavigation } from 'next-intl/navigation'; + +import { routing } from './routing'; + +// Lightweight wrappers around Next.js' navigation APIs +// that will consider the routing configuration +// Redirect will append locale prefix even when in default locale +// More info: https://github.com/amannn/next-intl/issues/1335 +export const { Link, redirect, usePathname, useRouter, permanentRedirect } = + createNavigation(routing); diff --git a/core/i18n/request.ts b/core/i18n/request.ts index c5cc74a36f..d9b1997a36 100644 --- a/core/i18n/request.ts +++ b/core/i18n/request.ts @@ -1,5 +1,7 @@ import deepmerge from 'deepmerge'; import { notFound } from 'next/navigation'; +import * as rootParams from 'next/root-params'; +import { hasLocale } from 'next-intl'; import { getRequestConfig } from 'next-intl/server'; import { locales } from './locales'; @@ -7,11 +9,21 @@ import { locales } from './locales'; // The language to fall back to if the requested message string is not available. const fallbackLocale = 'en'; -export default getRequestConfig(async ({ requestLocale }) => { - const locale = await requestLocale; +export default getRequestConfig(async ({ locale: inputLocale }) => { + // When locale is not provided, resolve from root-params. + // rootParams.locale() reads from the URL path, not headers(), + // so it's safe inside 'use cache' and cacheComponents. + let locale = inputLocale; - if (!locale || !locales.includes(locale)) { - notFound(); + if (!locale) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const paramValue = await rootParams.locale(); + + if (hasLocale(locales, paramValue)) { + locale = paramValue; + } else { + notFound(); + } } if (locale === fallbackLocale) { diff --git a/core/i18n/routing.ts b/core/i18n/routing.ts index 60517b1884..e3087bb365 100644 --- a/core/i18n/routing.ts +++ b/core/i18n/routing.ts @@ -1,4 +1,3 @@ -import { createNavigation } from 'next-intl/navigation'; import { defineRouting } from 'next-intl/routing'; import { defaultLocale, locales } from './locales'; @@ -18,10 +17,3 @@ export const routing = defineRouting({ defaultLocale, localePrefix, }); - -// Lightweight wrappers around Next.js' navigation APIs -// that will consider the routing configuration -// Redirect will append locale prefix even when in default locale -// More info: https://github.com/amannn/next-intl/issues/1335 -export const { Link, redirect, usePathname, useRouter, permanentRedirect } = - createNavigation(routing); diff --git a/core/lib/recaptcha.ts b/core/lib/recaptcha.ts index 77d5aa1518..e0836e0187 100644 --- a/core/lib/recaptcha.ts +++ b/core/lib/recaptcha.ts @@ -1,5 +1,6 @@ import 'server-only'; +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -24,10 +25,14 @@ export const ReCaptchaSettingsQuery = graphql(` } `); -export const getReCaptchaSettings = cache(async (): Promise => { +async function getCachedReCaptchaSettings(): Promise { + 'use cache'; + + cacheLife({ revalidate }); + const { data } = await client.fetch({ document: ReCaptchaSettingsQuery, - fetchOptions: { next: { revalidate } }, + fetchOptions: { cache: 'no-store' }, }); const reCaptcha = data.site.settings?.reCaptcha; @@ -40,7 +45,9 @@ export const getReCaptchaSettings = cache(async (): Promise => { const settings = await getReCaptchaSettings(); diff --git a/core/lib/seo/canonical.ts b/core/lib/seo/canonical.ts index dd1573947a..2252e758e1 100644 --- a/core/lib/seo/canonical.ts +++ b/core/lib/seo/canonical.ts @@ -1,3 +1,4 @@ +import { cacheLife } from 'next/cache'; import { cache } from 'react'; import { client } from '~/client'; @@ -45,10 +46,14 @@ const VanityUrlQuery = graphql(` } `); -const getVanityUrl = cache(async () => { +async function getCachedVanityUrl() { + 'use cache'; + + cacheLife({ revalidate }); + const { data } = await client.fetch({ document: VanityUrlQuery, - fetchOptions: { next: { revalidate } }, + fetchOptions: { cache: 'no-store' }, }); const vanityUrl = data.site.settings?.url.vanityUrl; @@ -58,7 +63,9 @@ const getVanityUrl = cache(async () => { } return vanityUrl; -}); +} + +const getVanityUrl = cache(getCachedVanityUrl); export async function getMetadataAlternates(options: CanonicalUrlOptions) { const { path, locale, includeAlternates = true } = options; diff --git a/core/next.config.ts b/core/next.config.ts index 1f39548d60..1e6706e669 100644 --- a/core/next.config.ts +++ b/core/next.config.ts @@ -62,8 +62,10 @@ export default async (): Promise => { let nextConfig: NextConfig = { reactStrictMode: true, + cacheComponents: true, experimental: { optimizePackageImports: ['@icons-pack/react-simple-icons'], + rootParams: true, }, typescript: { ignoreBuildErrors: !!process.env.CI, diff --git a/core/package.json b/core/package.json index d9b6cd3a34..bb0c57e57c 100644 --- a/core/package.json +++ b/core/package.json @@ -49,18 +49,18 @@ "clsx": "^2.1.1", "content-security-policy-builder": "^2.3.0", "deepmerge": "^4.3.1", + "dompurify": "^3.3.1", "embla-carousel": "9.0.0-rc01", "embla-carousel-autoplay": "9.0.0-rc01", "embla-carousel-fade": "9.0.0-rc01", "embla-carousel-react": "9.0.0-rc01", "gql.tada": "^1.8.10", "graphql": "^16.11.0", - "dompurify": "^3.3.1", "jose": "^5.10.0", "lodash.debounce": "^4.0.8", "lru-cache": "^11.1.0", "lucide-react": "^0.474.0", - "next": "~16.1.6", + "next": "~16.2.3", "next-auth": "5.0.0-beta.30", "next-intl": "^4.6.1", "nuqs": "^2.4.3", diff --git a/core/vibes/soul/lib/streamable.tsx b/core/vibes/soul/lib/streamable.tsx index ad98eeadc6..8cf7077f43 100644 --- a/core/vibes/soul/lib/streamable.tsx +++ b/core/vibes/soul/lib/streamable.tsx @@ -1,12 +1,12 @@ import PLazy from 'p-lazy'; import { Suspense, use } from 'react'; -import { v4 as uuid } from 'uuid'; export type Streamable = T | Promise; // eslint-disable-next-line func-names const stableKeys = (function () { const cache = new WeakMap(); + let counter = 0; function getObjectKey(obj: object): string { const key = cache.get(obj); @@ -15,7 +15,8 @@ const stableKeys = (function () { return key; } - const keyValue = uuid(); + // eslint-disable-next-line no-plusplus + const keyValue = `sk_${(counter++).toString(36)}`; cache.set(obj, keyValue); diff --git a/core/vibes/soul/primitives/compare-card/add-to-cart-form.tsx b/core/vibes/soul/primitives/compare-card/add-to-cart-form.tsx index 0b7f993b1b..21e9879979 100644 --- a/core/vibes/soul/primitives/compare-card/add-to-cart-form.tsx +++ b/core/vibes/soul/primitives/compare-card/add-to-cart-form.tsx @@ -7,7 +7,7 @@ import { requestFormReset } from 'react-dom'; import { Button } from '@/vibes/soul/primitives/button'; import { toast } from '@/vibes/soul/primitives/toaster'; import { useEvents } from '~/components/analytics/events'; -import { useRouter } from '~/i18n/routing'; +import { useRouter } from '~/i18n/navigation'; type Action = (state: Awaited, payload: P) => S | Promise; diff --git a/core/vibes/soul/primitives/navigation/index.tsx b/core/vibes/soul/primitives/navigation/index.tsx index f539252327..e5cf36cea6 100644 --- a/core/vibes/soul/primitives/navigation/index.tsx +++ b/core/vibes/soul/primitives/navigation/index.tsx @@ -35,7 +35,7 @@ import { Logo } from '@/vibes/soul/primitives/logo'; import { Price } from '@/vibes/soul/primitives/price-label'; import { ProductCard } from '@/vibes/soul/primitives/product-card'; import { Link } from '~/components/link'; -import { usePathname, useRouter } from '~/i18n/routing'; +import { usePathname, useRouter } from '~/i18n/navigation'; import { useSearch } from '~/lib/search'; interface Link { diff --git a/core/vibes/soul/primitives/wishlist-item-card/wishlist-item-add-to-cart.tsx b/core/vibes/soul/primitives/wishlist-item-card/wishlist-item-add-to-cart.tsx index 969f45ff7a..e3eb2f8fea 100644 --- a/core/vibes/soul/primitives/wishlist-item-card/wishlist-item-add-to-cart.tsx +++ b/core/vibes/soul/primitives/wishlist-item-card/wishlist-item-add-to-cart.tsx @@ -7,7 +7,7 @@ import { requestFormReset, useFormStatus } from 'react-dom'; import { Button } from '@/vibes/soul/primitives/button'; import { toast } from '@/vibes/soul/primitives/toaster'; import { useEvents } from '~/components/analytics/events'; -import { useRouter } from '~/i18n/routing'; +import { useRouter } from '~/i18n/navigation'; import { WishlistItem } from '.'; diff --git a/core/vibes/soul/sections/order-details-section/index.tsx b/core/vibes/soul/sections/order-details-section/index.tsx index 1989b50f79..33d8e15ae0 100644 --- a/core/vibes/soul/sections/order-details-section/index.tsx +++ b/core/vibes/soul/sections/order-details-section/index.tsx @@ -95,7 +95,7 @@ export interface Order { export interface OrderDetailsSectionProps { order: Streamable; - title?: string; + title?: Streamable; orderSummaryLabel?: string; shipmentAddressLabel?: string; shipmentMethodLabel?: string; @@ -141,9 +141,9 @@ export function OrderDetailsSection({
} - value={streamableOrder} + value={Streamable.all([streamableOrder, title ?? ''])} > - {(order) => ( + {([order, resolvedTitle]) => ( <>
{prevHref !== '' && ( @@ -154,7 +154,7 @@ export function OrderDetailsSection({

- {title ?? `Order #${order.id}`} + {resolvedTitle || `Order #${order.id}`}

{order.status}
@@ -538,11 +538,7 @@ function OrderDetailsSectionSkeleton({ return (
- {prevHref != null && prevHref !== '' && ( - - - - )} + {prevHref != null && prevHref !== '' && }
diff --git a/core/vibes/soul/sections/product-detail/product-detail-form.tsx b/core/vibes/soul/sections/product-detail/product-detail-form.tsx index dace2f9dd0..1a527d07b0 100644 --- a/core/vibes/soul/sections/product-detail/product-detail-form.tsx +++ b/core/vibes/soul/sections/product-detail/product-detail-form.tsx @@ -31,7 +31,7 @@ import { Textarea } from '@/vibes/soul/form/textarea'; import { Button } from '@/vibes/soul/primitives/button'; import { toast } from '@/vibes/soul/primitives/toaster'; import { useEvents } from '~/components/analytics/events'; -import { usePathname, useRouter } from '~/i18n/routing'; +import { usePathname, useRouter } from '~/i18n/navigation'; import { revalidateCart } from './actions/revalidate-cart'; import { Field, schema, SchemaRawShape } from './schema'; diff --git a/core/vibes/soul/sections/product-list/index.tsx b/core/vibes/soul/sections/product-list/index.tsx index 9439010a6f..45fd4f2688 100644 --- a/core/vibes/soul/sections/product-list/index.tsx +++ b/core/vibes/soul/sections/product-list/index.tsx @@ -11,7 +11,7 @@ import * as Skeleton from '@/vibes/soul/primitives/skeleton'; interface ProductListProps { products: Streamable; - showRating?: boolean; + showRating?: Streamable; compareProducts?: Streamable; className?: string; colorScheme?: 'light' | 'dark'; @@ -72,6 +72,7 @@ export function ProductList({ streamableCompareProducts, streamableRemoveLabel, streamableMaxCompareLimitMessage, + showRating ?? false, ])} > {([ @@ -81,6 +82,7 @@ export function ProductList({ compareProducts, removeLabel, maxCompareLimitMessage, + resolvedShowRating, ]) => { if (products.length === 0) { return ( @@ -110,7 +112,7 @@ export function ProductList({ key={product.id} product={product} showCompare={showCompare} - showRating={showRating} + showRating={resolvedShowRating} /> ))}
diff --git a/core/vibes/soul/sections/products-list-section/index.tsx b/core/vibes/soul/sections/products-list-section/index.tsx index 6efc97fd94..3e16dc8acf 100644 --- a/core/vibes/soul/sections/products-list-section/index.tsx +++ b/core/vibes/soul/sections/products-list-section/index.tsx @@ -30,7 +30,7 @@ interface Props { filterLabel?: string; filtersPanelTitle?: Streamable; resetFiltersLabel?: Streamable; - showRating?: boolean; + showRating?: Streamable; rangeFilterApplyLabel?: Streamable; sortLabel?: Streamable; sortPlaceholder?: Streamable; diff --git a/core/vibes/soul/sections/sidebar-menu/sidebar-menu-link.tsx b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-link.tsx index 0c5e544099..771f576bca 100644 --- a/core/vibes/soul/sections/sidebar-menu/sidebar-menu-link.tsx +++ b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-link.tsx @@ -4,7 +4,7 @@ import { clsx } from 'clsx'; import React from 'react'; import { Link } from '~/components/link'; -import { usePathname } from '~/i18n/routing'; +import { usePathname } from '~/i18n/navigation'; export function SidebarMenuLink({ className, diff --git a/core/vibes/soul/sections/sidebar-menu/sidebar-menu-select.tsx b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-select.tsx index 3d0407c00a..f862857fc5 100644 --- a/core/vibes/soul/sections/sidebar-menu/sidebar-menu-select.tsx +++ b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-select.tsx @@ -1,7 +1,7 @@ 'use client'; import { Select } from '@/vibes/soul/form/select'; -import { usePathname, useRouter } from '~/i18n/routing'; +import { usePathname, useRouter } from '~/i18n/navigation'; export function SidebarMenuSelect({ links }: { links: Array<{ href: string; label: string }> }) { const pathname = usePathname(); diff --git a/core/vibes/soul/sections/wishlist-details/index.tsx b/core/vibes/soul/sections/wishlist-details/index.tsx index 5301a30895..a9dbd48d21 100644 --- a/core/vibes/soul/sections/wishlist-details/index.tsx +++ b/core/vibes/soul/sections/wishlist-details/index.tsx @@ -221,15 +221,9 @@ function WishlistDetailSkeleton({
- {prevHref != null && - prevHref !== '' && - (prevHref ? ( - - - - ) : ( - - ))} + {prevHref != null && prevHref !== '' && ( + + )}
{ platform?: string; backendUserAgentExtensions?: string; logger?: boolean; - getChannelId?: (defaultChannelId: string) => Promise | string; + getChannelId?: (defaultChannelId: string, locale?: string) => Promise | string; beforeRequest?: ( fetchOptions?: FetcherRequestInit, ) => Promise | undefined> | Partial | undefined; @@ -49,7 +49,7 @@ type GraphQLErrorPolicy = 'none' | 'all' | 'auth' | 'ignore'; class Client { private backendUserAgent: string; private readonly defaultChannelId: string; - private getChannelId: (defaultChannelId: string) => Promise | string; + private getChannelId: (defaultChannelId: string, locale?: string) => Promise | string; private beforeRequest?: ( fetchOptions?: FetcherRequestInit, ) => Promise | undefined> | Partial | undefined; @@ -85,6 +85,7 @@ class Client { customerAccessToken?: string; fetchOptions?: FetcherRequestInit; channelId?: string; + locale?: string; errorPolicy?: GraphQLErrorPolicy; validateCustomerAccessToken?: boolean; }): Promise>; @@ -96,6 +97,7 @@ class Client { customerAccessToken?: string; fetchOptions?: FetcherRequestInit; channelId?: string; + locale?: string; errorPolicy?: GraphQLErrorPolicy; validateCustomerAccessToken?: boolean; }): Promise>; @@ -106,6 +108,7 @@ class Client { customerAccessToken, fetchOptions = {} as FetcherRequestInit, channelId, + locale, errorPolicy = 'none', validateCustomerAccessToken = true, }: { @@ -114,6 +117,7 @@ class Client { customerAccessToken?: string; fetchOptions?: FetcherRequestInit; channelId?: string; + locale?: string; errorPolicy?: GraphQLErrorPolicy; validateCustomerAccessToken?: boolean; }): Promise> { @@ -126,6 +130,7 @@ class Client { channelId, operationInfo.name, operationInfo.type, + locale, ); const { headers: additionalFetchHeaders = {}, ...additionalFetchOptions } = (await this.beforeRequest?.(fetchOptions)) ?? {}; @@ -136,6 +141,7 @@ class Client { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.storefrontToken}`, 'User-Agent': this.backendUserAgent, + ...(locale && { 'Accept-Language': locale }), ...(customerAccessToken && { 'X-Bc-Customer-Access-Token': customerAccessToken }), ...(validateCustomerAccessToken && { 'X-Bc-Error-On-Invalid-Customer-Access-Token': 'true', @@ -210,8 +216,8 @@ class Client { return response.text(); } - private async getCanonicalUrl(channelId?: string) { - const resolvedChannelId = channelId ?? (await this.getChannelId(this.defaultChannelId)); + private async getCanonicalUrl(channelId?: string, locale?: string) { + const resolvedChannelId = channelId ?? (await this.getChannelId(this.defaultChannelId, locale)); return `https://store-${this.config.storeHash}-${resolvedChannelId}.${graphqlApiDomain}`; } @@ -220,8 +226,9 @@ class Client { channelId?: string, operationName?: string, operationType?: string, + locale?: string, ) { - const baseUrl = new URL(`${await this.getCanonicalUrl(channelId)}/graphql`); + const baseUrl = new URL(`${await this.getCanonicalUrl(channelId, locale)}/graphql`); if (operationName) { baseUrl.searchParams.set('operation', operationName); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18248ad97d..8eaeb0c42e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: link:../packages/client '@c15t/nextjs': specifier: ^1.8.2 - version: 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) + version: 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) '@conform-to/react': specifier: ^1.6.1 version: 1.6.1(react@19.1.5) @@ -115,7 +115,7 @@ importers: version: 1.35.0 '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) + version: 1.5.0(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) '@vercel/functions': specifier: ^2.2.12 version: 2.2.12(@aws-sdk/credential-provider-web-identity@3.864.0) @@ -124,7 +124,7 @@ importers: version: 2.1.0(@opentelemetry/api-logs@0.208.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)) '@vercel/speed-insights': specifier: ^1.2.0 - version: 1.2.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) + version: 1.2.0(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -168,17 +168,17 @@ importers: specifier: ^0.474.0 version: 0.474.0(react@19.1.5) next: - specifier: ~16.1.6 - version: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + specifier: ~16.2.3 + version: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) next-auth: specifier: 5.0.0-beta.30 - version: 5.0.0-beta.30(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5) + version: 5.0.0-beta.30(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5) next-intl: specifier: ^4.6.1 - version: 4.8.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(typescript@5.8.3) + version: 4.8.3(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(typescript@5.8.3) nuqs: specifier: ^2.4.3 - version: 2.4.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5) + version: 2.4.3(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5) p-lazy: specifier: ^5.0.0 version: 5.0.0 @@ -2509,8 +2509,8 @@ packages: '@next/bundle-analyzer@16.1.6': resolution: {integrity: sha512-ee2kagdTaeEWPlotgdTOqFHYcD3e2m2bbE3I9Rq2i6ABYi5OgopmtEUe8NM23viaYxLV2tDH/2nd5+qKoEr6cw==} - '@next/env@16.1.6': - resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + '@next/env@16.2.3': + resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==} '@next/eslint-plugin-next@15.3.3': resolution: {integrity: sha512-VKZJEiEdpKkfBmcokGjHu0vGDG+8CehGs90tBEy/IDoDDKGngeyIStt2MmE5FYNyU9BhgR7tybNWTAJY/30u+Q==} @@ -2518,50 +2518,50 @@ packages: '@next/eslint-plugin-next@15.5.10': resolution: {integrity: sha512-fDpxcy6G7Il4lQVVsaJD0fdC2/+SmuBGTF+edRLlsR4ZFOE3W2VyzrrGYdg/pHW8TydeAdSVM+mIzITGtZ3yWA==} - '@next/swc-darwin-arm64@16.1.6': - resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + '@next/swc-darwin-arm64@16.2.3': + resolution: {integrity: sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.1.6': - resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + '@next/swc-darwin-x64@16.2.3': + resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.1.6': - resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + '@next/swc-linux-arm64-gnu@16.2.3': + resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.1.6': - resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + '@next/swc-linux-arm64-musl@16.2.3': + resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.1.6': - resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + '@next/swc-linux-x64-gnu@16.2.3': + resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.1.6': - resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + '@next/swc-linux-x64-musl@16.2.3': + resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.1.6': - resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + '@next/swc-win32-arm64-msvc@16.2.3': + resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.1.6': - resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + '@next/swc-win32-x64-msvc@16.2.3': + resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -5832,14 +5832,6 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} - dedent@1.5.3: - resolution: {integrity: sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - dedent@1.7.0: resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} peerDependencies: @@ -8057,8 +8049,8 @@ packages: typescript: optional: true - next@16.1.6: - resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + next@16.2.3: + resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -8842,7 +8834,6 @@ packages: puppeteer@24.10.0: resolution: {integrity: sha512-Oua9VkGpj0S2psYu5e6mCer6W9AU9POEQh22wRgSXnLXASGH+MwLUVWgLCLeP9QPHHcJ7tySUlg4Sa9OJmaLpw==} engines: {node: '>=18'} - deprecated: < 24.15.0 is no longer supported hasBin: true pure-rand@6.1.0: @@ -10412,8 +10403,8 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@asamuzakjp/css-color@3.2.0': dependencies: @@ -11477,8 +11468,8 @@ snapshots: dependencies: '@babel/parser': 7.28.0 '@babel/types': 7.28.0 - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 '@babel/helper-compilation-targets@7.27.2': @@ -11667,9 +11658,9 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-config-prettier: 9.1.0(eslint@8.57.1) - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-gettext: 1.2.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jest: 28.11.0(@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(jest@29.7.0(@types/node@22.15.30)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(typescript@5.8.3) eslint-plugin-jest-dom: 5.5.0(eslint@8.57.1) eslint-plugin-jest-formatting: 3.1.0(eslint@8.57.1) @@ -11774,11 +11765,11 @@ snapshots: neverthrow: 8.2.0 picocolors: 1.1.1 - '@c15t/nextjs@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': + '@c15t/nextjs@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': dependencies: '@c15t/react': 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) '@c15t/translations': 1.8.0 - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) react: 19.1.5 react-dom: 19.1.5(react@19.1.5) transitivePeerDependencies: @@ -13132,7 +13123,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 '@types/node': 22.15.30 chalk: 4.1.2 collect-v8-coverage: 1.0.2 @@ -13160,7 +13151,7 @@ snapshots: '@jest/source-map@29.6.3': dependencies: - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 callsites: 3.1.0 graceful-fs: 4.2.11 @@ -13182,7 +13173,7 @@ snapshots: dependencies: '@babel/core': 7.27.4 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 @@ -13304,7 +13295,7 @@ snapshots: - bufferutil - utf-8-validate - '@next/env@16.1.6': {} + '@next/env@16.2.3': {} '@next/eslint-plugin-next@15.3.3': dependencies: @@ -13314,28 +13305,28 @@ snapshots: dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.1.6': + '@next/swc-darwin-arm64@16.2.3': optional: true - '@next/swc-darwin-x64@16.1.6': + '@next/swc-darwin-x64@16.2.3': optional: true - '@next/swc-linux-arm64-gnu@16.1.6': + '@next/swc-linux-arm64-gnu@16.2.3': optional: true - '@next/swc-linux-arm64-musl@16.1.6': + '@next/swc-linux-arm64-musl@16.2.3': optional: true - '@next/swc-linux-x64-gnu@16.1.6': + '@next/swc-linux-x64-gnu@16.2.3': optional: true - '@next/swc-linux-x64-musl@16.1.6': + '@next/swc-linux-x64-musl@16.2.3': optional: true - '@next/swc-win32-arm64-msvc@16.1.6': + '@next/swc-win32-arm64-msvc@16.2.3': optional: true - '@next/swc-win32-x64-msvc@16.1.6': + '@next/swc-win32-x64-msvc@16.2.3': optional: true '@noble/ciphers@1.3.0': {} @@ -16211,9 +16202,9 @@ snapshots: dependencies: uncrypto: 0.1.3 - '@vercel/analytics@1.5.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': + '@vercel/analytics@1.5.0(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': optionalDependencies: - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) react: 19.1.5 svelte: 5.1.15 vue: 3.5.16(typescript@5.8.3) @@ -16239,9 +16230,9 @@ snapshots: '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - '@vercel/speed-insights@1.2.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': + '@vercel/speed-insights@1.2.0(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': optionalDependencies: - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) react: 19.1.5 svelte: 5.1.15 vue: 3.5.16(typescript@5.8.3) @@ -16340,7 +16331,7 @@ snapshots: '@vue/compiler-ssr': 3.5.16 '@vue/shared': 3.5.16 estree-walker: 2.0.2 - magic-string: 0.30.17 + magic-string: 0.30.21 postcss: 8.5.6 source-map-js: 1.2.1 @@ -17302,8 +17293,6 @@ snapshots: dependencies: mimic-response: 3.1.0 - dedent@1.5.3: {} - dedent@1.7.0: {} deep-eql@5.0.2: {} @@ -17709,8 +17698,8 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -17737,6 +17726,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.1 + eslint: 8.57.1 + get-tsconfig: 4.10.0 + is-bun-module: 1.3.0 + rspack-resolver: 1.2.2 + stable-hash: 0.0.5 + tinyglobby: 0.2.14 + optionalDependencies: + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + transitivePeerDependencies: + - supports-color + eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -17759,7 +17763,7 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -17773,6 +17777,35 @@ snapshots: dependencies: gettext-parser: 4.2.0 + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 @@ -19018,7 +19051,7 @@ snapshots: '@types/node': 22.15.30 chalk: 4.1.2 co: 4.6.0 - dedent: 1.5.3 + dedent: 1.7.0 is-generator-fn: 2.1.0 jest-each: 29.7.0 jest-matcher-utils: 29.7.0 @@ -19312,7 +19345,7 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.7.2 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -19763,7 +19796,6 @@ snapshots: magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - optional: true magicast@0.3.5: dependencies: @@ -19777,7 +19809,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.4 make-error@1.3.6: optional: true @@ -19956,22 +19988,22 @@ snapshots: optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.44.2 - next-auth@5.0.0-beta.30(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): + next-auth@5.0.0-beta.30(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): dependencies: '@auth/core': 0.41.0 - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) react: 19.1.5 next-intl-swc-plugin-extractor@4.8.3: {} - next-intl@4.8.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(typescript@5.8.3): + next-intl@4.8.3(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(typescript@5.8.3): dependencies: '@formatjs/intl-localematcher': 0.8.1 '@parcel/watcher': 2.5.1 '@swc/core': 1.15.18 icu-minify: 4.8.3 negotiator: 1.0.0 - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) next-intl-swc-plugin-extractor: 4.8.3 po-parser: 2.1.1 react: 19.1.5 @@ -19981,9 +20013,9 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5): + next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5): dependencies: - '@next/env': 16.1.6 + '@next/env': 16.2.3 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.7 caniuse-lite: 1.0.30001721 @@ -19992,14 +20024,14 @@ snapshots: react-dom: 19.1.5(react@19.1.5) styled-jsx: 5.1.6(@babel/core@7.27.4)(react@19.1.5) optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 + '@next/swc-darwin-arm64': 16.2.3 + '@next/swc-darwin-x64': 16.2.3 + '@next/swc-linux-arm64-gnu': 16.2.3 + '@next/swc-linux-arm64-musl': 16.2.3 + '@next/swc-linux-x64-gnu': 16.2.3 + '@next/swc-linux-x64-musl': 16.2.3 + '@next/swc-win32-arm64-msvc': 16.2.3 + '@next/swc-win32-x64-msvc': 16.2.3 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.52.0 sharp: 0.34.5 @@ -20050,12 +20082,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.4.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): + nuqs@2.4.3(next@16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): dependencies: mitt: 3.0.1 react: 19.1.5 optionalDependencies: - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) nwsapi@2.2.20: optional: true @@ -22175,7 +22207,7 @@ snapshots: v8-to-istanbul@9.2.0: dependencies: - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0