Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ Files can be placed in `cms/components/` and `cms/pages/`, or co-located alongsi

> For schema syntax and the full architectural overview, see the [CMS architecture and schema declarations](https://developers.vtex.com/docs/guides/understanding-cms-architecture-and-schema-declarations) guide.

### Testing Content Platform branch variants

When `contentSource.type` is set to `CP`, a storefront URL can render content from a Content Platform branch by adding the `__variant` query parameter. The value must be the Content Platform branch UUID, not the branch display name.

```text
/?__variant=ebe75532-bb2f-417a-9c02-9b9f1c957367
```

The proxy rewrites the request to the internal `/_variant/[branchId]` route. That route resolves all Content Platform entries with the same `branchId` while keeping Next.js preview mode disabled, so ISR behavior remains unchanged.

## How to test

```sh
Expand Down
66 changes: 66 additions & 0 deletions packages/core/src/components/ExperimentDataLayer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useEffect } from 'react'

/**
* The PoC runs a single A/B experiment, so the experiment id is hardcoded.
* When the program graduates beyond one experiment this should be sourced from
* config or the edge alongside the variant cookie.
*/
export const EXPERIMENT_ID = 'ab-test-variant-branch'

/** Session cookie set by the edge with the assigned variant. */
export const EXPERIMENT_VARIANT_COOKIE = 'vtex_exp_variant'

export type ExperimentContext = {
experiment_id: string
variant_id: string
}

/** Reads a single cookie value from a `document.cookie` string. */
export function readCookie(
name: string,
cookieString: string
): string | undefined {
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))

Check notice on line 23 in packages/core/src/components/ExperimentDataLayer/index.tsx

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/core/src/components/ExperimentDataLayer/index.tsx#L23

`String.raw` should be used to avoid escaping `\`.

Check notice on line 23 in packages/core/src/components/ExperimentDataLayer/index.tsx

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/core/src/components/ExperimentDataLayer/index.tsx#L23

Use the "RegExp.exec()" method instead.
return match ? decodeURIComponent(match[1]) : undefined
}

/**
* Pushes the experiment context to the provided dataLayer when the variant
* cookie is present. No-op when the cookie is absent so non-experiment traffic
* is never tagged.
*/
export function pushExperimentContext(
cookieString: string,
dataLayer: Array<Record<string, unknown>>
): void {
const variantId = readCookie(EXPERIMENT_VARIANT_COOKIE, cookieString)

if (!variantId) {
return
}

dataLayer.push({
experiment_id: EXPERIMENT_ID,
variant_id: variantId,
} satisfies ExperimentContext)
}

/**
* Client-side script that reads the `vtex_exp_variant` cookie set by the edge
* and pushes the experiment context to `window.dataLayer`, so the existing
* Activity Flow can tag events with the experiment/variant.
*/
function ExperimentDataLayer(): null {
useEffect(() => {
if (typeof window === 'undefined') {

Check notice on line 55 in packages/core/src/components/ExperimentDataLayer/index.tsx

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/core/src/components/ExperimentDataLayer/index.tsx#L55

Prefer `globalThis.window` over `window`.
return
}

window.dataLayer = window.dataLayer ?? []

Check notice on line 59 in packages/core/src/components/ExperimentDataLayer/index.tsx

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/core/src/components/ExperimentDataLayer/index.tsx#L59

Prefer `globalThis` over `window`.

Check notice on line 59 in packages/core/src/components/ExperimentDataLayer/index.tsx

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/core/src/components/ExperimentDataLayer/index.tsx#L59

Prefer `globalThis` over `window`.
pushExperimentContext(document.cookie, window.dataLayer)

Check notice on line 60 in packages/core/src/components/ExperimentDataLayer/index.tsx

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/core/src/components/ExperimentDataLayer/index.tsx#L60

Prefer `globalThis` over `window`.
}, [])

return null
}

export default ExperimentDataLayer
2 changes: 2 additions & 0 deletions packages/core/src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useSearch } from '@faststore/sdk'
import { UIProvider } from '@faststore/ui'

import { useReloadAfterLogoutReturn } from 'src/components/account/MyAccountDrawer/OrganizationDrawer/useReloadAfterLogoutReturn'
import ExperimentDataLayer from 'src/components/ExperimentDataLayer'
import ThirdPartyScripts from 'src/components/ThirdPartyScripts'
import Layout from 'src/Layout'
import AnalyticsHandler from 'src/sdk/analytics'
Expand Down Expand Up @@ -47,6 +48,7 @@ function App({ Component, pageProps }: AppProps) {
<DefaultSeo {...SEO} />

<AnalyticsHandler />
<ExperimentDataLayer />

<UIProvider>
<DeliveryPromiseProvider>
Expand Down
215 changes: 215 additions & 0 deletions packages/core/src/pages/_variant/[branchId]/[...slug].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { isNotFoundError } from '@faststore/api'
import storeConfig from 'discovery.config'
import type { GetStaticPaths, GetStaticProps } from 'next'

import { gql } from '@generated'
import type {
ServerCollectionPageQueryQuery,
ServerCollectionPageQueryQueryVariables,
ServerManyProductsQueryQuery,
ServerManyProductsQueryQueryVariables,
} from '@generated/graphql'
import type { SearchState } from '@faststore/sdk'
import {
type GlobalSectionsData,
getGlobalSectionsData,
} from 'src/components/cms/GlobalSections'
import { getLandingPageBySlug } from 'src/components/templates/LandingPage'
import { getRedirect } from 'src/sdk/redirects'
import { execute } from 'src/server'
import type { PageContentType } from 'src/server/cms'
import { injectGlobalSections } from 'src/server/cms/global'
import type { PLPContentType } from 'src/server/cms/plp'
import { contentService } from 'src/server/content/service'
import type { PreviewData } from 'src/server/content/types'
import { getVariantBranchId } from 'src/server/content/utils'
import { getDynamicContent } from 'src/utils/dynamicContent'
import { fetchServerManyProducts } from 'src/utils/fetchProductGallerySSR'

// Reuse the original PLP/landing page component — only the data fetching differs.
import CatchAllPage from '../../[...slug]'

type BaseProps = {
globalSections: GlobalSectionsData
}

type Props = BaseProps &
(
| {
type: 'plp'
page: PLPContentType
data: ServerCollectionPageQueryQuery & ServerManyProductsQueryQuery
serverManyProductsVariables: ServerManyProductsQueryQueryVariables
}
| {
type: 'page'
slug: string
page: PageContentType
serverData?: unknown
}
)

const query = gql(`
query ServerCollectionPageQuery($slug: String!) {
...ServerCollectionPage
collection(slug: $slug) {
seo {
title
description
}
breadcrumbList {
itemListElement {
item
name
position
}
}
meta {
selectedFacets {
key
value
}
}
}
}
`)

export const getStaticProps: GetStaticProps<
Props,
{ branchId: string; slug: string[] },
PreviewData
> = async ({ params, previewData, locale }) => {
const slug = params?.slug.join('/') ?? ''
const branchId = getVariantBranchId(params)
const rewrites = (await storeConfig.rewrites?.()) ?? []
const contentContext = { previewData, locale, branchId }

const [
globalSectionsPromise,
globalSectionsHeaderPromise,
globalSectionsFooterPromise,
] = getGlobalSectionsData(contentContext)

const landingPagePromise = getLandingPageBySlug(slug, contentContext)

const landingPage = await landingPagePromise

if (landingPage && Object.keys(landingPage).length > 0) {
const [
serverData,
globalSections,
globalSectionsHeader,
globalSectionsFooter,
] = await Promise.all([
getDynamicContent({ pageType: slug }),
globalSectionsPromise,
globalSectionsHeaderPromise,
globalSectionsFooterPromise,
])

const globalSectionsResult = injectGlobalSections({
globalSections,
globalSectionsHeader,
globalSectionsFooter,
})

return {
props: {
page: landingPage,
globalSections: globalSectionsResult,
type: 'page',
slug,
serverData,
},
}
}

const [
{ data, errors = [] },
cmsPage,
globalSections,
globalSectionsHeader,
globalSectionsFooter,
] = await Promise.all([
execute<
ServerCollectionPageQueryQueryVariables,
ServerCollectionPageQueryQuery
>({
variables: { slug },
operation: query,
}),
contentService.getPlpContent(
{
...contentContext,
slug,
locale,
},
rewrites
),
globalSectionsPromise,
globalSectionsHeaderPromise,
globalSectionsFooterPromise,
])

const [serverManyProductsData, serverManyProductsVariables] =
await fetchServerManyProducts({
itemsPerPage: cmsPage?.settings?.productGallery?.itemsPerPage,
sort: cmsPage?.settings?.productGallery
?.sortBySelection as SearchState['sort'],
term: '',
selectedFacets: data?.collection?.meta.selectedFacets,
locale,
})

const notFound = errors.find(isNotFoundError)

if (notFound) {
if (storeConfig.experimental.enableRedirects) {
const redirect = await getRedirect({ pathname: `/${slug}` })
if (redirect) {
return {
redirect,
revalidate: 60 * 5, // 5 minutes
}
}
}

return {
notFound: true,
}
}

if (errors.length > 0) {
console.error(...errors)
throw errors[0]
}

const globalSectionsResult = injectGlobalSections({
globalSections,
globalSectionsHeader,
globalSectionsFooter,
})

return {
props: {
data: {
...data,
...serverManyProductsData,
},
serverManyProductsVariables,
page: cmsPage,
globalSections: globalSectionsResult,
type: 'plp',
key: slug,
},
}
}

export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: [],
fallback: 'blocking',
}
}

export default CatchAllPage
Loading
Loading