diff --git a/packages/api/src/platforms/vtex/clients/search/index.ts b/packages/api/src/platforms/vtex/clients/search/index.ts index a8d3d81610..3efa9a4d7a 100644 --- a/packages/api/src/platforms/vtex/clients/search/index.ts +++ b/packages/api/src/platforms/vtex/clients/search/index.ts @@ -39,6 +39,7 @@ export interface SearchArgs { showInvisibleItems?: boolean showSponsored?: boolean sponsoredCount?: number + regionId?: string } export interface ProductLocator { @@ -101,24 +102,30 @@ export const IntelligentSearch = ( } } - const getRegionFacet = (): IStoreSelectedFacet | null => { - const { regionId, seller } = ctx.storage.channel + const getRegionFacet = ( + queryRegionId?: string + ): IStoreSelectedFacet | null => { + const { regionId: channelRegionId, seller } = ctx.storage.channel const sellerRegionId = seller ? Buffer.from(`SW#${seller}`).toString('base64') : null - const facet = sellerRegionId ?? regionId + const regionId = sellerRegionId ?? channelRegionId + const regionFacet = regionId ?? queryRegionId - if (!facet) { + if (!regionFacet) { return null } return { key: REGION_KEY, - value: facet, + value: regionFacet, } } - const addDefaultFacets = (facets: SelectedFacet[]) => { + const addDefaultFacets = ( + facets: SelectedFacet[], + queryRegionId?: string + ) => { const withDefaultFacets = facets.filter( ({ key }) => !EXTRA_FACETS_KEYS.has(key) ) @@ -127,7 +134,8 @@ export const IntelligentSearch = ( facets.find(({ key }) => key === POLICY_KEY) ?? getPolicyFacet() const regionFacet = - facets.find(({ key }) => key === REGION_KEY) ?? getRegionFacet() + facets.find(({ key }) => key === REGION_KEY) ?? + getRegionFacet(queryRegionId) if (policyFacet !== null) { withDefaultFacets.push(policyFacet) @@ -165,6 +173,7 @@ export const IntelligentSearch = ( type, showInvisibleItems, sponsoredCount, + regionId = undefined, }: SearchArgs): Promise => { const params = new URLSearchParams({ page: (page + 1).toString(), @@ -196,7 +205,7 @@ export const IntelligentSearch = ( params.append('sponsoredCount', sponsoredCount.toString()) } - const pathname = addDefaultFacets(selectedFacets) + const pathname = addDefaultFacets(selectedFacets, regionId) .map(({ key, value }) => `${key}/${value}`) .join('/') diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 4f607a72da..d32dfb043b 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -9,6 +9,7 @@ import { findSkuId, findSlug, transformSelectedFacet, + findRegionId, } from '../utils/facets' import { SORT_MAP } from '../utils/sort' import { StoreCollection } from './collection' @@ -34,6 +35,7 @@ export const Query = { const locale = findLocale(locator) const id = findSkuId(locator) const slug = findSlug(locator) + const regionId = findRegionId(locator) if (channel) { mutateChannelContext(ctx, channel) @@ -92,6 +94,7 @@ export const Query = { page: 0, count: 1, query: `product:${route.id}`, + regionId, }) if (!product) { diff --git a/packages/api/src/platforms/vtex/utils/facets.ts b/packages/api/src/platforms/vtex/utils/facets.ts index ef6512c10c..ecd162b4fc 100644 --- a/packages/api/src/platforms/vtex/utils/facets.ts +++ b/packages/api/src/platforms/vtex/utils/facets.ts @@ -98,3 +98,9 @@ export const findLocale = (facets?: Maybe) => export const findChannel = (facets?: Maybe) => facets?.find((facet) => facet.key === 'channel')?.value ?? null + +export const findRegionId = (facets?: Maybe) => { + const regionId = facets?.find((facet) => facet.key === 'regionId')?.value + + return regionId && regionId !== '' ? regionId : undefined +} diff --git a/packages/cli/src/utils/generate.ts b/packages/cli/src/utils/generate.ts index 5613ec6f67..151213cf95 100644 --- a/packages/cli/src/utils/generate.ts +++ b/packages/cli/src/utils/generate.ts @@ -373,7 +373,7 @@ function checkDependencies(basePath: string, packagesToCheck: string[]) { logger.warn( `${chalk.yellow( 'warning' - )} - Version mismatch detected for ${packageName}. + )} - Version mismatch detected for ${packageName}. Core: ${coreVersion}, Customization: ${rootVersion}. Please align both versions to prevent issues` ) } @@ -513,6 +513,27 @@ function enableSearchSSR(basePath: string) { writeFileSync(searchPagePath, searchPageWithSSR) } +function enablePdpSSR(basePath: string) { + const storeConfigPath = getCurrentUserStoreConfigFile(basePath) + + if (!storeConfigPath) { + return + } + + const storeConfig = require(storeConfigPath) + if (!storeConfig.experimental.enablePdpSSR) { + return + } + + const { tmpDir } = withBasePath(basePath) + const pdpPath = path.join(tmpDir, 'src', 'pages', 'p.tsx') + const pdpData = String(readFileSync(pdpPath)) + + const pdpWithSSR = pdpData.replaceAll('getStaticProps', 'getServerSideProps') + + writeFileSync(pdpPath, pdpWithSSR) +} + export async function generate(options: GenerateOptions) { const { basePath, setup = false } = options @@ -533,6 +554,7 @@ export async function generate(options: GenerateOptions) { setupPromise, checkDependencies(basePath, ['typescript']), enableSearchSSR(basePath), + enablePdpSSR(basePath), updateBuildTime(basePath), copyUserStarterToCustomizations(basePath), copyTheme(basePath), diff --git a/packages/core/discovery.config.default.js b/packages/core/discovery.config.default.js index 68776f8f7e..9e8fa63a5b 100644 --- a/packages/core/discovery.config.default.js +++ b/packages/core/discovery.config.default.js @@ -116,5 +116,6 @@ module.exports = { preact: false, enableRedirects: false, enableSearchSSR: false, + enablePdpSSR: false, }, } diff --git a/packages/core/src/components/region/RegionModal/RegionModal.tsx b/packages/core/src/components/region/RegionModal/RegionModal.tsx index a7908adad1..3569a90ef1 100644 --- a/packages/core/src/components/region/RegionModal/RegionModal.tsx +++ b/packages/core/src/components/region/RegionModal/RegionModal.tsx @@ -4,8 +4,10 @@ import { useUI, } from '@faststore/ui' import { useRef, useState } from 'react' +import type { Session } from '@faststore/sdk' import { sessionStore, useSession, validateSession } from 'src/sdk/session' +import { setCookie } from 'src/utils/cookies' import dynamic from 'next/dynamic' import styles from './section.module.scss' @@ -36,6 +38,17 @@ interface RegionModalProps { } } +async function setRegionIdCookie(session: Session) { + const ONE_WEEK_SECONDS = 7 * 24 * 3600 + const channel = JSON.parse(session.channel ?? 'null') + + setCookie( + 'vtex-faststore-regionid', + channel?.regionId as string, + ONE_WEEK_SECONDS + ) +} + function RegionModal({ title, description, @@ -70,6 +83,9 @@ function RegionModal({ const validatedSession = await validateSession(newSession) + // Set cookie for the regionId + await setRegionIdCookie(validatedSession ?? newSession) + sessionStore.set(validatedSession ?? newSession) } catch (error) { setErrorMessage(inputFieldErrorMessage) diff --git a/packages/core/src/experimental/pdpServerSideFunctions/getServerSideProps.ts b/packages/core/src/experimental/pdpServerSideFunctions/getServerSideProps.ts new file mode 100644 index 0000000000..f5427a76bd --- /dev/null +++ b/packages/core/src/experimental/pdpServerSideFunctions/getServerSideProps.ts @@ -0,0 +1,153 @@ +import type { GetServerSideProps } from 'next' +import type { Locator } from '@vtex/client-cms' +import { isNotFoundError } from '@faststore/api' + +import { + getGlobalSectionsData, + type GlobalSectionsData, +} from 'src/components/cms/GlobalSections' +import { execute } from 'src/server' +import { getPDP, type PDPContentType } from 'src/server/cms/pdp' +import { gql } from '@generated' +import type { + ServerProductQueryQuery, + ServerProductQueryQueryVariables, +} from '@generated/graphql' +import storeConfig from 'discovery.config' +import type { PDPProps, StoreConfig } from './getStaticProps' + +const query = gql(` + query ServerProductQuery($locator: [IStoreSelectedFacet!]!) { + ...ServerProduct + product(locator: $locator) { + id: productID + + seo { + title + description + canonical + } + + brand { + name + } + + sku + gtin + name + description + releaseDate + + breadcrumbList { + itemListElement { + item + name + position + } + } + + image { + url + alternateName + } + + offers { + lowPrice + highPrice + lowPriceWithTaxes + priceCurrency + offers { + availability + price + priceValidUntil + priceCurrency + itemCondition + seller { + identifier + } + } + } + + isVariantOf { + productGroupID + } + + ...ProductDetailsFragment_product + } + } +`) + +export const getServerSideProps: GetServerSideProps< + PDPProps, + { slug: string }, + Locator +> = async ({ params, previewData, req, res }) => { + const slug = params?.slug ?? '' + const regionId = req.cookies['vtex-faststore-regionid'] ?? '' + + const { data, errors = [] } = await execute< + ServerProductQueryQueryVariables, + ServerProductQueryQuery + >({ + variables: { + locator: [ + { key: 'slug', value: slug }, + { key: 'regionId', value: regionId }, + ], + }, + operation: query, + }) + + const notFound = errors.find(isNotFoundError) + + if (notFound) { + return { + notFound: true, + } + } + + if (errors.length > 0) { + throw errors[0] + } + + const globalSections = await getGlobalSectionsData(previewData) + const cmsPage: PDPContentType = await getPDP(data.product, previewData) + + const { seo } = data.product + const meta = { + title: seo.title || storeConfig.seo.title, + description: seo.description || storeConfig.seo.description, + canonical: `${storeConfig.storeUrl}${seo.canonical}`, + } + + let offer = {} + if (data.product.offers.offers.length > 0) { + const { listPrice, ...offerData } = data.product.offers.offers[0] + + offer = offerData + } + + const offers = { + ...offer, + priceCurrency: data.product.offers.priceCurrency, + url: meta.canonical, + } + + // 5 minutes of fresh content and 1 year of stale content + res.setHeader( + 'Cache-Control', + 'public, s-maxage=300, stale-while-revalidate=31536000, stale-if-error=31536000' + ) + + return { + props: { + data, + ...cmsPage, + meta, + offers, + globalSections, + key: seo.canonical, + }, + revalidate: (storeConfig as StoreConfig).experimental.revalidate ?? false, + } +} diff --git a/packages/core/src/experimental/pdpServerSideFunctions/getStaticProps.ts b/packages/core/src/experimental/pdpServerSideFunctions/getStaticProps.ts new file mode 100644 index 0000000000..a425ced21d --- /dev/null +++ b/packages/core/src/experimental/pdpServerSideFunctions/getStaticProps.ts @@ -0,0 +1,169 @@ +import type { GetStaticPaths, GetStaticProps } from 'next' +import type { Locator } from '@vtex/client-cms' +import { isNotFoundError } from '@faststore/api' + +import { + getGlobalSectionsData, + type GlobalSectionsData, +} from 'src/components/cms/GlobalSections' +import { execute } from 'src/server' +import { getPDP, type PDPContentType } from 'src/server/cms/pdp' +import { gql } from '@generated' +import type { + ServerProductQueryQuery, + ServerProductQueryQueryVariables, +} from '@generated/graphql' +import storeConfig from 'discovery.config' + +export type StoreConfig = typeof storeConfig & { + experimental: { + revalidate?: number + enableClientOffer?: boolean + } +} + +export type PDPProps = PDPContentType & { + data: ServerProductQueryQuery + globalSections: GlobalSectionsData + meta: { + title: string + description: string + canonical: string + } +} + +const query = gql(` + query ServerProductQuery($locator: [IStoreSelectedFacet!]!) { + ...ServerProduct + product(locator: $locator) { + id: productID + + seo { + title + description + canonical + } + + brand { + name + } + + sku + gtin + name + description + releaseDate + + breadcrumbList { + itemListElement { + item + name + position + } + } + + image { + url + alternateName + } + + offers { + lowPrice + highPrice + lowPriceWithTaxes + priceCurrency + offers { + availability + price + priceValidUntil + priceCurrency + itemCondition + seller { + identifier + } + } + } + + isVariantOf { + productGroupID + } + + ...ProductDetailsFragment_product + } + } +`) + +/* + Depending on the value of the storeConfig.experimental.enablePdpSSR flag, the function used will be getServerSideProps (./getServerSideProps). + Our CLI that does this process of converting from getStaticProps to getServerSideProps. +*/ +export const getStaticProps: GetStaticProps< + PDPProps, + { slug: string }, + Locator +> = async ({ params, previewData }) => { + const slug = params?.slug ?? '' + const [searchResult, globalSections] = await Promise.all([ + execute({ + variables: { locator: [{ key: 'slug', value: slug }] }, + operation: query, + }), + getGlobalSectionsData(previewData), + ]) + + const { data, errors = [] } = searchResult + + const notFound = errors.find(isNotFoundError) + + if (notFound) { + return { + notFound: true, + } + } + + if (errors.length > 0) { + throw errors[0] + } + + const cmsPage: PDPContentType = await getPDP(data.product, previewData) + + const { seo } = data.product + const title = seo.title || storeConfig.seo.title + const description = seo.description || storeConfig.seo.description + const canonical = `${storeConfig.storeUrl}${seo.canonical}` + + const meta = { title, description, canonical } + + let offer = {} + + if (data.product.offers.offers.length > 0) { + const { listPrice, ...offerData } = data.product.offers.offers[0] + + offer = offerData + } + + const offers = { + ...offer, + priceCurrency: data.product.offers.priceCurrency, + url: canonical, + } + + return { + props: { + data, + ...cmsPage, + meta, + offers, + globalSections, + key: seo.canonical, + }, + revalidate: (storeConfig as StoreConfig).experimental.revalidate ?? false, + } +} + +export const getStaticPaths: GetStaticPaths = async () => { + return { + paths: [], + fallback: 'blocking', + } +} diff --git a/packages/core/src/experimental/pdpServerSideFunctions/index.ts b/packages/core/src/experimental/pdpServerSideFunctions/index.ts new file mode 100644 index 0000000000..962debee2b --- /dev/null +++ b/packages/core/src/experimental/pdpServerSideFunctions/index.ts @@ -0,0 +1,2 @@ +export * from './getServerSideProps' +export * from './getStaticProps' diff --git a/packages/core/src/pages/[slug]/p.tsx b/packages/core/src/pages/[slug]/p.tsx index ce3924dd43..ef7c76bce3 100644 --- a/packages/core/src/pages/[slug]/p.tsx +++ b/packages/core/src/pages/[slug]/p.tsx @@ -1,16 +1,8 @@ -import { isNotFoundError } from '@faststore/api' -import type { Locator } from '@vtex/client-cms' import deepmerge from 'deepmerge' -import type { GetStaticPaths, GetStaticProps } from 'next' import { BreadcrumbJsonLd, NextSeo, ProductJsonLd } from 'next-seo' import Head from 'next/head' import type { ComponentType } from 'react' -import { gql } from '@generated' -import type { - ServerProductQueryQuery, - ServerProductQueryQueryVariables, -} from '@generated/graphql' import { default as GLOBAL_COMPONENTS } from 'src/components/cms/global/Components' import RenderSections from 'src/components/cms/RenderSections' import BannerNewsletter from 'src/components/sections/BannerNewsletter/BannerNewsletter' @@ -25,17 +17,17 @@ import ProductTiles from 'src/components/sections/ProductTiles' import CUSTOM_COMPONENTS from 'src/customizations/src/components' import PLUGINS_COMPONENTS from 'src/plugins' import { useSession } from 'src/sdk/session' -import { execute } from 'src/server' import storeConfig from 'discovery.config' -import { - getGlobalSectionsData, - type GlobalSectionsData, -} from 'src/components/cms/GlobalSections' import { getOfferUrl, useOffer } from 'src/sdk/offer' import PageProvider, { type PDPContext } from 'src/sdk/overrides/PageProvider' import { useProductQuery } from 'src/sdk/product/useProductQuery' -import { getPDP, type PDPContentType } from 'src/server/cms/pdp' + +import { + getStaticProps, + getStaticPaths, + type PDPProps, +} from 'src/experimental/pdpServerSideFunctions' type StoreConfig = typeof storeConfig & { experimental: { @@ -63,16 +55,6 @@ const COMPONENTS: Record> = { ...CUSTOM_COMPONENTS, } -type Props = PDPContentType & { - data: ServerProductQueryQuery - globalSections: GlobalSectionsData - meta: { - title: string - description: string - canonical: string - } -} - // Array merging strategy from deepmerge that makes client arrays overwrite server array // https://www.npmjs.com/package/deepmerge const overwriteMerge = (_: any[], sourceArray: any[]) => sourceArray @@ -80,7 +62,13 @@ const overwriteMerge = (_: any[], sourceArray: any[]) => sourceArray const isClientOfferEnabled = (storeConfig as StoreConfig).experimental .enableClientOffer -function Page({ data: server, sections, globalSections, offers, meta }: Props) { +function Page({ + data: server, + sections, + globalSections, + offers, + meta, +}: PDPProps) { const { product } = server const { currency } = useSession() const titleTemplate = storeConfig?.seo?.titleTemplate ?? '' @@ -184,136 +172,6 @@ function Page({ data: server, sections, globalSections, offers, meta }: Props) { ) } -const query = gql(` - query ServerProductQuery($locator: [IStoreSelectedFacet!]!) { - ...ServerProduct - product(locator: $locator) { - id: productID - - seo { - title - description - canonical - } - - brand { - name - } - - sku - gtin - name - description - releaseDate - - breadcrumbList { - itemListElement { - item - name - position - } - } - - image { - url - alternateName - } - - offers { - lowPrice - highPrice - lowPriceWithTaxes - priceCurrency - offers { - availability - price - priceValidUntil - priceCurrency - itemCondition - seller { - identifier - } - } - } - - isVariantOf { - productGroupID - } - - ...ProductDetailsFragment_product - } - } -`) - -export const getStaticProps: GetStaticProps< - Props, - { slug: string }, - Locator -> = async ({ params, previewData }) => { - const slug = params?.slug ?? '' - const [searchResult, globalSections] = await Promise.all([ - execute({ - variables: { locator: [{ key: 'slug', value: slug }] }, - operation: query, - }), - getGlobalSectionsData(previewData), - ]) - - const { data, errors = [] } = searchResult - - const notFound = errors.find(isNotFoundError) - - if (notFound) { - return { - notFound: true, - } - } - - if (errors.length > 0) { - throw errors[0] - } - - const cmsPage: PDPContentType = await getPDP(data.product, previewData) - - const { seo } = data.product - const title = seo.title || storeConfig.seo.title - const description = seo.description || storeConfig.seo.description - const canonical = `${storeConfig.storeUrl}${seo.canonical}` - - const meta = { title, description, canonical } - - let offer = {} - - if (data.product.offers.offers.length > 0) { - const { listPrice, ...offerData } = data.product.offers.offers[0] - - offer = offerData - } - - const offers = { - ...offer, - priceCurrency: data.product.offers.priceCurrency, - url: canonical, - } - - return { - props: { - data, - ...cmsPage, - meta, - offers, - globalSections, - key: seo.canonical, - }, - revalidate: (storeConfig as StoreConfig).experimental.revalidate ?? false, - } -} - -export const getStaticPaths: GetStaticPaths = async () => { - return { - paths: [], - fallback: 'blocking', - } -} +export { getStaticProps, getStaticPaths } export default Page diff --git a/packages/core/src/sdk/analytics/platform/vtex/search.ts b/packages/core/src/sdk/analytics/platform/vtex/search.ts index 7064098c46..096c45b16b 100644 --- a/packages/core/src/sdk/analytics/platform/vtex/search.ts +++ b/packages/core/src/sdk/analytics/platform/vtex/search.ts @@ -5,7 +5,7 @@ import type { AnalyticsEvent } from '@faststore/sdk' import type { SearchEvents } from '../../types' import config from '../../../../../discovery.config' -import { getCookie } from '../../../../utils/getCookie' +import { getCookie } from '../../../../utils/cookies' const THIRTY_MINUTES_S = 30 * 60 const ONE_YEAR_S = 365 * 24 * 3600 diff --git a/packages/core/src/utils/getCookie.ts b/packages/core/src/utils/cookies.ts similarity index 72% rename from packages/core/src/utils/getCookie.ts rename to packages/core/src/utils/cookies.ts index b6ce36a0b2..aabf657a22 100644 --- a/packages/core/src/utils/getCookie.ts +++ b/packages/core/src/utils/cookies.ts @@ -12,3 +12,7 @@ export function getCookie(name: string): string | undefined { return undefined // Cookie not found } + +export function setCookie(key: string, value: string, seconds: number) { + document.cookie = `${key}=${value}; max-age=${seconds}; path=/` +}