feat(SW-2265): Added campaign-page

Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-06-10 06:35:43 +00:00
parent ead822fa62
commit ace5519869
25 changed files with 538 additions and 1 deletions

View File

@@ -0,0 +1,19 @@
import { Suspense } from "react"
import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import type { BreadcrumbsProps } from "@/components/TempDesignSystem/Breadcrumbs/breadcrumbs"
export default function CampaignPageBreadcrumbs() {
const variants: Pick<BreadcrumbsProps, "color" | "size"> = {
color: "Surface/Primary/OnSurface/Default",
size: "contentWidth",
}
return (
<Suspense fallback={<BreadcrumbsSkeleton {...variants} />}>
<Breadcrumbs {...variants} />
</Suspense>
)
}

View File

@@ -0,0 +1,21 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { env } from "@/env/server"
import CampaignPage from "@/components/ContentType/CampaignPage"
import CampaignPageSkeleton from "@/components/ContentType/CampaignPage/CampaignPageSkeleton"
export { generateMetadata } from "@/utils/generateMetadata"
export default async function CampaignPagePage() {
if (env.NEW_SITE_LIVE_STATUS === "NOT_LIVE") {
return notFound()
}
return (
<Suspense fallback={<CampaignPageSkeleton />}>
<CampaignPage />
</Suspense>
)
}

View File

@@ -0,0 +1,15 @@
"use client"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./campaignPage.module.css"
export default function CampaignPageSkeleton() {
return (
<div className={styles.pageContainer}>
<h1>
<SkeletonShimmer width="50%" height="2rem" />
</h1>
</div>
)
}

View File

@@ -0,0 +1,52 @@
.pageContainer {
display: grid;
gap: var(--Space-x6);
padding-bottom: var(--Space-x7);
width: var(--max-width-content);
margin: 0 auto;
}
.hero {
/* Temporary styles until we add the hero */
width: 100%;
height: 478px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--Surface-Brand-Accent-Default);
}
.intro {
display: grid;
gap: var(--Space-x5);
}
.headingWrapper {
display: grid;
gap: var(--Space-x2);
}
.heading {
color: var(--Text-Heading);
text-wrap: balance;
hyphens: auto;
}
.preamble {
display: grid;
gap: var(--Space-x2);
&:not(.twoColumns) {
max-width: var(--max-width-text-block);
}
}
@media screen and (min-width: 1367px) {
.pageContainer {
gap: var(--Space-x9);
}
.preamble.twoColumns {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -0,0 +1,60 @@
import { cx } from "class-variance-authority"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getCampaignPage } from "@/lib/trpc/memoizedRequests"
import CampaignPageSkeleton from "./CampaignPageSkeleton"
import styles from "./campaignPage.module.css"
export default async function CampaignPage() {
const pageData = await getCampaignPage()
if (!pageData) {
notFound()
}
const { campaignPage } = pageData
const { heading, subheading, preamble } = campaignPage
return (
<>
<Suspense fallback={<CampaignPageSkeleton />}>
<div className={styles.pageContainer}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<div className={styles.hero}>---PLACE FOR THE HERO---</div>
<div className={styles.intro}>
<div className={styles.headingWrapper}>
<Typography variant="Title/lg">
<h1 className={styles.heading}>{heading}</h1>
</Typography>
{subheading ? (
<Typography variant="Title/Subtitle/lg">
<p>{subheading}</p>
</Typography>
) : null}
</div>
<Typography variant="Body/Lead text">
<div
className={cx(styles.preamble, {
[styles.twoColumns]: preamble.is_two_columns,
})}
>
<p>{preamble.first_column}</p>
{preamble.is_two_columns && preamble.second_column ? (
<p className={styles.secondColumn}>
{preamble.second_column}
</p>
) : null}
</div>
</Typography>
</div>
</div>
</Suspense>
{/* <TrackingSDK pageData={tracking} /> */}
</>
)
}

View File

@@ -0,0 +1,29 @@
#import "../../Fragments/Breadcrumbs/Breadcrumbs.graphql"
#import "../../Fragments/System.graphql"
query GetCampaignPageBreadcrumbs($locale: String!, $uid: String!) {
campaign_page(locale: $locale, uid: $uid) {
url
web {
breadcrumbs {
...Breadcrumbs
}
}
system {
...System
}
}
}
query GetCampaignPageBreadcrumbsRefs($locale: String!, $uid: String!) {
campaign_page(locale: $locale, uid: $uid) {
web {
breadcrumbs {
...BreadcrumbsRefs
}
}
system {
...System
}
}
}

View File

@@ -0,0 +1,55 @@
#import "../../Fragments/System.graphql"
query GetCampaignPage($locale: String!, $uid: String!) {
campaign_page(uid: $uid, locale: $locale) {
title
campaign_identifier
heading
subheading
preamble {
is_two_columns
first_column
second_column
}
system {
...System
created_at
updated_at
}
}
trackingProps: campaign_page(locale: "en", uid: $uid) {
url
}
}
query GetCampaignPageRefs($locale: String!, $uid: String!) {
campaign_page(locale: $locale, uid: $uid) {
system {
...System
}
}
}
query GetDaDeEnUrlsCampaignPage($uid: String!) {
de: campaign_page(locale: "de", uid: $uid) {
url
}
en: campaign_page(locale: "en", uid: $uid) {
url
}
da: campaign_page(locale: "da", uid: $uid) {
url
}
}
query GetFiNoSvUrlsCampaignPage($uid: String!) {
fi: campaign_page(locale: "fi", uid: $uid) {
url
}
no: campaign_page(locale: "no", uid: $uid) {
url
}
sv: campaign_page(locale: "sv", uid: $uid) {
url
}
}

View File

@@ -0,0 +1,22 @@
#import "../../Fragments/Metadata.graphql"
#import "../../Fragments/System.graphql"
query GetCampaignPageMetadata($locale: String!, $uid: String!) {
campaign_page(locale: $locale, uid: $uid) {
web {
breadcrumbs {
title
}
seo_metadata {
...Metadata
}
}
heading
preamble {
first_column
}
system {
...System
}
}
}

View File

@@ -8,6 +8,14 @@ query GetAccountPageSettings($uid: String!, $locale: String!) {
}
}
query GetCampaignPageSettings($uid: String!, $locale: String!) {
page: campaign_page(uid: $uid, locale: $locale) {
settings: page_settings {
...PageSettings
}
}
}
query GetCollectionPageSettings($uid: String!, $locale: String!) {
page: collection_page(uid: $uid, locale: $locale) {
settings: page_settings {

View File

@@ -84,4 +84,12 @@ query EntryByUrlBatch2($locale: String!, $url: String!) {
}
total
}
all_campaign_page(where: { url: $url }, locale: $locale) {
items {
system {
...System
}
}
total
}
}

View File

@@ -416,3 +416,8 @@ export const getSelectedRoomsAvailabilityEnterDetails = cache(
return result
}
)
export const getCampaignPage = cache(async function getMemoizedCampaignPage() {
const caller = await serverClient()
return caller.contentstack.campaignPage.get()
})

