diff --git a/apps/scandic-web/app/api/web/sitemap/route.ts b/apps/scandic-web/app/api/web/sitemap/route.ts index 0397cf44a..8de850509 100644 --- a/apps/scandic-web/app/api/web/sitemap/route.ts +++ b/apps/scandic-web/app/api/web/sitemap/route.ts @@ -9,13 +9,18 @@ import { getEntries, getSyncToken, saveEntries, + saveHotelFilters, saveLastUpdatedDate, saveSitemapData, saveSyncToken, } from "@/utils/sitemap" import { contentstackSync } from "./sync" -import { mapEntriesToSitemapData, mergeEntries } from "./utils" +import { + mapEntriesToHotelFilters, + mapEntriesToSitemapData, + mergeEntries, +} from "./utils" export const dynamic = "force-dynamic" @@ -36,7 +41,6 @@ export async function GET(request: NextRequest) { const syncToken = await getSyncToken() const currentEntries = await getEntries() - const responseData = await contentstackSync(syncToken) const mergedEntries = mergeEntries(currentEntries, responseData.entries) @@ -49,7 +53,10 @@ export async function GET(request: NextRequest) { await saveEntries(mergedEntries) metricsEntriesSave.success() - const sitemapData = mapEntriesToSitemapData(mergedEntries) + const hotelFilters = mapEntriesToHotelFilters(mergedEntries) + await saveHotelFilters(hotelFilters) + + const sitemapData = mapEntriesToSitemapData(mergedEntries, hotelFilters) const lastUpdated = dt().utc().format() const saveDataCounter = createCounter("sitemap", "data.save") diff --git a/apps/scandic-web/app/api/web/sitemap/utils.ts b/apps/scandic-web/app/api/web/sitemap/utils.ts index a98d37cde..6a63fd3f9 100644 --- a/apps/scandic-web/app/api/web/sitemap/utils.ts +++ b/apps/scandic-web/app/api/web/sitemap/utils.ts @@ -4,7 +4,12 @@ import { removeTrailingSlash } from "@scandic-hotels/common/utils/url" import { env } from "@/env/server" -import type { SyncItem } from "@/types/sitemap" +import type { + HotelFilterEntries, + HotelFilterEntry, + SeoFilter, + SyncItem, +} from "@/types/sitemap" export function mergeEntries( currentEntries: SyncItem[], @@ -50,7 +55,44 @@ export function mergeEntries( return entries } -export function mapEntriesToSitemapData(entries: SyncItem[]) { +export function mapEntriesToHotelFilters(entries: SyncItem[]) { + const entriesTransformToHotelFiltersCounter = createCounter( + "sitemap", + "entries.transform.hotelFilters" + ) + const metricsEntriesTransformToHotelFilters = + entriesTransformToHotelFiltersCounter.init({ + entriesCount: entries.length, + }) + + metricsEntriesTransformToHotelFilters.start() + + const filteredEntries = entries + .filter( + (entry) => entry.content_type_uid === "hotel_filter" && entry.data.slug + ) + .map((entry) => ({ + uid: entry.data.uid, + locale: entry.data.locale, + slug: entry.data.slug, + })) + const entriesByUid = groupHotelFilterEntriesByUid( + filteredEntries as HotelFilterEntry[] + ) + + metricsEntriesTransformToHotelFilters.success({ + entriesCount: entries.length, + hotelFilterEntriesCount: filteredEntries.length, + uniqueHotelFilterUidsCount: Object.keys(entriesByUid).length, + }) + + return entriesByUid +} + +export function mapEntriesToSitemapData( + entries: SyncItem[], + hotelFilters: HotelFilterEntries +) { const entriesTransformCounter = createCounter("sitemap", "entries.transform") const metricsEntriesTransform = entriesTransformCounter.init({ entriesCount: entries.length, @@ -62,7 +104,7 @@ export function mapEntriesToSitemapData(entries: SyncItem[]) { const entriesByUid = groupEntriesByUid(filteredEntries) const sitemapEntries = Object.entries(entriesByUid) - .map(([_, entries]) => mapEntriesToSitemapEntries(entries)) + .map(([_, entries]) => mapEntriesToSitemapEntries(entries, hotelFilters)) .flat() metricsEntriesTransform.success({ @@ -94,9 +136,39 @@ function groupEntriesByUid(entries: SyncItem[]) { }, {}) } -function mapEntriesToSitemapEntries(entries: SyncItem[]) { - const alternates = mapToAlternates(entries) - return entries.map((currentEntry) => ({ +function groupHotelFilterEntriesByUid(entries: HotelFilterEntry[]) { + return entries.reduce((acc, entry) => { + const uid = entry.uid + if (!acc[uid]) { + acc[uid] = [] + } + acc[uid].push(entry) + return acc + }, {}) +} + +function isDestinationPageWithSeoFilters(entry: SyncItem) { + const destinationPageUids = [ + "destination_city_page", + "destination_country_page", + ] + + return ( + destinationPageUids.includes(entry.content_type_uid) && + !!entry.data.seo_filters?.length + ) +} + +function mapEntriesToSitemapEntries( + entries: SyncItem[], + hotelFilters: HotelFilterEntries +) { + const alternates = getAlternates(entries) + const seoFilterEntries = mapEntriesToSeoFilterSitemapEntries( + entries, + hotelFilters + ) + const pageEntries = entries.map((currentEntry) => ({ url: removeTrailingSlash( `${env.PUBLIC_URL}/${currentEntry.data.locale}${currentEntry.data.url}` ), @@ -106,9 +178,84 @@ function mapEntriesToSitemapEntries(entries: SyncItem[]) { priority: currentEntry.data.web?.seo_metadata?.sitemap?.priority ?? 0.5, alternates, })) + + return [...pageEntries, ...seoFilterEntries] } -function mapToAlternates(entries: SyncItem[]) { +function mapEntriesToSeoFilterSitemapEntries( + entries: SyncItem[], + hotelFilters: HotelFilterEntries +) { + const relevantEntries = entries.filter((entry) => + isDestinationPageWithSeoFilters(entry) + ) + + if (!relevantEntries.length) { + return [] + } + + return relevantEntries.flatMap((entry) => { + const seoFilters = entry.data.seo_filters?.filter( + (seoFilter) => !seoFilter.seo_metadata?.noindex + ) + + if (!seoFilters?.length) { + return [] + } + + return seoFilters + .map((seoFilter) => + getEntryBySeoFilter(entry, entries, seoFilter, hotelFilters) + ) + .filter((e): e is NonNullable => !!e) + }) +} + +function getEntryBySeoFilter( + currentEntry: SyncItem, + entries: SyncItem[], + seoFilter: SeoFilter, + hotelFilters: HotelFilterEntries +) { + const filterUid = seoFilter.filter?.[0]?.uid + if (!filterUid) { + return null + } + const locale = currentEntry.data.locale + const baseUrl = removeTrailingSlash( + `${env.PUBLIC_URL}/${locale}${currentEntry.data.url}` + ) + const defaultMetadata = currentEntry.data.web?.seo_metadata + const matchedHotelFilter = hotelFilters[filterUid] + const matchedHotelFilterForLocale = matchedHotelFilter?.find( + (f) => f.locale === locale + ) + if (!matchedHotelFilterForLocale) { + return null + } + + const alternates = getHotelFilterAlternates( + entries, + matchedHotelFilter, + filterUid + ) + + return { + url: removeTrailingSlash(`${baseUrl}/${matchedHotelFilterForLocale.slug}`), + lastModified: currentEntry.data.updated_at, + changeFrequency: + seoFilter.seo_metadata?.sitemap?.change_frequency ?? + defaultMetadata?.sitemap?.change_frequency ?? + "daily", + priority: + seoFilter.seo_metadata?.sitemap?.priority ?? + defaultMetadata?.sitemap?.priority ?? + 0.5, + alternates, + } +} + +function getAlternates(entries: SyncItem[]) { return entries.reduce>>((acc, entry) => { acc[entry.data.locale] = removeTrailingSlash( `${env.PUBLIC_URL}/${entry.data.locale}${entry.data.url}` @@ -116,3 +263,31 @@ function mapToAlternates(entries: SyncItem[]) { return acc }, {}) } + +function getHotelFilterAlternates( + entries: SyncItem[], + matchedHotelFilter: HotelFilterEntry[], + filterUid: string +) { + const matchingFilterInOtherLocales = entries.filter((entry) => + entry.data.seo_filters?.some( + (sf) => + sf.filter?.some((f) => f.uid === filterUid) && !sf.seo_metadata?.noindex + ) + ) + + return matchingFilterInOtherLocales.reduce>>( + (acc, entry) => { + const locale = entry.data.locale + const foundFilter = matchedHotelFilter?.find((f) => f.locale === locale) + if (!foundFilter) { + return acc + } + acc[locale] = removeTrailingSlash( + `${env.PUBLIC_URL}/${locale}${entry.data.url}/${foundFilter.slug}` + ) + return acc + }, + {} + ) +} diff --git a/apps/scandic-web/types/sitemap.ts b/apps/scandic-web/types/sitemap.ts index d032ac598..85e8449de 100644 --- a/apps/scandic-web/types/sitemap.ts +++ b/apps/scandic-web/types/sitemap.ts @@ -20,23 +20,43 @@ export interface SitemapEntry { export type SitemapData = SitemapEntry[] +export interface SeoMetadata { + noindex?: boolean | null + sitemap?: { + change_frequency: SitemapEntry["changeFrequency"] + priority: SitemapEntry["priority"] + } | null +} + +export interface SeoFilter { + filter?: { + uid?: string + }[] + seo_metadata?: SeoMetadata +} +export interface HotelFilterEntry { + uid: string + locale: Lang + slug: string +} + +export type HotelFilterEntries = Record + export interface SyncItemData { uid: string locale: Lang url?: string updated_at: string web?: { - seo_metadata?: { - noindex?: boolean | null - sitemap?: { - change_frequency: SitemapEntry["changeFrequency"] - priority: SitemapEntry["priority"] - } | null - } + seo_metadata?: SeoMetadata } + // SEO Filters on destination pages + slug?: string + seo_filters?: SeoFilter[] } export interface SyncItem { type: string + content_type_uid: string data: SyncItemData } export interface SyncResponse extends Omit { diff --git a/apps/scandic-web/utils/sitemap.ts b/apps/scandic-web/utils/sitemap.ts index 79f011af1..5b2ec08c0 100644 --- a/apps/scandic-web/utils/sitemap.ts +++ b/apps/scandic-web/utils/sitemap.ts @@ -2,11 +2,12 @@ import { getStore } from "@netlify/blobs" import { env } from "@/env/server" -import type { SitemapData, SyncItem } from "@/types/sitemap" +import type { HotelFilterEntry, SitemapData, SyncItem } from "@/types/sitemap" const branch = env.CMS_BRANCH const environment = env.CMS_ENVIRONMENT const entriesKey = `${environment}/${branch}/entries` +const hotelFiltersKey = `${environment}/${branch}/hotelFilters` const syncTokenKey = `${environment}/${branch}/syncToken` const sitemapDataKey = `${environment}/${branch}/sitemapData` const lastUpdatedKey = `${environment}/${branch}/lastUpdated` @@ -14,6 +15,13 @@ const MAX_ENTRIES_PER_SITEMAP = 50000 // We need to wrap `getStore` because calling it in the root of the file causes // it to be executed during build time. This is not supported by Netlify. +// To run this locally, you need to change the arguments to `getStore` like this: +// return getStore({ +// name: "sitemap", +// siteID: "SITE_ID from netlify", +// token: "Personal access token from Netlify", +// }) +// See https://docs.netlify.com/build/data-and-storage/netlify-blobs/#getstore for more info. function store() { return getStore("sitemap") } @@ -22,6 +30,12 @@ export async function saveEntries(entries: SyncItem[]) { await store().setJSON(entriesKey, entries) } +export async function saveHotelFilters( + hotelFilters: Record +) { + await store().setJSON(hotelFiltersKey, hotelFilters) +} + export async function saveSitemapData(sitemapData: SitemapData) { await store().setJSON(sitemapDataKey, sitemapData) }