View File

@@ -4,6 +4,10 @@ import {
GetMyPagesBreadcrumbs,
GetMyPagesBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/AccountPage.graphql"
import {
GetCampaignPageBreadcrumbs,
GetCampaignPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/CampaignPage.graphql"
import {
GetCollectionPageBreadcrumbs,
GetCollectionPageBreadcrumbsRefs,
@@ -151,6 +155,17 @@ export const breadcrumbsQueryRouter = router({
},
variables
)
case PageContentTypeEnum.campaignPage:
return await getBreadcrumbs<{
campaign_page: RawBreadcrumbsSchema
}>(
{
dataKey: "campaign_page",
refQuery: GetCampaignPageBreadcrumbsRefs,
query: GetCampaignPageBreadcrumbs,
},
variables
)
case PageContentTypeEnum.collectionPage:
return await getBreadcrumbs<{
collection_page: RawBreadcrumbsSchema

View File

@@ -0,0 +1,5 @@
import { mergeRouters } from "@/server/trpc"
import { campaignPageQueryRouter } from "./query"
export const campaignPageRouter = mergeRouters(campaignPageQueryRouter)

View File

@@ -0,0 +1,32 @@
import { z } from "zod"
import { systemSchema } from "../schemas/system"
export const campaignPageSchema = z.object({
campaign_page: z.object({
title: z.string(),
campaign_identifier: z.string().nullish(),
heading: z.string(),
subheading: z.string().nullish(),
preamble: z.object({
is_two_columns: z.boolean().default(false),
first_column: z.string(),
second_column: z.string(),
}),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
}),
trackingProps: z.object({
url: z.string(),
}),
})
export const campaignPageRefsSchema = z.object({
campaign_page: z.object({
system: systemSchema,
}),
})

View File

@@ -0,0 +1,125 @@
import {
GetCampaignPage,
GetCampaignPageRefs,
} from "@/lib/graphql/Query/CampaignPage/CampaignPage.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { createCounter } from "@/server/telemetry"
import { contentStackUidWithServiceProcedure, router } from "@/server/trpc"
import { generateRefsResponseTag } from "@/utils/generateTag"
import { campaignPageRefsSchema, campaignPageSchema } from "./output"
import { generatePageTags } from "./utils"
import {
TrackingChannelEnum,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type {
GetCampaignPageData,
GetCampaignPageRefsData,
} from "@/types/trpc/routers/contentstack/campaignPage"
export const campaignPageQueryRouter = router({
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
const { lang, uid } = ctx
const getCampaignPageRefsCounter = createCounter(
"trpc.contentstack",
"campaignPage.get.refs"
)
const metricsGetCampaignPageRefs = getCampaignPageRefsCounter.init({
lang,
uid,
})
metricsGetCampaignPageRefs.start()
const refsResponse = await request<GetCampaignPageRefsData>(
GetCampaignPageRefs,
{ locale: lang, uid },
{
key: generateRefsResponseTag(lang, uid),
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
metricsGetCampaignPageRefs.noDataError()
throw notFoundError
}
const validatedRefsData = campaignPageRefsSchema.safeParse(
refsResponse.data
)
if (!validatedRefsData.success) {
metricsGetCampaignPageRefs.validationError(validatedRefsData.error)
return null
}
metricsGetCampaignPageRefs.success()
const tags = generatePageTags(validatedRefsData.data, lang)
const getCampaignPageCounter = createCounter(
"trpc.contentstack",
"campaignPage.get"
)
const metricsGetCampaignPage = getCampaignPageCounter.init({
lang,
uid,
})
metricsGetCampaignPage.start()
const response = await request<GetCampaignPageData>(
GetCampaignPage,
{
locale: lang,
uid,
},
{
key: tags,
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetCampaignPage.noDataError()
throw notFoundError
}
const validatedResponse = campaignPageSchema.safeParse(response.data)
if (!validatedResponse.success) {
metricsGetCampaignPage.validationError(validatedResponse.error)
return null
}
const campaignPage = validatedResponse.data.campaign_page
metricsGetCampaignPage.success()
const system = campaignPage.system
const pageName = `campaign-page`
const tracking: TrackingSDKPageData = {
pageId: system.uid,
domainLanguage: system.locale,
publishDate: system.updated_at,
createDate: system.created_at,
channel: TrackingChannelEnum["campaign-page"],
pageType: "campaign-page",
pageName,
siteSections: pageName,
siteVersion: "new-web",
}
return {
campaignPage,
tracking,
}
}),
})

View File

@@ -0,0 +1,11 @@
import { generateTag } from "@/utils/generateTag"
import type { CampaignPageRefs } from "@/types/trpc/routers/contentstack/campaignPage"
import type { Lang } from "@/constants/languages"
export function generatePageTags(
validatedData: CampaignPageRefs,
lang: Lang
): string[] {
return [generateTag(lang, validatedData.campaign_page.system.uid)].flat()
}

View File

@@ -3,6 +3,7 @@ import { router } from "@/server/trpc"
import { accountPageRouter } from "./accountPage"
import { baseRouter } from "./base"
import { breadcrumbsRouter } from "./breadcrumbs"
import { campaignPageRouter } from "./campaignPage"
import { collectionPageRouter } from "./collectionPage"
import { contentPageRouter } from "./contentPage"
import { destinationCityPageRouter } from "./destinationCityPage"
@@ -25,6 +26,7 @@ export const contentstackRouter = router({
hotelPage: hotelPageRouter,
languageSwitcher: languageSwitcherRouter,
loyaltyPage: loyaltyPageRouter,
campaignPage: campaignPageRouter,
collectionPage: collectionPageRouter,
contentPage: contentPageRouter,
destinationOverviewPage: destinationOverviewPageRouter,

View File

@@ -4,6 +4,10 @@ import {
GetDaDeEnUrlsAccountPage,
GetFiNoSvUrlsAccountPage,
} from "@/lib/graphql/Query/AccountPage/AccountPage.graphql"
import {
GetDaDeEnUrlsCampaignPage,
GetFiNoSvUrlsCampaignPage,
} from "@/lib/graphql/Query/CampaignPage/CampaignPage.graphql"
import {
GetDaDeEnUrlsCollectionPage,
GetFiNoSvUrlsCollectionPage,
@@ -95,6 +99,10 @@ export async function getUrlsOfAllLanguages(
daDeEnDocument = GetDaDeEnUrlsCurrentBlocksPage
fiNoSvDocument = GetFiNoSvUrlsCurrentBlocksPage
break
case PageContentTypeEnum.campaignPage:
daDeEnDocument = GetDaDeEnUrlsCampaignPage
fiNoSvDocument = GetFiNoSvUrlsCampaignPage
break
case PageContentTypeEnum.loyaltyPage:
daDeEnDocument = GetDaDeEnUrlsLoyaltyPage
fiNoSvDocument = GetFiNoSvUrlsLoyaltyPage

View File

@@ -85,7 +85,21 @@ export const rawMetadataSchema = z.object({
})
.nullish(),
heading: z.string().nullish(),
preamble: z.string().nullish(),
preamble: z
.union([
z.string(),
z.object({
first_column: z.string(),
}),
])
.transform((preamble) => {
if (typeof preamble === "string") {
return preamble
}
return preamble?.first_column || null
})
.nullish(),
header: z
.object({
heading: z.string().nullish(),

View File

@@ -2,6 +2,7 @@ import { cache } from "react"
import { env } from "@/env/server"
import { GetAccountPageMetadata } from "@/lib/graphql/Query/AccountPage/Metadata.graphql"
import { GetCampaignPageMetadata } from "@/lib/graphql/Query/CampaignPage/Metadata.graphql"
import { GetCollectionPageMetadata } from "@/lib/graphql/Query/CollectionPage/Metadata.graphql"
import { GetContentPageMetadata } from "@/lib/graphql/Query/ContentPage/Metadata.graphql"
import { GetDestinationCityPageMetadata } from "@/lib/graphql/Query/DestinationCityPage/Metadata.graphql"
@@ -128,6 +129,14 @@ export const metadataQueryRouter = router({
accountPageResponse.account_page
)
break
case PageContentTypeEnum.campaignPage:
const campaignPageResponse = await fetchMetadata<{
campaign_page: RawMetadataSchema
}>(GetCampaignPageMetadata, variables)
metadata = await getTransformedMetadata(
campaignPageResponse.campaign_page
)
break
case PageContentTypeEnum.collectionPage:
const collectionPageResponse = await fetchMetadata<{
collection_page: RawMetadataSchema

View File

@@ -2,6 +2,7 @@ import * as Sentry from "@sentry/nextjs"
import {
GetAccountPageSettings,
GetCampaignPageSettings,
GetCollectionPageSettings,
GetContentPageSettings,
GetCurrentBlocksPageSettings,
@@ -74,6 +75,7 @@ export const pageSettingsQueryRouter = router({
const graphqlQueriesForContentType: Record<PageContentTypeEnum, any> = {
[PageContentTypeEnum.accountPage]: GetAccountPageSettings,
[PageContentTypeEnum.campaignPage]: GetCampaignPageSettings,
[PageContentTypeEnum.collectionPage]: GetCollectionPageSettings,
[PageContentTypeEnum.contentPage]: GetContentPageSettings,
[PageContentTypeEnum.currentBlocksPage]: GetCurrentBlocksPageSettings,

View File

@@ -7,6 +7,7 @@ export enum TrackingChannelEnum {
"static-content-page" = "static-content-page",
"hotelreservation" = "hotelreservation",
"collection-page" = "collection-page",
"campaign-page" = "campaign-page",
"hotels" = "hotels",
"homepage" = "homepage",
}

View File

@@ -1,5 +1,6 @@
export enum PageContentTypeEnum {
accountPage = "account_page",
campaignPage = "campaign_page",
collectionPage = "collection_page",
contentPage = "content_page",
currentBlocksPage = "current_blocks_page",

View File

@@ -14,6 +14,7 @@ const entryResolveSchema = z.object({
export const validateEntryResolveSchema = z.object({
all_account_page: entryResolveSchema,
all_campaign_page: entryResolveSchema,
all_collection_page: entryResolveSchema,
all_content_page: entryResolveSchema,
all_loyalty_page: entryResolveSchema,

View File

@@ -0,0 +1,17 @@
import type { z } from "zod"
import type {
campaignPageRefsSchema,
campaignPageSchema,
} from "@/server/routers/contentstack/campaignPage/output"
export interface GetCampaignPageData
extends z.input<typeof campaignPageSchema> {}
export interface CampaignPage extends z.output<typeof campaignPageSchema> {}
export type CampaignPageData = CampaignPage["campaign_page"]
export interface GetCampaignPageRefsData
extends z.input<typeof campaignPageRefsSchema> {}
export interface CampaignPageRefs
extends z.output<typeof campaignPageRefsSchema> {}