Merged in feat/sw-2863-move-contentstack-router-to-trpc-package (pull request #2389)

feat(SW-2863): Move contentstack router to trpc package

* Add exports to packages and lint rule to prevent relative imports

* Add env to trpc package

* Add eslint to trpc package

* Apply lint rules

* Use direct imports from trpc package

* Add lint-staged config to trpc

* Move lang enum to common

* Restructure trpc package folder structure

* WIP first step

* update internal imports in trpc

* Fix most errors in scandic-web

Just 100 left...

* Move Props type out of trpc

* Fix CategorizedFilters types

* Move more schemas in hotel router

* Fix deps

* fix getNonContentstackUrls

* Fix import error

* Fix entry error handling

* Fix generateMetadata metrics

* Fix alertType enum

* Fix duplicated types

* lint:fix

* Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package

* Fix broken imports

* Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-06-26 07:53:01 +00:00
parent 0263ab8c87
commit 002d093af4
921 changed files with 3112 additions and 3008 deletions

View File

@@ -1,9 +1,9 @@
/** Routers */
import { router } from "@scandic-hotels/trpc"
import { contentstackRouter } from "@scandic-hotels/trpc/routers/contentstack"
import { autocompleteRouter } from "./routers/autocomplete"
import { bookingRouter } from "./routers/booking"
import { contentstackRouter } from "./routers/contentstack"
import { hotelsRouter } from "./routers/hotels"
import { navigationRouter } from "./routers/navigation"
import { partnerRouter } from "./routers/partners"

View File

@@ -4,17 +4,21 @@ import { Lang } from "@scandic-hotels/common/constants/language"
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
import { safeProtectedServiceProcedure } from "@scandic-hotels/trpc/procedures"
import { getCityPageUrls } from "@scandic-hotels/trpc/routers/contentstack/destinationCityPage/utils"
import { getCountryPageUrls } from "@scandic-hotels/trpc/routers/contentstack/destinationCountryPage/utils"
import { getHotelPageUrls } from "@scandic-hotels/trpc/routers/contentstack/hotelPage/utils"
import {
getCitiesByCountry,
getCountries,
getLocations,
} from "@scandic-hotels/trpc/routers/hotels/utils"
import { ApiCountry, type Country } from "@scandic-hotels/trpc/types/country"
import { isDefined } from "@/server/utils"
import { getCityPageUrls } from "../contentstack/destinationCityPage/utils"
import { getCountryPageUrls } from "../contentstack/destinationCountryPage/utils"
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
import { getCitiesByCountry, getCountries, getLocations } from "../hotels/utils"
import { filterAndCategorizeAutoComplete } from "./util/filterAndCategorizeAutoComplete"
import { mapLocationToAutoCompleteLocation } from "./util/mapLocationToAutoCompleteLocation"
import { ApiCountry, type Country } from "@/types/enums/country"
import type { AutoCompleteLocation } from "./schema"
const destinationsAutoCompleteInputSchema = z.object({

View File

@@ -2,8 +2,9 @@ import { describe, expect, it } from "@jest/globals"
import { getSearchTokens } from "./getSearchTokens"
import type { Location } from "@scandic-hotels/trpc/types/locations"
import type { DeepPartial } from "@/types/DeepPartial"
import type { Location } from "@/types/trpc/routers/hotel/locations"
describe("getSearchTokens", () => {
it("should return lowercased tokens for a hotel location", () => {

View File

@@ -1,6 +1,6 @@
import { normalizeAumlauts } from "./normalizeAumlauts"
import type { Location } from "@/types/trpc/routers/hotel/locations"
import type { Location } from "@scandic-hotels/trpc/types/locations"
export function getSearchTokens(location: Location) {
const tokens = [

View File

@@ -1,6 +1,7 @@
import { getSearchTokens } from "./getSearchTokens"
import type { Location } from "@/types/trpc/routers/hotel/locations"
import type { Location } from "@scandic-hotels/trpc/types/locations"
import type { AutoCompleteLocation } from "../schema"
export function mapLocationToAutoCompleteLocation(

View File

@@ -1,8 +1,8 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { ChildBedTypeEnum } from "@scandic-hotels/trpc/enums/childBedTypeEnum"
import { ChildBedTypeEnum } from "@/constants/booking"
import { langToApiLang } from "@/constants/languages"
const roomsSchema = z

View File

@@ -1,8 +1,8 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import * as api from "@scandic-hotels/trpc/api"
import { safeProtectedServiceProcedure } from "@scandic-hotels/trpc/procedures"
import * as api from "@/lib/api"
import { createRefIdPlugin } from "@/server/plugins/refIdToConfirmationNumber"
import { getMembershipNumber } from "@/server/routers/user/utils"

View File

@@ -1,17 +1,18 @@
import { z } from "zod"
import { BookingStatusEnum, ChildBedTypeEnum } from "@/constants/booking"
import { dt } from "@/lib/dt"
import { calculateRefId } from "@/utils/refId"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { dt } from "@scandic-hotels/common/dt"
import { nullableArrayObjectValidator } from "@scandic-hotels/common/utils/zod/arrayValidator"
import {
nullableStringEmailValidator,
nullableStringValidator,
} from "@/utils/zod/stringValidator"
} from "@scandic-hotels/common/utils/zod/stringValidator"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import { ChildBedTypeEnum } from "@scandic-hotels/trpc/enums/childBedTypeEnum"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { CurrencyEnum } from "@/types/enums/currency"
import { BookingStatusEnum } from "@/constants/booking"
import { calculateRefId } from "@/utils/refId"
const guestSchema = z.object({
email: nullableStringEmailValidator,

View File

@@ -1,5 +1,6 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import * as api from "@scandic-hotels/trpc/api"
import {
badRequestError,
serverErrorByStatus,
@@ -8,15 +9,14 @@ import {
safeProtectedServiceProcedure,
serviceProcedure,
} from "@scandic-hotels/trpc/procedures"
import { getHotel } from "@scandic-hotels/trpc/routers/hotels/utils"
import { toApiLang } from "@scandic-hotels/trpc/utils"
import * as api from "@/lib/api"
import { createRefIdPlugin } from "@/server/plugins/refIdToConfirmationNumber"
import { toApiLang } from "@/server/utils"
import { getBookedHotelRoom } from "@/utils/booking"
import { encrypt } from "../../../utils/encryption"
import { getHotel } from "../hotels/utils"
import {
createRefIdInput,
findBookingInput,

View File

@@ -1,11 +1,10 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "@scandic-hotels/trpc/api"
import {
badRequestError,
serverErrorByStatus,
} from "@scandic-hotels/trpc/errors"
import * as api from "@/lib/api"
import { toApiLang } from "@/server/utils"
import { toApiLang } from "@scandic-hotels/trpc/utils"
import { bookingConfirmationSchema, createBookingSchema } from "./output"

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { accountPageQueryRouter } from "./query"
export const accountPageRouter = mergeRouters(accountPageQueryRouter)

View File

@@ -1,92 +0,0 @@
import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import {
dynamicContentRefsSchema,
dynamicContentSchema,
} from "../schemas/blocks/dynamicContent"
import {
shortcutsRefsSchema,
shortcutsSchema,
} from "../schemas/blocks/shortcuts"
import { textContentSchema } from "../schemas/blocks/textContent"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import { systemSchema } from "../schemas/system"
import { AccountPageEnum } from "@/types/enums/accountPage"
const accountPageDynamicContent = z
.object({
__typename: z.literal(AccountPageEnum.ContentStack.blocks.DynamicContent),
})
.merge(dynamicContentSchema)
const accountPageShortcuts = z
.object({
__typename: z.literal(AccountPageEnum.ContentStack.blocks.ShortCuts),
})
.merge(shortcutsSchema)
const accountPageTextContent = z
.object({
__typename: z.literal(AccountPageEnum.ContentStack.blocks.TextContent),
})
.merge(textContentSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [
accountPageDynamicContent,
accountPageShortcuts,
accountPageTextContent,
])
export const accountPageSchema = z.object({
account_page: z.object({
content: discriminatedUnionArray(blocksSchema.options),
heading: z.string().nullable(),
preamble: z.string().nullable(),
hero_image: tempImageVaultAssetSchema,
hero_image_active: z
.boolean()
.nullable()
.transform((val) => val ?? false),
title: z.string(),
url: z.string(),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
}),
trackingProps: z.object({
url: z.string(),
}),
})
const accountPageDynamicContentRefs = z
.object({
__typename: z.literal(AccountPageEnum.ContentStack.blocks.DynamicContent),
})
.merge(dynamicContentRefsSchema)
const accountPageShortcutsRefs = z
.object({
__typename: z.literal(AccountPageEnum.ContentStack.blocks.ShortCuts),
})
.merge(shortcutsRefsSchema)
const accountPageContentItemRefs = z.discriminatedUnion("__typename", [
z.object({
__typename: z.literal(AccountPageEnum.ContentStack.blocks.TextContent),
}),
accountPageDynamicContentRefs,
accountPageShortcutsRefs,
])
export const accountPageRefsSchema = z.object({
account_page: z.object({
content: discriminatedUnionArray(accountPageContentItemRefs.options),
system: systemSchema,
}),
})

View File

@@ -1,131 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentstackExtendedProcedureUID } from "@scandic-hotels/trpc/procedures"
import {
GetAccountPage,
GetAccountPageRefs,
} from "@/lib/graphql/Query/AccountPage/AccountPage.graphql"
import { request } from "@/lib/graphql/request"
import {
generateRefsResponseTag,
generateTag,
generateTagsFromSystem,
} from "@/utils/generateTag"
import { accountPageRefsSchema, accountPageSchema } from "./output"
import { getConnections } from "./utils"
import {
TrackingChannelEnum,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type {
GetAccountPageRefsSchema,
GetAccountPageSchema,
} from "@/types/trpc/routers/contentstack/accountPage"
export const accountPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const { lang, uid } = ctx
const getAccountPageRefsCounter = createCounter(
"trpc.contentstack",
"accountPage.get.refs"
)
const metricsRefs = getAccountPageRefsCounter.init({ lang, uid })
metricsRefs.start()
const refsResponse = await request<GetAccountPageRefsSchema>(
GetAccountPageRefs,
{
locale: lang,
uid,
},
{
key: generateRefsResponseTag(lang, uid),
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
metricsRefs.noDataError()
throw notFoundError
}
const validatedAccountPageRefs = accountPageRefsSchema.safeParse(
refsResponse.data
)
if (!validatedAccountPageRefs.success) {
metricsRefs.validationError(validatedAccountPageRefs.error)
return null
}
const connections = getConnections(validatedAccountPageRefs.data)
const tags = [
generateTagsFromSystem(lang, connections),
generateTag(lang, validatedAccountPageRefs.data.account_page.system.uid),
].flat()
metricsRefs.success()
const getAccountPageCounter = createCounter(
"trpc.contentstack",
"accountPage.get"
)
const metrics = getAccountPageCounter.init({ lang, uid })
metrics.start()
const response = await request<GetAccountPageSchema>(
GetAccountPage,
{
locale: lang,
uid,
},
{
key: tags,
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metrics.noDataError()
throw notFoundError
}
const validatedAccountPage = accountPageSchema.safeParse(response.data)
if (!validatedAccountPage.success) {
metrics.validationError(validatedAccountPage.error)
return null
}
metrics.success()
const parsedtitle = response.data.account_page.title
.replaceAll(" ", "")
.toLowerCase()
const tracking: TrackingSDKPageData = {
pageId: validatedAccountPage.data.account_page.system.uid,
domainLanguage: lang,
publishDate: validatedAccountPage.data.account_page.system.updated_at,
createDate: validatedAccountPage.data.account_page.system.created_at,
channel: TrackingChannelEnum["scandic-friends"],
pageType: `member${parsedtitle}page`,
pageName: validatedAccountPage.data.trackingProps.url,
siteSections: validatedAccountPage.data.trackingProps.url,
siteVersion: "new-web",
}
return {
accountPage: validatedAccountPage.data.account_page,
tracking,
}
}),
})

View File

@@ -1,28 +0,0 @@
import { AccountPageEnum } from "@/types/enums/accountPage"
import type { System } from "@/types/requests/system"
import type { AccountPageRefs } from "@/types/trpc/routers/contentstack/accountPage"
export function getConnections({ account_page }: AccountPageRefs) {
const connections: System["system"][] = [account_page.system]
if (account_page.content) {
account_page.content.forEach((block) => {
switch (block.__typename) {
case AccountPageEnum.ContentStack.blocks.ShortCuts: {
if (block.shortcuts.shortcuts.length) {
connections.push(...block.shortcuts.shortcuts)
}
break
}
case AccountPageEnum.ContentStack.blocks.DynamicContent: {
if (block.dynamic_content.link) {
connections.push(block.dynamic_content.link)
}
break
}
}
})
}
return connections
}

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { baseQueryRouter } from "./query"
export const baseRouter = mergeRouters(baseQueryRouter)

View File

@@ -1,857 +0,0 @@
import { z, ZodError, ZodIssueCode } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { discriminatedUnion } from "@/lib/discriminatedUnion"
import {
cardBlockRefsSchema,
cardBlockSchema,
transformCardBlock,
transformCardBlockRefs,
} from "@/server/routers/contentstack/schemas/blocks/cardsGrid"
import {
linkRefsUnionSchema,
linkUnionSchema,
transformPageLink,
transformPageLinkRef,
} from "@/server/routers/contentstack/schemas/pageLinks"
import { IconName } from "@/components/Icons/iconName"
import { removeMultipleSlashes } from "@/utils/url"
import { systemSchema } from "../schemas/system"
import { AlertTypeEnum } from "@/types/enums/alert"
import type { Image } from "@/types/image"
// Help me write this zod schema based on the type ContactConfig
export const validateContactConfigSchema = z.object({
all_contact_config: z.object({
items: z.array(
z.object({
email: z.object({
name: z.string().nullable(),
address: z.string().nullable(),
}),
email_loyalty: z.object({
name: z.string().nullable(),
address: z.string().nullable(),
}),
mailing_address: z.object({
zip: z.string().nullable(),
street: z.string().nullable(),
name: z.string().nullable(),
city: z.string().nullable(),
country: z.string().nullable(),
}),
phone: z.object({
number: z.string().nullable(),
name: z.string().nullable(),
footnote: z.string().nullable(),
}),
phone_loyalty: z.object({
number: z.string().nullable(),
name: z.string().nullable(),
footnote: z.string().nullable(),
}),
visiting_address: z.object({
zip: z.string().nullable(),
country: z.string().nullable(),
city: z.string().nullable(),
street: z.string().nullable(),
}),
})
),
}),
})
export enum ContactFieldGroupsEnum {
email = "email",
email_loyalty = "email_loyalty",
mailing_address = "mailing_address",
phone = "phone",
phone_loyalty = "phone_loyalty",
visiting_address = "visiting_address",
}
export type ContactFieldGroups = keyof typeof ContactFieldGroupsEnum
export type ContactConfigData = z.infer<typeof validateContactConfigSchema>
export type ContactConfig = ContactConfigData["all_contact_config"]["items"][0]
export type ContactFields = {
display_text: string | null
contact_field: string
footnote?: string | null
}
export const validateCurrentHeaderConfigSchema = z
.object({
all_current_header: z.object({
items: z.array(
z.object({
frontpage_link_text: z.string(),
logoConnection: z.object({
edges: z.array(
z.object({
node: z.object({
description: z.string().optional().nullable(),
dimension: z.object({
height: z.number(),
width: z.number(),
}),
metadata: z.any().nullable(),
system: z.object({
uid: z.string(),
}),
title: z.string().optional().default(""),
url: z.string().optional().default(""),
}),
})
),
}),
menu: z.object({
links: z.array(
z.object({
href: z.string(),
title: z.string(),
})
),
}),
top_menu: z.object({
links: z.array(
z.object({
link: z.object({
href: z.string(),
title: z.string(),
}),
show_on_mobile: z.boolean(),
sort_order_mobile: z.number(),
})
),
}),
})
),
}),
})
.transform((data) => {
if (!data.all_current_header.items.length) {
return {
header: null,
}
}
const header = data.all_current_header.items[0]
return {
header: {
frontpageLinkText: header.frontpage_link_text,
logo: header.logoConnection.edges[0].node,
menu: header.menu,
topMenu: header.top_menu,
},
}
})
export interface GetCurrentHeaderData
extends z.input<typeof validateCurrentHeaderConfigSchema> {}
export type HeaderData = z.output<typeof validateCurrentHeaderConfigSchema>
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const validateCurrentHeaderRefConfigSchema = z.object({
all_current_header: z.object({
items: z.array(
z.object({
system: systemSchema,
})
),
}),
})
export type CurrentHeaderRefDataRaw = z.infer<
typeof validateCurrentHeaderRefConfigSchema
>
const validateAppDownload = z.object({
href: z.string(),
imageConnection: z.object({
edges: z.array(
z.object({
node: z.object({
description: z.string().optional().nullable(),
dimension: z.object({
height: z.number(),
width: z.number(),
}),
metadata: z.any().nullable(),
system: z.object({
uid: z.string(),
}),
title: z.string(),
url: z.string(),
}),
})
),
}),
})
const validateNavigationItem = z.object({
links: z.array(z.object({ href: z.string(), title: z.string() })),
title: z.string(),
})
export type NavigationItem = z.infer<typeof validateNavigationItem>
export const validateCurrentFooterConfigSchema = z.object({
all_current_footer: z.object({
items: z.array(
z.object({
title: z.string(),
about: z.object({
title: z.string(),
text: z.string(),
}),
app_downloads: z.object({
title: z.string(),
app_store: validateAppDownload,
google_play: validateAppDownload,
}),
logoConnection: z.object({
edges: z.array(
z.object({
node: z.object({
description: z.string().optional().nullable(),
dimension: z.object({
height: z.number(),
width: z.number(),
}),
metadata: z.any().nullable(),
system: z.object({
uid: z.string(),
}),
title: z.string(),
url: z.string(),
}),
})
),
}),
navigation: z.array(validateNavigationItem),
social_media: z.object({
title: z.string(),
facebook: z.object({ href: z.string(), title: z.string() }),
instagram: z.object({ href: z.string(), title: z.string() }),
twitter: z.object({ href: z.string(), title: z.string() }),
}),
trip_advisor: z.object({
title: z.string(),
logoConnection: z.object({
edges: z.array(
z.object({
node: z.object({
description: z.string().optional().nullable(),
dimension: z.object({
height: z.number(),
width: z.number(),
}),
metadata: z.any().nullable(),
system: z.object({
uid: z.string(),
}),
title: z.string(),
url: z.string(),
}),
})
),
}),
}),
})
),
}),
})
export type CurrentFooterDataRaw = z.infer<
typeof validateCurrentFooterConfigSchema
>
export type CurrentFooterData = Omit<
CurrentFooterDataRaw["all_current_footer"]["items"][0],
"logoConnection"
> & {
logo: Image
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const validateCurrentFooterRefConfigSchema = z.object({
all_current_footer: z.object({
items: z.array(
z.object({
system: systemSchema,
})
),
}),
})
export type CurrentFooterRefDataRaw = z.infer<
typeof validateCurrentFooterRefConfigSchema
>
const validateExternalLink = z
.object({
href: z.string(),
title: z.string(),
})
.optional()
const validateInternalLink = z
.object({
edges: z
.array(
z.object({
node: z.object({
system: z.object({
uid: z.string(),
locale: z.nativeEnum(Lang),
}),
url: z.string(),
title: z.string(),
web: z
.object({
original_url: z.string(),
})
.optional(),
}),
})
)
.max(1),
})
.transform((data) => {
const node = data.edges[0]?.node
if (!node) {
return null
}
const url = node.url
const originalUrl = node.web?.original_url
const lang = node.system.locale
return {
url: originalUrl || removeMultipleSlashes(`/${lang}/${url}`),
title: node.title,
}
})
.optional()
export const validateLinkItem = z
.object({
title: z.string(),
open_in_new_tab: z.boolean(),
link: validateExternalLink,
pageConnection: validateInternalLink,
})
.transform((data) => {
return {
url: data.pageConnection?.url ?? data.link?.href ?? "",
title: data?.title ?? data.link?.title,
openInNewTab: data.open_in_new_tab,
isExternal: !data.pageConnection?.url,
}
})
const validateLinks = z
.array(validateLinkItem)
.transform((data) => data.filter((item) => item.url))
export const validateSecondaryLinks = z.array(
z.object({
title: z.string(),
links: validateLinks,
})
)
export const validateLinksWithType = z.array(
z.object({
type: z.string(),
href: validateExternalLink,
})
)
export const validateFooterConfigSchema = z
.object({
all_footer: z.object({
items: z.array(
z.object({
main_links: validateLinks.nullish().transform((val) => val ?? []),
app_downloads: z.object({
title: z.string(),
links: validateLinksWithType
.nullish()
.transform((val) => val ?? []),
}),
secondary_links: validateSecondaryLinks
.nullish()
.transform((val) => val ?? []),
social_media: z.object({
links: validateLinksWithType
.nullish()
.transform((val) => val ?? []),
}),
tertiary_links: validateLinks.nullish().transform((val) => val ?? []),
})
),
}),
})
.transform((data) => {
const {
main_links,
app_downloads,
secondary_links,
social_media,
tertiary_links,
} = data.all_footer.items[0]
return {
mainLinks: main_links,
appDownloads: app_downloads,
secondaryLinks: secondary_links,
socialMedia: social_media,
tertiaryLinks: tertiary_links,
}
})
const pageConnectionRefs = z.object({
edges: z
.array(
z.object({
node: z.object({
system: systemSchema,
}),
})
)
.max(1),
})
export const validateFooterRefConfigSchema = z.object({
all_footer: z.object({
items: z
.array(
z.object({
main_links: z
.array(
z.object({
pageConnection: pageConnectionRefs,
})
)
.nullable(),
secondary_links: z
.array(
z.object({
links: z.array(
z.object({
pageConnection: pageConnectionRefs,
})
),
})
)
.nullable(),
tertiary_links: z
.array(
z.object({
pageConnection: pageConnectionRefs,
})
)
.nullable(),
system: systemSchema,
})
)
.length(1),
}),
})
/**
* New Header Validation
*/
const linkRefsSchema = z
.object({
linkConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
})
.transform((data) => {
if (data.linkConnection.edges.length) {
const link = transformPageLinkRef(data.linkConnection.edges[0].node)
if (link) {
return {
link,
}
}
}
return { link: null }
})
const menuItemsRefsSchema = z.intersection(
linkRefsSchema,
z
.object({
cardConnection: z.object({
edges: z.array(
z.object({
node: cardBlockRefsSchema,
})
),
}),
see_all_link: linkRefsSchema,
submenu: z.array(
z.object({
links: z.array(linkRefsSchema),
})
),
})
.transform((data) => {
let card = null
if (data.cardConnection.edges.length) {
card = transformCardBlockRefs(data.cardConnection.edges[0].node)
}
return {
card,
see_all_link: data.see_all_link,
submenu: data.submenu,
}
})
)
const topLinkRefsSchema = z.object({
logged_in: linkRefsSchema.nullable(),
logged_out: linkRefsSchema.nullable(),
})
export const headerRefsSchema = z
.object({
all_header: z.object({
items: z
.array(
z.object({
menu_items: z.array(menuItemsRefsSchema),
system: systemSchema,
top_link: topLinkRefsSchema,
})
)
.max(1),
}),
})
.transform((data) => {
if (!data.all_header.items.length) {
console.info(`Zod Error - No header returned in refs request`)
throw new ZodError([
{
code: ZodIssueCode.custom,
fatal: true,
message: "No header returned (Refs)",
path: ["all_header.items"],
},
])
}
return {
header: data.all_header.items[0],
}
})
const linkSchema = z
.object({
linkConnection: z.object({
edges: z.array(
z.object({
node: discriminatedUnion(linkUnionSchema.options),
})
),
}),
})
.transform((data) => {
if (data.linkConnection.edges.length) {
const linkNode = data.linkConnection.edges[0].node
if (linkNode) {
const link = transformPageLink(linkNode)
if (link) {
return {
link,
}
}
}
}
return {
link: null,
}
})
const titleSchema = z.object({
title: z.string().optional().default(""),
})
/**
* Intersection has to be used since you are not
* allowed to merge two schemas where one uses
* transform
*/
const linkAndTitleSchema = z.intersection(linkSchema, titleSchema)
/**
* Same as above 👆
*/
export const menuItemSchema = z
.intersection(
linkAndTitleSchema,
z
.object({
cardConnection: z.object({
edges: z.array(
z.object({
node: cardBlockSchema,
})
),
}),
see_all_link: linkAndTitleSchema,
submenu: z.array(
z.object({
links: z.array(linkAndTitleSchema),
title: z.string().optional().default(""),
})
),
})
.transform((data) => {
let card = null
if (data.cardConnection.edges.length) {
card = transformCardBlock(data.cardConnection.edges[0].node)
}
return {
card,
seeAllLink: data.see_all_link,
submenu: data.submenu,
}
})
)
.transform((data) => {
return {
...data,
link: data.submenu.length ? null : data.link,
seeAllLink: data.submenu.length ? data.seeAllLink : null,
}
})
const topLinkItemSchema = z.intersection(
linkAndTitleSchema,
z.object({
icon: z
.enum(["loyalty", "info", "offer"])
.nullable()
.transform((icon) => {
switch (icon) {
case "loyalty":
return IconName.Gift
case "info":
return IconName.InfoCircle
case "offer":
return IconName.PriceTag
default:
return null
}
}),
})
)
export const topLinkSchema = z.object({
logged_in: topLinkItemSchema.nullable(),
logged_out: topLinkItemSchema.nullable(),
})
export const headerSchema = z
.object({
all_header: z.object({
items: z
.array(
z.object({
menu_items: z.array(menuItemSchema),
top_link: topLinkSchema,
})
)
.max(1),
}),
})
.transform((data) => {
if (!data.all_header.items.length) {
console.info(`Zod Error - No header returned in request`)
throw new ZodError([
{
code: ZodIssueCode.custom,
fatal: true,
message: "No header returned",
path: ["all_header.items"],
},
])
}
const header = data.all_header.items[0]
return {
header: {
menuItems: header.menu_items,
topLink: header.top_link,
},
}
})
export const alertSchema = z
.object({
type: z.nativeEnum(AlertTypeEnum),
text: z.string(),
heading: z.string(),
phone_contact: z.object({
display_text: z.string(),
phone_number: z.string().nullable(),
footnote: z.string().nullable(),
}),
has_link: z.boolean(),
link: linkAndTitleSchema,
has_sidepeek_button: z.boolean(),
sidepeek_button: z.object({
cta_text: z.string(),
}),
sidepeek_content: z.object({
heading: z.string(),
content: z.object({
json: z.any(),
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkUnionSchema.transform((data) => {
const link = transformPageLink(data)
if (link) {
return link
}
return data
}),
})
),
}),
}),
}),
})
.transform(
({
type,
heading,
text,
phone_contact,
has_link,
link,
has_sidepeek_button,
sidepeek_button,
sidepeek_content,
}) => {
const hasLink = has_link && link.link
return {
type,
text,
heading,
phoneContact:
phone_contact.display_text && phone_contact.phone_number
? {
displayText: phone_contact.display_text,
phoneNumber: phone_contact.phone_number,
footnote: phone_contact.footnote,
}
: null,
hasSidepeekButton: !!has_sidepeek_button,
link: hasLink
? {
url: link.link.url,
title: link.title,
}
: null,
sidepeekButton:
!hasLink && has_sidepeek_button ? sidepeek_button : null,
sidepeekContent:
!hasLink && has_sidepeek_button ? sidepeek_content : null,
}
}
)
export const siteConfigSchema = z
.object({
all_site_config: z.object({
items: z
.array(
z.object({
sitewide_alert: z.object({
booking_widget_disabled: z.boolean(),
alertConnection: z.object({
edges: z
.array(
z.object({
node: alertSchema,
})
)
.max(1),
}),
}),
})
)
.max(1),
}),
})
.transform((data) => {
if (!data.all_site_config.items.length) {
return {
sitewideAlert: null,
bookingWidgetDisabled: false,
}
}
const { sitewide_alert } = data.all_site_config.items[0]
return {
sitewideAlert: sitewide_alert.alertConnection.edges[0]?.node || null,
bookingWidgetDisabled: sitewide_alert.booking_widget_disabled,
}
})
const sidepeekContentRefSchema = z.object({
content: z.object({
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
}),
})
const alertConnectionRefSchema = z.object({
edges: z.array(
z.object({
node: z.object({
link: linkRefsSchema,
sidepeek_content: sidepeekContentRefSchema,
system: systemSchema,
}),
})
),
})
export const siteConfigRefSchema = z.object({
all_site_config: z.object({
items: z.array(
z.object({
sitewide_alert: z.object({
alertConnection: alertConnectionRefSchema,
}),
system: systemSchema,
})
),
}),
})

View File

@@ -1,504 +0,0 @@
import { cache } from "react"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentstackBaseProcedure } from "@scandic-hotels/trpc/procedures"
import { GetContactConfig } from "@/lib/graphql/Query/ContactConfig.graphql"
import {
GetCurrentFooter,
GetCurrentFooterRef,
} from "@/lib/graphql/Query/Current/Footer.graphql"
import {
GetCurrentHeader,
GetCurrentHeaderRef,
} from "@/lib/graphql/Query/Current/Header.graphql"
import { GetFooter, GetFooterRef } from "@/lib/graphql/Query/Footer.graphql"
import { GetHeader, GetHeaderRef } from "@/lib/graphql/Query/Header.graphql"
import {
GetSiteConfig,
GetSiteConfigRef,
} from "@/lib/graphql/Query/SiteConfig.graphql"
import { request } from "@/lib/graphql/request"
import { langInput } from "@/server/utils"
import {
generateRefsResponseTag,
generateTag,
generateTags,
generateTagsFromSystem,
} from "@/utils/generateTag"
import {
type ContactConfigData,
type CurrentFooterDataRaw,
type CurrentFooterRefDataRaw,
type CurrentHeaderRefDataRaw,
type GetCurrentHeaderData,
headerRefsSchema,
headerSchema,
siteConfigRefSchema,
siteConfigSchema,
validateContactConfigSchema,
validateCurrentFooterConfigSchema,
validateCurrentHeaderConfigSchema,
validateFooterConfigSchema,
validateFooterRefConfigSchema,
} from "./output"
import {
getAlertPhoneContactData,
getConnections,
getFooterConnections,
getSiteConfigConnections,
} from "./utils"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type {
FooterDataRaw,
FooterRefDataRaw,
} from "@/types/components/footer/footer"
import type {
GetHeader as GetHeaderData,
GetHeaderRefs,
} from "@/types/trpc/routers/contentstack/header"
import type {
GetSiteConfigData,
GetSiteConfigRefData,
} from "@/types/trpc/routers/contentstack/siteConfig"
const getContactConfig = cache(async (lang: Lang) => {
const getContactConfigCounter = createCounter(
"trpc.contentstack",
"contactConfig.get"
)
const metricsGetContactConfig = getContactConfigCounter.init({ lang })
metricsGetContactConfig.start()
const response = await request<ContactConfigData>(
GetContactConfig,
{
locale: lang,
},
{
key: `${lang}:contact`,
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetContactConfig.noDataError()
throw notFoundError
}
const verifiedData = validateContactConfigSchema.safeParse(response.data)
if (!verifiedData.success) {
metricsGetContactConfig.validationError(verifiedData.error)
return null
}
metricsGetContactConfig.success()
return verifiedData.data.all_contact_config.items[0]
})
export const baseQueryRouter = router({
contact: contentstackBaseProcedure.query(async ({ ctx }) => {
return await getContactConfig(ctx.lang)
}),
header: contentstackBaseProcedure.query(async ({ ctx }) => {
const { lang } = ctx
const getHeaderRefsCounter = createCounter(
"trpc.contentstack",
"header.get.refs"
)
const metricsGetHeaderRefs = getHeaderRefsCounter.init({ lang })
metricsGetHeaderRefs.start()
const responseRef = await request<GetHeaderRefs>(
GetHeaderRef,
{
locale: lang,
},
{
key: generateRefsResponseTag(lang, "header"),
ttl: "max",
}
)
if (!responseRef.data) {
const notFoundError = notFound(responseRef)
metricsGetHeaderRefs.noDataError()
throw notFoundError
}
const validatedHeaderRefs = headerRefsSchema.safeParse(responseRef.data)
if (!validatedHeaderRefs.success) {
metricsGetHeaderRefs.validationError(validatedHeaderRefs.error)
return null
}
metricsGetHeaderRefs.success()
const connections = getConnections(validatedHeaderRefs.data)
const getHeaderCounter = createCounter("trpc.contentstack", "header.get")
const metricsGetHeader = getHeaderCounter.init({ lang })
metricsGetHeader.start()
const tags = [
generateTagsFromSystem(lang, connections),
generateTag(lang, validatedHeaderRefs.data.header.system.uid),
].flat()
const response = await request<GetHeaderData>(
GetHeader,
{ locale: lang },
{ key: tags, ttl: "max" }
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetHeader.noDataError()
throw notFoundError
}
const validatedHeaderConfig = headerSchema.safeParse(response.data)
if (!validatedHeaderConfig.success) {
metricsGetHeader.validationError(validatedHeaderConfig.error)
return null
}
metricsGetHeader.success()
return {
data: validatedHeaderConfig.data.header,
}
}),
currentHeader: contentstackBaseProcedure
.input(langInput)
.query(async ({ input }) => {
const getCurrentHeaderRefsCounter = createCounter(
"trpc.contentstack",
"currentHeader.get.refs"
)
const metricsGetCurrentHeaderRefs = getCurrentHeaderRefsCounter.init({
lang: input.lang,
})
metricsGetCurrentHeaderRefs.start()
const responseRef = await request<CurrentHeaderRefDataRaw>(
GetCurrentHeaderRef,
{
locale: input.lang,
},
{
key: generateRefsResponseTag(input.lang, "current_header"),
ttl: "max",
}
)
const getCurrentHeaderCounter = createCounter(
"trpc.contentstack",
"currentHeader.get"
)
const metricsGetCurrentHeader = getCurrentHeaderCounter.init({
lang: input.lang,
})
metricsGetCurrentHeader.start()
const currentHeaderUID =
responseRef.data.all_current_header.items[0].system.uid
// There's currently no error handling/validation for the responseRef, should it be added?
const response = await request<GetCurrentHeaderData>(
GetCurrentHeader,
{ locale: input.lang },
{
key: generateTag(input.lang, currentHeaderUID),
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetCurrentHeader.noDataError()
throw notFoundError
}
const validatedHeaderConfig = validateCurrentHeaderConfigSchema.safeParse(
response.data
)
if (!validatedHeaderConfig.success) {
metricsGetCurrentHeader.validationError(validatedHeaderConfig.error)
return null
}
metricsGetCurrentHeader.success()
return validatedHeaderConfig.data
}),
currentFooter: contentstackBaseProcedure
.input(langInput)
.query(async ({ input }) => {
const getCurrentFooterRefsCounter = createCounter(
"trpc.contentstack",
"currentFooter.get.refs"
)
const metricsGetCurrentFooterRefs = getCurrentFooterRefsCounter.init({
lang: input.lang,
})
metricsGetCurrentFooterRefs.start()
const responseRef = await request<CurrentFooterRefDataRaw>(
GetCurrentFooterRef,
{
locale: input.lang,
},
{
key: generateRefsResponseTag(input.lang, "current_footer"),
ttl: "max",
}
)
const getCurrentFooterCounter = createCounter(
"trpc.contentstack",
"currentFooter.get"
)
const metricsGetCurrentFooter = getCurrentFooterCounter.init({
lang: input.lang,
})
metricsGetCurrentFooter.start()
const currentFooterUID =
responseRef.data.all_current_footer.items[0].system.uid
const response = await request<CurrentFooterDataRaw>(
GetCurrentFooter,
{
locale: input.lang,
},
{
key: generateTag(input.lang, currentFooterUID),
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetCurrentFooter.noDataError()
throw notFoundError
}
const validatedCurrentFooterConfig =
validateCurrentFooterConfigSchema.safeParse(response.data)
if (!validatedCurrentFooterConfig.success) {
metricsGetCurrentFooter.validationError(
validatedCurrentFooterConfig.error
)
return null
}
metricsGetCurrentFooter.success()
return validatedCurrentFooterConfig.data.all_current_footer.items[0]
}),
footer: contentstackBaseProcedure.query(async ({ ctx }) => {
const { lang } = ctx
const getFooterRefsCounter = createCounter(
"trpc.contentstack",
"footer.get.refs"
)
const metricsGetFooterRefs = getFooterRefsCounter.init({ lang })
metricsGetFooterRefs.start()
const responseRef = await request<FooterRefDataRaw>(
GetFooterRef,
{
locale: lang,
},
{
key: generateRefsResponseTag(lang, "footer"),
ttl: "max",
}
)
if (!responseRef.data) {
const notFoundError = notFound(responseRef)
metricsGetFooterRefs.noDataError()
throw notFoundError
}
const validatedFooterRefs = validateFooterRefConfigSchema.safeParse(
responseRef.data
)
if (!validatedFooterRefs.success) {
metricsGetFooterRefs.validationError(validatedFooterRefs.error)
return null
}
metricsGetFooterRefs.success()
const connections = getFooterConnections(validatedFooterRefs.data)
const footerUID = responseRef.data.all_footer.items[0].system.uid
const getFooterCounter = createCounter("trpc.contentstack", "footer.get")
const metricsGetFooter = getFooterCounter.init({ lang })
metricsGetFooter.start()
const tags = [
generateTags(lang, connections),
generateTag(lang, footerUID),
].flat()
const response = await request<FooterDataRaw>(
GetFooter,
{
locale: lang,
},
{
key: tags,
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetFooter.noDataError()
throw notFoundError
}
const validatedFooterConfig = validateFooterConfigSchema.safeParse(
response.data
)
if (!validatedFooterConfig.success) {
metricsGetFooter.validationError(validatedFooterConfig.error)
return null
}
metricsGetFooter.success()
return validatedFooterConfig.data
}),
siteConfig: contentstackBaseProcedure
.input(langInput)
.query(async ({ input, ctx }) => {
const lang = input.lang ?? ctx.lang
const getSiteConfigRefsCounter = createCounter(
"trpc.contentstack",
"siteConfig.get.refs"
)
const metricsGetSiteConfigRefs = getSiteConfigRefsCounter.init({ lang })
metricsGetSiteConfigRefs.start()
const responseRef = await request<GetSiteConfigRefData>(
GetSiteConfigRef,
{
locale: lang,
},
{
key: generateRefsResponseTag(lang, "site_config"),
ttl: "max",
}
)
if (!responseRef.data) {
const notFoundError = notFound(responseRef)
metricsGetSiteConfigRefs.noDataError()
throw notFoundError
}
const validatedSiteConfigRef = siteConfigRefSchema.safeParse(
responseRef.data
)
if (!validatedSiteConfigRef.success) {
metricsGetSiteConfigRefs.validationError(validatedSiteConfigRef.error)
return null
}
const connections = getSiteConfigConnections(validatedSiteConfigRef.data)
const siteConfigUid = responseRef.data.all_site_config.items[0].system.uid
const tags = [
generateTagsFromSystem(lang, connections),
generateTag(lang, siteConfigUid),
].flat()
metricsGetSiteConfigRefs.success()
const getSiteConfigCounter = createCounter(
"trpc.contentstack",
"siteConfig.get"
)
const metricsGetSiteConfig = getSiteConfigCounter.init({ lang })
metricsGetSiteConfig.start()
const [siteConfigResponse, contactConfig] = await Promise.all([
request<GetSiteConfigData>(
GetSiteConfig,
{
locale: lang,
},
{
key: tags,
ttl: "max",
}
),
getContactConfig(lang),
])
if (!siteConfigResponse.data) {
const notFoundError = notFound(siteConfigResponse)
metricsGetSiteConfig.noDataError()
throw notFoundError
}
const validatedSiteConfig = siteConfigSchema.safeParse(
siteConfigResponse.data
)
if (!validatedSiteConfig.success) {
metricsGetSiteConfig.validationError(validatedSiteConfig.error)
return null
}
metricsGetSiteConfig.success()
const { sitewideAlert } = validatedSiteConfig.data
return {
...validatedSiteConfig.data,
sitewideAlert: sitewideAlert
? {
...sitewideAlert,
phoneContact: contactConfig
? getAlertPhoneContactData(sitewideAlert, contactConfig)
: null,
}
: null,
}
}),
})

View File

@@ -1,122 +0,0 @@
import { getValueFromContactConfig } from "@/utils/contactConfig"
import type { FooterRefDataRaw } from "@/types/components/footer/footer"
import type { System } from "@/types/requests/system"
import type { Edges } from "@/types/requests/utils/edges"
import type { NodeRefs } from "@/types/requests/utils/refs"
import type { HeaderRefs } from "@/types/trpc/routers/contentstack/header"
import type {
AlertOutput,
GetSiteConfigRefData,
} from "@/types/trpc/routers/contentstack/siteConfig"
import type { ContactConfig } from "./output"
export function getConnections({ header }: HeaderRefs) {
const connections: System["system"][] = [header.system]
if (header.top_link) {
if (header.top_link.logged_in?.link) {
connections.push(header.top_link.logged_in.link)
}
if (header.top_link.logged_out?.link) {
connections.push(header.top_link.logged_out.link)
}
}
if (header.menu_items.length) {
header.menu_items.forEach((menuItem) => {
if (menuItem.card) {
connections.push(...menuItem.card)
}
if (menuItem.link) {
connections.push(menuItem.link)
}
if (menuItem.see_all_link?.link) {
connections.push(menuItem.see_all_link.link)
}
if (menuItem.submenu.length) {
menuItem.submenu.forEach((subMenuItem) => {
if (subMenuItem.links.length) {
subMenuItem.links.forEach((link) => {
if (link?.link) {
connections.push(link.link)
}
})
}
})
}
})
}
return connections
}
export function getFooterConnections(refs: FooterRefDataRaw) {
const connections: Edges<NodeRefs>[] = []
const footerData = refs.all_footer.items[0]
const mainLinks = footerData.main_links
const secondaryLinks = footerData.secondary_links
const tertiaryLinks = footerData.tertiary_links
if (mainLinks) {
mainLinks.forEach(({ pageConnection }) => {
connections.push(pageConnection)
})
}
secondaryLinks?.forEach(({ links }) => {
if (links) {
links.forEach(({ pageConnection }) => {
connections.push(pageConnection)
})
}
})
if (tertiaryLinks) {
tertiaryLinks.forEach(({ pageConnection }) => {
connections.push(pageConnection)
})
}
return connections
}
export function getSiteConfigConnections(refs: GetSiteConfigRefData) {
const siteConfigData = refs.all_site_config.items[0]
const connections: System["system"][] = []
if (!siteConfigData) return connections
const alertConnection = siteConfigData.sitewide_alert.alertConnection
alertConnection.edges.forEach(({ node }) => {
connections.push(node.system)
const link = node.link.link
if (link) {
connections.push(link)
}
node.sidepeek_content.content.embedded_itemsConnection.edges.forEach(
({ node }) => {
connections.push(node.system)
}
)
})
return connections
}
export function getAlertPhoneContactData(
alert: AlertOutput,
contactConfig: ContactConfig
) {
if (alert.phoneContact) {
const { displayText, phoneNumber, footnote } = alert.phoneContact
return {
displayText,
phoneNumber: getValueFromContactConfig(phoneNumber, contactConfig),
footnote: footnote
? getValueFromContactConfig(footnote, contactConfig)
: null,
}
}
return null
}

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { breadcrumbsQueryRouter } from "./query"
export const breadcrumbsRouter = mergeRouters(breadcrumbsQueryRouter)

View File

@@ -1,77 +0,0 @@
import { z } from "zod"
import { removeMultipleSlashes } from "@/utils/url"
import { systemSchema } from "../schemas/system"
import { homeBreadcrumbs } from "./utils"
export const breadcrumbsRefsSchema = z.object({
web: z
.object({
breadcrumbs: z
.object({
title: z.string(),
parentsConnection: z.object({
edges: z.array(
z.object({
node: z.object({
system: systemSchema,
}),
})
),
}),
})
.optional(),
})
.optional(),
system: systemSchema,
})
export const rawBreadcrumbsDataSchema = z.object({
url: z.string(),
web: z.object({
breadcrumbs: z.object({
title: z.string(),
parentsConnection: z.object({
edges: z.array(
z.object({
node: z.object({
web: z.object({
breadcrumbs: z.object({
title: z.string(),
}),
}),
system: systemSchema,
url: z.string(),
}),
})
),
}),
}),
}),
system: systemSchema,
})
export const breadcrumbsSchema = rawBreadcrumbsDataSchema.transform(
(data): { title: string; href: string; uid: string }[] => {
const { parentsConnection, title } = data.web.breadcrumbs
const parentBreadcrumbs = parentsConnection.edges.map((breadcrumb) => {
return {
href: removeMultipleSlashes(
`/${breadcrumb.node.system.locale}/${breadcrumb.node.url}`
),
title: breadcrumb.node.web.breadcrumbs.title,
uid: breadcrumb.node.system.uid,
}
})
const pageBreadcrumb = {
title,
uid: data.system.uid,
href: removeMultipleSlashes(`/${data.system.locale}/${data.url}`),
}
const homeBreadcrumb = homeBreadcrumbs[data.system.locale]
return [homeBreadcrumb, parentBreadcrumbs, pageBreadcrumb].flat()
}
)

View File

@@ -1,268 +0,0 @@
import { cache } from "react"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentstackExtendedProcedureUID } from "@scandic-hotels/trpc/procedures"
import {
GetMyPagesBreadcrumbs,
GetMyPagesBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/AccountPage.graphql"
import {
GetCampaignOverviewPageBreadcrumbs,
GetCampaignOverviewPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/CampaignOverviewPage.graphql"
import {
GetCampaignPageBreadcrumbs,
GetCampaignPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/CampaignPage.graphql"
import {
GetCollectionPageBreadcrumbs,
GetCollectionPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/CollectionPage.graphql"
import {
GetContentPageBreadcrumbs,
GetContentPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/ContentPage.graphql"
import {
GetDestinationCityPageBreadcrumbs,
GetDestinationCityPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/DestinationCityPage.graphql"
import {
GetDestinationCountryPageBreadcrumbs,
GetDestinationCountryPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/DestinationCountryPage.graphql"
import {
GetDestinationOverviewPageBreadcrumbs,
GetDestinationOverviewPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/DestinationOverviewPage.graphql"
import {
GetHotelPageBreadcrumbs,
GetHotelPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/HotelPage.graphql"
import {
GetLoyaltyPageBreadcrumbs,
GetLoyaltyPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/LoyaltyPage.graphql"
import { request } from "@/lib/graphql/request"
import { generateRefsResponseTag } from "@/utils/generateTag"
import { breadcrumbsRefsSchema, breadcrumbsSchema } from "./output"
import { getTags } from "./utils"
import type { Lang } from "@scandic-hotels/common/constants/language"
import { PageContentTypeEnum } from "@/types/requests/contentType"
import type {
BreadcrumbsRefsSchema,
RawBreadcrumbsSchema,
} from "@/types/trpc/routers/contentstack/breadcrumbs"
interface BreadcrumbsPageData<T> {
dataKey: keyof T
refQuery: string
query: string
}
const getBreadcrumbs = cache(async function fetchMemoizedBreadcrumbs<T>(
{ dataKey, refQuery, query }: BreadcrumbsPageData<T>,
{ uid, lang }: { uid: string; lang: Lang }
) {
const getBreadcrumbsRefsCounter = createCounter(
"trpc.contentstack",
"breadcrumbs.get.refs"
)
const metricsGetBreadcrumbsRefs = getBreadcrumbsRefsCounter.init({
lang,
uid,
})
metricsGetBreadcrumbsRefs.start()
const refsResponse = await request<{ [K in keyof T]: BreadcrumbsRefsSchema }>(
refQuery,
{ locale: lang, uid },
{
key: generateRefsResponseTag(lang, uid, "breadcrumbs"),
ttl: "max",
}
)
const validatedRefsData = breadcrumbsRefsSchema.safeParse(
refsResponse.data[dataKey]
)
if (!validatedRefsData.success) {
metricsGetBreadcrumbsRefs.validationError(validatedRefsData.error)
return []
}
metricsGetBreadcrumbsRefs.success()
const tags = getTags(validatedRefsData.data, lang)
const getBreadcrumbsCounter = createCounter(
"trpc.contentstack",
"breadcrumbs.get"
)
const metricsGetBreadcrumbs = getBreadcrumbsCounter.init({
lang,
uid,
})
metricsGetBreadcrumbs.start()
const response = await request<T>(
query,
{ locale: lang, uid },
{
key: tags,
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetBreadcrumbs.noDataError()
throw notFoundError
}
const validatedBreadcrumbs = breadcrumbsSchema.safeParse(
response.data[dataKey]
)
if (!validatedBreadcrumbs.success) {
metricsGetBreadcrumbs.validationError(validatedBreadcrumbs.error)
return []
}
metricsGetBreadcrumbs.success()
return validatedBreadcrumbs.data
})
export const breadcrumbsQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const variables = {
lang: ctx.lang,
uid: ctx.uid,
}
switch (ctx.contentType) {
case PageContentTypeEnum.accountPage:
return await getBreadcrumbs<{
account_page: RawBreadcrumbsSchema
}>(
{
dataKey: "account_page",
refQuery: GetMyPagesBreadcrumbsRefs,
query: GetMyPagesBreadcrumbs,
},
variables
)
case PageContentTypeEnum.campaignOverviewPage:
return await getBreadcrumbs<{
campaign_overview_page: RawBreadcrumbsSchema
}>(
{
dataKey: "campaign_overview_page",
refQuery: GetCampaignOverviewPageBreadcrumbsRefs,
query: GetCampaignOverviewPageBreadcrumbs,
},
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
}>(
{
dataKey: "collection_page",
refQuery: GetCollectionPageBreadcrumbsRefs,
query: GetCollectionPageBreadcrumbs,
},
variables
)
case PageContentTypeEnum.contentPage:
return await getBreadcrumbs<{
content_page: RawBreadcrumbsSchema
}>(
{
dataKey: "content_page",
refQuery: GetContentPageBreadcrumbsRefs,
query: GetContentPageBreadcrumbs,
},
variables
)
case PageContentTypeEnum.destinationOverviewPage:
return await getBreadcrumbs<{
destination_overview_page: RawBreadcrumbsSchema
}>(
{
dataKey: "destination_overview_page",
refQuery: GetDestinationOverviewPageBreadcrumbsRefs,
query: GetDestinationOverviewPageBreadcrumbs,
},
variables
)
case PageContentTypeEnum.destinationCountryPage:
return await getBreadcrumbs<{
destination_country_page: RawBreadcrumbsSchema
}>(
{
dataKey: "destination_country_page",
refQuery: GetDestinationCountryPageBreadcrumbsRefs,
query: GetDestinationCountryPageBreadcrumbs,
},
variables
)
case PageContentTypeEnum.destinationCityPage:
return await getBreadcrumbs<{
destination_city_page: RawBreadcrumbsSchema
}>(
{
dataKey: "destination_city_page",
refQuery: GetDestinationCityPageBreadcrumbsRefs,
query: GetDestinationCityPageBreadcrumbs,
},
variables
)
case PageContentTypeEnum.hotelPage:
return await getBreadcrumbs<{
hotel_page: RawBreadcrumbsSchema
}>(
{
dataKey: "hotel_page",
refQuery: GetHotelPageBreadcrumbsRefs,
query: GetHotelPageBreadcrumbs,
},
variables
)
case PageContentTypeEnum.loyaltyPage:
return await getBreadcrumbs<{
loyalty_page: RawBreadcrumbsSchema
}>(
{
dataKey: "loyalty_page",
refQuery: GetLoyaltyPageBreadcrumbsRefs,
query: GetLoyaltyPageBreadcrumbs,
},
variables
)
default:
return []
}
}),
})

View File

@@ -1,62 +0,0 @@
import { Lang } from "@scandic-hotels/common/constants/language"
import { generateTag, generateTags } from "@/utils/generateTag"
import type { Edges } from "@/types/requests/utils/edges"
import type { NodeRefs } from "@/types/requests/utils/refs"
import type { BreadcrumbsRefsSchema } from "@/types/trpc/routers/contentstack/breadcrumbs"
export const affix = "breadcrumbs"
// TODO: Make these editable in CMS?
export const homeBreadcrumbs: {
[key in keyof typeof Lang]: { href: string; title: string; uid: string }
} = {
[Lang.da]: {
href: "/da",
title: "Hjem",
uid: "da",
},
[Lang.de]: {
href: "/de",
title: "Heim",
uid: "de",
},
[Lang.en]: {
href: "/en",
title: "Home",
uid: "en",
},
[Lang.fi]: {
href: "/fi",
title: "Koti",
uid: "fi",
},
[Lang.no]: {
href: "/no",
title: "Hjem",
uid: "no",
},
[Lang.sv]: {
href: "/sv",
title: "Hem",
uid: "sv",
},
}
export function getConnections(data: BreadcrumbsRefsSchema) {
const connections: Edges<NodeRefs>[] = []
if (data.web?.breadcrumbs) {
connections.push(data.web.breadcrumbs.parentsConnection)
}
return connections
}
export function getTags(data: BreadcrumbsRefsSchema, lang: Lang) {
const connections = getConnections(data)
const tags = generateTags(lang, connections)
tags.push(generateTag(lang, data.system.uid, affix))
return tags
}

View File

@@ -1,7 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { campaignOverviewPageQueryRouter } from "./query"
export const campaignOverviewPageRouter = mergeRouters(
campaignOverviewPageQueryRouter
)

View File

@@ -1,117 +0,0 @@
import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import {
campaignPageHotelListing,
heroSchema,
includedHotelsSchema,
} from "@/server/routers/contentstack/campaignPage/output"
import {
linkAndTitleSchema,
linkConnectionRefs,
} from "@/server/routers/contentstack/schemas/linkConnection"
import { systemSchema } from "../schemas/system"
import { CampaignPageEnum } from "@/types/enums/campaignPage"
const navigationLinksSchema = z
.array(linkAndTitleSchema)
.nullable()
.transform((data) => {
if (!data) {
return null
}
return data
.filter((item) => !!item.link)
.map((item) => ({
url: item.link!.url,
title: item.title || item.link!.title,
}))
})
export const blocksSchema = z.discriminatedUnion("__typename", [
campaignPageHotelListing,
])
const topCampaignSchema = z
.object({
heading: z.string(),
hero: heroSchema,
included_hotels: includedHotelsSchema,
blocks: discriminatedUnionArray(blocksSchema.options),
url: z.string(),
})
.transform((data) => {
const { blocks, included_hotels, ...rest } = data
const hotelListingBlock = blocks.find(
(block) =>
block.__typename === CampaignPageEnum.ContentStack.blocks.HotelListing
)
return {
...rest,
hotel_listing: {
heading: hotelListingBlock?.hotel_listing.heading || "",
hotelIds: included_hotels,
},
}
})
export const campaignOverviewPageSchema = z.object({
campaign_overview_page: z
.object({
title: z.string(),
header: z.object({
heading: z.string(),
preamble: z.string(),
navigation_links: navigationLinksSchema,
}),
top_campaignConnection: z.object({
edges: z.array(
z.object({
node: topCampaignSchema,
})
),
}),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
})
.transform((data) => {
const { top_campaignConnection, ...rest } = data
return {
...rest,
topCampaign: top_campaignConnection.edges.map(({ node }) => node)[0],
}
}),
trackingProps: z.object({
url: z.string(),
}),
})
/** REFS */
const campaignOverviewPageHeaderRefs = z.object({
navigation_links: z.array(linkConnectionRefs),
})
export const campaignOverviewPageRefsSchema = z.object({
campaign_overview_page: z.object({
header: campaignOverviewPageHeaderRefs,
top_campaignConnection: z.object({
edges: z.array(
z.object({
node: z.object({
system: systemSchema,
}),
})
),
}),
system: systemSchema,
}),
})

View File

@@ -1,135 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentStackUidWithServiceProcedure } from "@scandic-hotels/trpc/procedures"
import {
GetCampaignOverviewPage,
GetCampaignOverviewPageRefs,
} from "@/lib/graphql/Query/CampaignOverviewPage/CampaignOverviewPage.graphql"
import { request } from "@/lib/graphql/request"
import { generateRefsResponseTag } from "@/utils/generateTag"
import {
campaignOverviewPageRefsSchema,
campaignOverviewPageSchema,
} from "./output"
import { generatePageTags } from "./utils"
import {
TrackingChannelEnum,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type {
GetCampaignOverviewPageData,
GetCampaignOverviewPageRefsData,
} from "@/types/trpc/routers/contentstack/campaignOverviewPage"
export const campaignOverviewPageQueryRouter = router({
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
const { lang, uid } = ctx
const getCampaignOverviewPageRefsCounter = createCounter(
"trpc.contentstack",
"campaignOverviewPage.get.refs"
)
const metricsGetCampaignOverviewPageRefs =
getCampaignOverviewPageRefsCounter.init({
lang,
uid,
})
metricsGetCampaignOverviewPageRefs.start()
const refsResponse = await request<GetCampaignOverviewPageRefsData>(
GetCampaignOverviewPageRefs,
{ locale: lang, uid },
{
key: generateRefsResponseTag(lang, uid),
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
metricsGetCampaignOverviewPageRefs.noDataError()
throw notFoundError
}
const validatedRefsData = campaignOverviewPageRefsSchema.safeParse(
refsResponse.data
)
if (!validatedRefsData.success) {
metricsGetCampaignOverviewPageRefs.validationError(
validatedRefsData.error
)
return null
}
metricsGetCampaignOverviewPageRefs.success()
const tags = generatePageTags(validatedRefsData.data, lang)
const getCampaignOverviewPageCounter = createCounter(
"trpc.contentstack",
"campaignOverviewPage.get"
)
const metricsGetCampaignOverviewPage = getCampaignOverviewPageCounter.init({
lang,
uid,
})
metricsGetCampaignOverviewPage.start()
const response = await request<GetCampaignOverviewPageData>(
GetCampaignOverviewPage,
{
locale: lang,
uid,
},
{
key: tags,
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetCampaignOverviewPage.noDataError()
throw notFoundError
}
const validatedResponse = campaignOverviewPageSchema.safeParse(
response.data
)
if (!validatedResponse.success) {
metricsGetCampaignOverviewPage.validationError(validatedResponse.error)
return null
}
const campaignOverviewPage = validatedResponse.data.campaign_overview_page
metricsGetCampaignOverviewPage.success()
const system = campaignOverviewPage.system
const pageName = `campaign-overview-page`
const tracking: TrackingSDKPageData = {
pageId: system.uid,
domainLanguage: system.locale,
publishDate: system.updated_at,
createDate: system.created_at,
channel: TrackingChannelEnum["campaign-overview-page"],
pageType: "campaign-overview-page",
pageName,
siteSections: pageName,
siteVersion: "new-web",
}
return {
campaignOverviewPage,
tracking,
}
}),
})

View File

@@ -1,38 +0,0 @@
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { System } from "@/types/requests/system"
import type { CampaignOverviewPageRefs } from "@/types/trpc/routers/contentstack/campaignOverviewPage"
export function generatePageTags(
validatedData: CampaignOverviewPageRefs,
lang: Lang
): string[] {
const connections = getConnections(validatedData)
return [
generateTagsFromSystem(lang, connections),
generateTag(lang, validatedData.campaign_overview_page.system.uid),
].flat()
}
export function getConnections({
campaign_overview_page,
}: CampaignOverviewPageRefs) {
const connections: System["system"][] = [campaign_overview_page.system]
if (campaign_overview_page.header.navigation_links) {
campaign_overview_page.header.navigation_links.forEach((link) => {
if (link.link) {
connections.push(link.link)
}
})
}
if (campaign_overview_page.top_campaignConnection) {
campaign_overview_page.top_campaignConnection.edges.forEach(({ node }) => {
connections.push(node.system)
})
}
return connections
}

View File

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

View File

@@ -1,193 +0,0 @@
import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import {
accordionRefsSchema,
accordionSchema,
} from "../schemas/blocks/accordion"
import {
carouselCardsRefsSchema,
carouselCardsSchema,
} from "../schemas/blocks/carouselCards"
import { essentialsBlockSchema } from "../schemas/blocks/essentials"
import { campaignPageHotelListingSchema } from "../schemas/blocks/hotelListing"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import {
linkConnectionRefs,
linkConnectionSchema,
} from "../schemas/linkConnection"
import { systemSchema } from "../schemas/system"
import { CampaignPageEnum } from "@/types/enums/campaignPage"
const campaignPageEssentials = z
.object({
__typename: z.literal(CampaignPageEnum.ContentStack.blocks.Essentials),
})
.merge(essentialsBlockSchema)
const campaignPageCarouselCards = z
.object({
__typename: z.literal(CampaignPageEnum.ContentStack.blocks.CarouselCards),
})
.merge(carouselCardsSchema)
export const campaignPageAccordion = z
.object({
__typename: z.literal(CampaignPageEnum.ContentStack.blocks.Accordion),
})
.merge(accordionSchema)
export const campaignPageHotelListing = z
.object({
__typename: z.literal(CampaignPageEnum.ContentStack.blocks.HotelListing),
})
.merge(campaignPageHotelListingSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [
campaignPageEssentials,
campaignPageCarouselCards,
campaignPageAccordion,
campaignPageHotelListing,
])
export const heroSchema = z.object({
image: tempImageVaultAssetSchema,
heading: z.string(),
theme: z.enum(["Peach", "Burgundy"]).default("Peach"),
benefits: z
.array(z.string())
.nullish()
.transform((data) => data || []),
rate_text: z
.object({
bold_text: z.string().nullish(),
text: z.string().nullish(),
})
.nullish(),
button: z
.intersection(z.object({ cta: z.string() }), linkConnectionSchema)
.transform((data) => {
if (!data.link) {
return null
}
return {
cta: data.cta,
url: data.link?.url || "",
}
}),
})
export const includedHotelsSchema = z
.object({
list_1Connection: z.object({
edges: z.array(
z.object({
node: z.object({
hotel_page_id: z.string(),
}),
})
),
}),
list_2Connection: z.object({
edges: z.array(
z.object({
node: z.object({
hotel_page_id: z.string(),
}),
})
),
}),
})
.transform((data) => {
const list1HotelIds = data.list_1Connection.edges
.map((edge) => edge.node.hotel_page_id)
.filter(Boolean)
const list2HotelIds = data.list_2Connection.edges
.map((edge) => edge.node.hotel_page_id)
.filter(Boolean)
return [...new Set([...list1HotelIds, ...list2HotelIds])]
})
export const campaignPageSchema = z
.object({
campaign_page: z.object({
title: z.string(),
hero: heroSchema,
heading: z.string(),
subheading: z.string().nullish(),
included_hotels: includedHotelsSchema,
preamble: z.object({
is_two_columns: z.boolean().default(false),
first_column: z.string(),
second_column: z.string(),
}),
blocks: discriminatedUnionArray(blocksSchema.options),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
}),
trackingProps: z.object({
url: z.string(),
}),
})
.transform((data) => {
const blocks = data.campaign_page.blocks.map((block) => {
if (
block.__typename === CampaignPageEnum.ContentStack.blocks.HotelListing
) {
return {
...block,
hotel_listing: {
...block.hotel_listing,
hotelIds: data.campaign_page.included_hotels,
},
}
}
return block
})
return {
...data,
campaign_page: {
...data.campaign_page,
blocks: [...blocks],
},
}
})
/** REFS */
const campaignPageCarouselCardsRef = z
.object({
__typename: z.literal(CampaignPageEnum.ContentStack.blocks.CarouselCards),
})
.merge(carouselCardsRefsSchema)
const campaignPageAccordionRefs = z
.object({
__typename: z.literal(CampaignPageEnum.ContentStack.blocks.Accordion),
})
.merge(accordionRefsSchema)
const campaignPageBlockRefsItem = z.discriminatedUnion("__typename", [
campaignPageCarouselCardsRef,
campaignPageAccordionRefs,
])
const heroRefsSchema = z.object({
button: linkConnectionRefs,
})
export const campaignPageRefsSchema = z.object({
campaign_page: z.object({
hero: heroRefsSchema,
blocks: discriminatedUnionArray(
campaignPageBlockRefsItem.options
).nullable(),
system: systemSchema,
}),
})

View File

@@ -1,126 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentStackUidWithServiceProcedure } from "@scandic-hotels/trpc/procedures"
import {
GetCampaignPage,
GetCampaignPageRefs,
} from "@/lib/graphql/Query/CampaignPage/CampaignPage.graphql"
import { request } from "@/lib/graphql/request"
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

@@ -1,45 +0,0 @@
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
import type { Lang } from "@scandic-hotels/common/constants/language"
import { CampaignPageEnum } from "@/types/enums/campaignPage"
import type { System } from "@/types/requests/system"
import type { CampaignPageRefs } from "@/types/trpc/routers/contentstack/campaignPage"
export function generatePageTags(
validatedData: CampaignPageRefs,
lang: Lang
): string[] {
const connections = getConnections(validatedData)
return [
generateTagsFromSystem(lang, connections),
generateTag(lang, validatedData.campaign_page.system.uid),
].flat()
}
export function getConnections({ campaign_page }: CampaignPageRefs) {
const connections: System["system"][] = [campaign_page.system]
if (campaign_page.blocks) {
campaign_page.blocks.forEach((block) => {
switch (block.__typename) {
case CampaignPageEnum.ContentStack.blocks.CarouselCards: {
block.carousel_cards.card_groups.forEach((group) => {
group.cardConnection.edges.forEach(({ node }) => {
connections.push(node.system)
})
})
break
}
case CampaignPageEnum.ContentStack.blocks.Accordion: {
if (block.accordion.length) {
connections.push(...block.accordion)
}
break
}
}
})
}
return connections
}

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { collectionPageQueryRouter } from "./query"
export const collectionPageRouter = mergeRouters(collectionPageQueryRouter)

View File

@@ -1,166 +0,0 @@
import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import {
cardGridRefsSchema,
cardsGridSchema,
} from "../schemas/blocks/cardsGrid"
import {
dynamicContentRefsSchema,
dynamicContentSchema as blockDynamicContentSchema,
} from "../schemas/blocks/dynamicContent"
import {
shortcutsRefsSchema,
shortcutsSchema,
} from "../schemas/blocks/shortcuts"
import { uspGridRefsSchema, uspGridSchema } from "../schemas/blocks/uspGrid"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import {
linkAndTitleSchema,
linkConnectionRefs,
} from "../schemas/linkConnection"
import { systemSchema } from "../schemas/system"
import { CollectionPageEnum } from "@/types/enums/collectionPage"
// Block schemas
export const collectionPageCards = z
.object({
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.CardsGrid),
})
.merge(cardsGridSchema)
export const collectionPageShortcuts = z
.object({
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.Shortcuts),
})
.merge(shortcutsSchema)
export const collectionPageUspGrid = z
.object({
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.UspGrid),
})
.merge(uspGridSchema)
export const collectionPageDynamicContent = z
.object({
__typename: z.literal(
CollectionPageEnum.ContentStack.blocks.DynamicContent
),
})
.merge(blockDynamicContentSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [
collectionPageCards,
collectionPageDynamicContent,
collectionPageShortcuts,
collectionPageUspGrid,
])
const navigationLinksSchema = z
.array(linkAndTitleSchema)
.nullable()
.transform((data) => {
if (!data) {
return null
}
return data
.filter((item) => !!item.link)
.map((item) => ({
url: item.link!.url,
title: item.title || item.link!.title,
}))
})
const topPrimaryButtonSchema = linkAndTitleSchema
.nullable()
.transform((data) => {
if (!data?.link) {
return null
}
return {
url: data.link.url,
title: data.title || data.link.title || null,
}
})
// Content Page Schema and types
export const collectionPageSchema = z.object({
collection_page: z.object({
hero_image: tempImageVaultAssetSchema,
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
title: z.string(),
header: z.object({
heading: z.string(),
preamble: z.string(),
top_primary_button: topPrimaryButtonSchema,
navigation_links: navigationLinksSchema,
}),
meeting_package: z
.object({
show_widget: z.boolean(),
location: z.string(),
})
.nullable(),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
}),
trackingProps: z.object({
url: z.string(),
}),
})
/** REFS */
const collectionPageCardsRefs = z
.object({
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.CardsGrid),
})
.merge(cardGridRefsSchema)
const collectionPageShortcutsRefs = z
.object({
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.Shortcuts),
})
.merge(shortcutsRefsSchema)
const collectionPageUspGridRefs = z
.object({
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.UspGrid),
})
.merge(uspGridRefsSchema)
const contentPageDynamicContentRefs = z
.object({
__typename: z.literal(
CollectionPageEnum.ContentStack.blocks.DynamicContent
),
})
.merge(dynamicContentRefsSchema)
const collectionPageBlockRefsItem = z.discriminatedUnion("__typename", [
collectionPageShortcutsRefs,
contentPageDynamicContentRefs,
collectionPageCardsRefs,
collectionPageUspGridRefs,
])
const collectionPageHeaderRefs = z.object({
navigation_links: z.array(linkConnectionRefs),
top_primary_button: linkConnectionRefs.nullable(),
})
export const collectionPageRefsSchema = z.object({
collection_page: z.object({
header: collectionPageHeaderRefs,
blocks: discriminatedUnionArray(
collectionPageBlockRefsItem.options
).nullable(),
system: systemSchema,
}),
})

View File

@@ -1,82 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { contentstackExtendedProcedureUID } from "@scandic-hotels/trpc/procedures"
import { GetCollectionPage } from "@/lib/graphql/Query/CollectionPage/CollectionPage.graphql"
import { request } from "@/lib/graphql/request"
import { collectionPageSchema } from "./output"
import {
fetchCollectionPageRefs,
generatePageTags,
validateCollectionPageRefs,
} from "./utils"
import {
TrackingChannelEnum,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type { GetCollectionPageSchema } from "@/types/trpc/routers/contentstack/collectionPage"
export const collectionPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const { lang, uid } = ctx
const collectionPageRefsData = await fetchCollectionPageRefs(lang, uid)
const collectionPageRefs = validateCollectionPageRefs(
collectionPageRefsData,
lang,
uid
)
if (!collectionPageRefs) {
return null
}
const tags = generatePageTags(collectionPageRefs, lang)
const getCollectionPageCounter = createCounter(
"trpc.contentstack",
"collectionPage.get"
)
const metricsGetCollectionPage = getCollectionPageCounter.init({
lang,
uid,
})
metricsGetCollectionPage.start()
const response = await request<GetCollectionPageSchema>(
GetCollectionPage,
{ locale: lang, uid },
{
key: tags,
ttl: "max",
}
)
const collectionPage = collectionPageSchema.safeParse(response.data)
if (!collectionPage.success) {
metricsGetCollectionPage.validationError(collectionPage.error)
return null
}
metricsGetCollectionPage.success()
const tracking: TrackingSDKPageData = {
pageId: collectionPage.data.collection_page.system.uid,
domainLanguage: lang,
publishDate: collectionPage.data.collection_page.system.updated_at,
createDate: collectionPage.data.collection_page.system.created_at,
channel: TrackingChannelEnum["collection-page"],
pageType: "collectionpage",
pageName: collectionPage.data.trackingProps.url,
siteSections: collectionPage.data.trackingProps.url,
siteVersion: "new-web",
}
return {
collectionPage: collectionPage.data.collection_page,
tracking,
}
}),
})

View File

@@ -1,122 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { notFound } from "@scandic-hotels/trpc/errors"
import { GetCollectionPageRefs } from "@/lib/graphql/Query/CollectionPage/CollectionPage.graphql"
import { request } from "@/lib/graphql/request"
import {
generateRefsResponseTag,
generateTag,
generateTagsFromSystem,
} from "@/utils/generateTag"
import { collectionPageRefsSchema } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import { CollectionPageEnum } from "@/types/enums/collectionPage"
import type { System } from "@/types/requests/system"
import type {
CollectionPageRefs,
GetCollectionPageRefsSchema,
} from "@/types/trpc/routers/contentstack/collectionPage"
export async function fetchCollectionPageRefs(lang: Lang, uid: string) {
const getCollectionPageRefsCounter = createCounter(
"trpc.contentstack",
"collectionPage.get.refs"
)
const metricsGetCollectionPageRefs = getCollectionPageRefsCounter.init({
lang,
uid,
})
metricsGetCollectionPageRefs.start()
const cacheKey = generateRefsResponseTag(lang, uid)
const refsResponse = await request<GetCollectionPageRefsSchema>(
GetCollectionPageRefs,
{
locale: lang,
uid,
},
{
key: cacheKey,
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
metricsGetCollectionPageRefs.noDataError()
throw notFoundError
}
return refsResponse.data
}
export function validateCollectionPageRefs(
data: GetCollectionPageRefsSchema,
lang: Lang,
uid: string
) {
const getCollectionPageRefsCounter = createCounter(
"trpc.contentstack",
"collectionPage.get.refs"
)
const metricsGetCollectionPageRefs = getCollectionPageRefsCounter.init({
lang,
uid,
})
const validatedData = collectionPageRefsSchema.safeParse(data)
if (!validatedData.success) {
metricsGetCollectionPageRefs.validationError(validatedData.error)
return null
}
metricsGetCollectionPageRefs.success()
return validatedData.data
}
export function generatePageTags(
validatedData: CollectionPageRefs,
lang: Lang
): string[] {
const connections = getConnections(validatedData)
return [
generateTagsFromSystem(lang, connections),
generateTag(lang, validatedData.collection_page.system.uid),
].flat()
}
export function getConnections({ collection_page }: CollectionPageRefs) {
const connections: System["system"][] = [collection_page.system]
if (collection_page.blocks) {
collection_page.blocks.forEach((block) => {
switch (block.__typename) {
case CollectionPageEnum.ContentStack.blocks.Shortcuts: {
if (block.shortcuts.shortcuts.length) {
connections.push(...block.shortcuts.shortcuts)
}
break
}
case CollectionPageEnum.ContentStack.blocks.CardsGrid: {
if (block.cards_grid.length) {
connections.push(...block.cards_grid)
}
break
}
case CollectionPageEnum.ContentStack.blocks.UspGrid: {
if (block.usp_grid.length) {
connections.push(...block.usp_grid)
}
}
}
})
}
return connections
}

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { contentPageQueryRouter } from "./query"
export const contentPageRouter = mergeRouters(contentPageQueryRouter)

View File

@@ -1,347 +0,0 @@
import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import {
accordionRefsSchema,
accordionSchema,
} from "../schemas/blocks/accordion"
import {
cardGridRefsSchema,
cardsGridSchema,
} from "../schemas/blocks/cardsGrid"
import {
contentRefsSchema as blockContentRefsSchema,
contentSchema as blockContentSchema,
} from "../schemas/blocks/content"
import {
dynamicContentRefsSchema,
dynamicContentSchema as blockDynamicContentSchema,
} from "../schemas/blocks/dynamicContent"
import { contentPageHotelListingSchema } from "../schemas/blocks/hotelListing"
import {
shortcutsRefsSchema,
shortcutsSchema,
} from "../schemas/blocks/shortcuts"
import { tableSchema } from "../schemas/blocks/table"
import { textColsRefsSchema, textColsSchema } from "../schemas/blocks/textCols"
import { uspGridRefsSchema, uspGridSchema } from "../schemas/blocks/uspGrid"
import {
dynamicContentRefsSchema as headerDynamicContentRefsSchema,
dynamicContentSchema as headerDynamicContentSchema,
} from "../schemas/headers/dynamicContent"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import {
linkAndTitleSchema,
linkConnectionRefs,
} from "../schemas/linkConnection"
import {
contentRefsSchema as sidebarContentRefsSchema,
contentSchema as sidebarContentSchema,
} from "../schemas/sidebar/content"
import { dynamicContentSchema as sidebarDynamicContentSchema } from "../schemas/sidebar/dynamicContent"
import {
joinLoyaltyContactRefsSchema,
joinLoyaltyContactSchema,
} from "../schemas/sidebar/joinLoyaltyContact"
import {
quickLinksRefschema,
quickLinksSchema,
} from "../schemas/sidebar/quickLinks"
import {
scriptedCardRefschema,
scriptedCardsSchema,
} from "../schemas/sidebar/scriptedCard"
import {
teaserCardRefschema,
teaserCardsSchema,
} from "../schemas/sidebar/teaserCard"
import { systemSchema } from "../schemas/system"
import { ContentPageEnum } from "@/types/enums/contentPage"
// Block schemas
export const contentPageCards = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.CardsGrid),
})
.merge(cardsGridSchema)
export const contentPageContent = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Content),
})
.merge(blockContentSchema)
export const contentPageDynamicContent = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.DynamicContent),
})
.merge(blockDynamicContentSchema)
export const contentPageShortcuts = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Shortcuts),
})
.merge(shortcutsSchema)
export const contentPageTextCols = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.TextCols),
})
.merge(textColsSchema)
export const contentPageUspGrid = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.UspGrid),
})
.merge(uspGridSchema)
export const contentPageTable = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Table),
})
.merge(tableSchema)
export const contentPageAccordion = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Accordion),
})
.merge(accordionSchema)
export const contentPageHotelListing = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.HotelListing),
})
.merge(contentPageHotelListingSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [
contentPageAccordion,
contentPageCards,
contentPageContent,
contentPageDynamicContent,
contentPageShortcuts,
contentPageTable,
contentPageTextCols,
contentPageUspGrid,
contentPageHotelListing,
])
export const contentPageSidebarContent = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.Content),
})
.merge(sidebarContentSchema)
export const contentPageSidebarDynamicContent = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.DynamicContent),
})
.merge(sidebarDynamicContentSchema)
export const contentPageJoinLoyaltyContact = z
.object({
__typename: z.literal(
ContentPageEnum.ContentStack.sidebar.JoinLoyaltyContact
),
})
.merge(joinLoyaltyContactSchema)
export const contentPageSidebarScriptedCard = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.ScriptedCard),
})
.merge(scriptedCardsSchema)
export const contentPageSidebarTeaserCard = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.TeaserCard),
})
.merge(teaserCardsSchema)
export const contentPageSidebarQuicklinks = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.QuickLinks),
})
.merge(quickLinksSchema)
export const sidebarSchema = z.discriminatedUnion("__typename", [
contentPageSidebarContent,
contentPageSidebarDynamicContent,
contentPageJoinLoyaltyContact,
contentPageSidebarScriptedCard,
contentPageSidebarTeaserCard,
contentPageSidebarQuicklinks,
])
const navigationLinksSchema = z
.array(linkAndTitleSchema)
.nullable()
.transform((data) => {
if (!data) {
return null
}
return data
.filter((item) => !!item.link)
.map((item) => ({
url: item.link!.url,
title: item.title || item.link!.title,
}))
})
const topPrimaryButtonSchema = linkAndTitleSchema
.nullable()
.transform((data) => {
if (!data?.link) {
return null
}
return {
url: data.link.url,
title: data.title || data.link.title || null,
}
})
// Content Page Schema and types
export const contentPageSchema = z.object({
content_page: z.object({
hero_image: tempImageVaultAssetSchema,
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
sidebar: discriminatedUnionArray(sidebarSchema.options).nullable(),
title: z.string(),
header: z.object({
heading: z.string(),
preamble: z.string(),
top_primary_button: topPrimaryButtonSchema,
navigation_links: navigationLinksSchema,
dynamic_content: headerDynamicContentSchema.nullish(),
}),
meeting_package: z
.object({
show_widget: z.boolean(),
location: z.string(),
})
.nullable(),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
}),
trackingProps: z.object({
url: z.string(),
}),
})
/** REFS */
const contentPageCardsRefs = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.CardsGrid),
})
.merge(cardGridRefsSchema)
const contentPageBlockContentRefs = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Content),
})
.merge(blockContentRefsSchema)
const contentPageDynamicContentRefs = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.DynamicContent),
})
.merge(dynamicContentRefsSchema)
const contentPageShortcutsRefs = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Shortcuts),
})
.merge(shortcutsRefsSchema)
const contentPageTextColsRefs = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.TextCols),
})
.merge(textColsRefsSchema)
const contentPageUspGridRefs = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.UspGrid),
})
.merge(uspGridRefsSchema)
const contentPageAccordionRefs = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Accordion),
})
.merge(accordionRefsSchema)
const contentPageBlockRefsItem = z.discriminatedUnion("__typename", [
contentPageAccordionRefs,
contentPageBlockContentRefs,
contentPageShortcutsRefs,
contentPageCardsRefs,
contentPageDynamicContentRefs,
contentPageTextColsRefs,
contentPageUspGridRefs,
])
const contentPageSidebarContentRef = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.Content),
})
.merge(sidebarContentRefsSchema)
const contentPageSidebarJoinLoyaltyContactRef = z
.object({
__typename: z.literal(
ContentPageEnum.ContentStack.sidebar.JoinLoyaltyContact
),
})
.merge(joinLoyaltyContactRefsSchema)
const contentPageSidebarScriptedCardRef = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.ScriptedCard),
})
.merge(scriptedCardRefschema)
const contentPageSidebarTeaserCardRef = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.TeaserCard),
})
.merge(teaserCardRefschema)
const contentPageSidebarQuickLinksRef = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.QuickLinks),
})
.merge(quickLinksRefschema)
const contentPageSidebarRefsItem = z.discriminatedUnion("__typename", [
contentPageSidebarContentRef,
contentPageSidebarJoinLoyaltyContactRef,
contentPageSidebarScriptedCardRef,
contentPageSidebarTeaserCardRef,
contentPageSidebarQuickLinksRef,
])
const contentPageHeaderRefs = z.object({
navigation_links: z.array(linkConnectionRefs),
top_primary_button: linkConnectionRefs.nullable(),
dynamic_content: headerDynamicContentRefsSchema.nullish(),
})
export const contentPageRefsSchema = z.object({
content_page: z.object({
header: contentPageHeaderRefs,
blocks: discriminatedUnionArray(
contentPageBlockRefsItem.options
).nullable(),
sidebar: discriminatedUnionArray(
contentPageSidebarRefsItem.options
).nullable(),
system: systemSchema,
}),
})

View File

@@ -1,100 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { contentstackExtendedProcedureUID } from "@scandic-hotels/trpc/procedures"
import { batchRequest } from "@/lib/graphql/batchRequest"
import {
GetContentPage,
GetContentPageBlocksBatch1,
GetContentPageBlocksBatch2,
} from "@/lib/graphql/Query/ContentPage/ContentPage.graphql"
import { contentPageSchema } from "./output"
import {
createChannel,
createPageType,
fetchContentPageRefs,
generatePageTags,
} from "./utils"
import type { TrackingSDKPageData } from "@/types/components/tracking"
import type { GetContentPageSchema } from "@/types/trpc/routers/contentstack/contentPage"
export const contentPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const { lang, uid } = ctx
const contentPageRefs = await fetchContentPageRefs(lang, uid)
if (!contentPageRefs) {
return null
}
const tags = generatePageTags(contentPageRefs, lang)
const getContentPageCounter = createCounter(
"trpc.contentstack",
"contentPage.get"
)
const metricsGetContentPage = getContentPageCounter.init({
lang,
uid,
})
metricsGetContentPage.start()
const contentPageRequest = await batchRequest<GetContentPageSchema>([
{
document: GetContentPage,
variables: { locale: lang, uid },
cacheOptions: {
key: `${tags.join(",")}:contentPage`,
ttl: "max",
},
},
{
document: GetContentPageBlocksBatch1,
variables: { locale: lang, uid },
cacheOptions: {
key: `${tags.join(",")}:contentPageBlocksBatch1`,
ttl: "max",
},
},
{
document: GetContentPageBlocksBatch2,
variables: { locale: lang, uid },
cacheOptions: {
key: `${tags.join(",")}:contentPageBlocksBatch2`,
ttl: "max",
},
},
])
const contentPage = contentPageSchema.safeParse(contentPageRequest.data)
if (!contentPage.success) {
metricsGetContentPage.validationError(contentPage.error)
return null
}
metricsGetContentPage.success()
const tracking: TrackingSDKPageData = {
pageId: contentPage.data.content_page.system.uid,
domainLanguage: lang,
publishDate: contentPage.data.content_page.system.updated_at,
createDate: contentPage.data.content_page.system.created_at,
channel: createChannel(contentPage.data.content_page.system.uid),
pageType: createPageType(contentPage.data.content_page.system.uid),
pageName: contentPage.data.trackingProps.url,
siteSections: contentPage.data.trackingProps.url,
siteVersion: "new-web",
}
return {
contentPage: contentPage.data.content_page,
tracking,
}
}),
})

View File

@@ -1,204 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { notFound } from "@scandic-hotels/trpc/errors"
import { batchRequest } from "@/lib/graphql/batchRequest"
import {
GetContentPageBlocksRefs,
GetContentPageRefs,
} from "@/lib/graphql/Query/ContentPage/ContentPage.graphql"
import {
generateRefsResponseTag,
generateTag,
generateTagsFromSystem,
} from "@/utils/generateTag"
import { contentPageRefsSchema } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import { TrackingChannelEnum } from "@/types/components/tracking"
import { ContentPageEnum } from "@/types/enums/contentPage"
import type { System } from "@/types/requests/system"
import {
type ContentPageRefs,
type GetContentPageRefsSchema,
} from "@/types/trpc/routers/contentstack/contentPage"
export async function fetchContentPageRefs(lang: Lang, uid: string) {
const getContentPageRefsCounter = createCounter(
"trpc.contentstack",
"contentPage.get.refs"
)
const metricsGetContentPageRefs = getContentPageRefsCounter.init({
lang,
uid,
})
metricsGetContentPageRefs.start()
const res = await batchRequest<GetContentPageRefsSchema>([
{
document: GetContentPageRefs,
variables: { locale: lang, uid },
cacheOptions: {
key: generateRefsResponseTag(lang, uid),
ttl: "max",
},
},
{
document: GetContentPageBlocksRefs,
variables: { locale: lang, uid },
cacheOptions: {
key: generateTag(lang, uid + 1),
ttl: "max",
},
},
])
if (!res.data) {
const notFoundError = notFound(res)
metricsGetContentPageRefs.noDataError()
throw notFoundError
}
const validatedData = contentPageRefsSchema.safeParse(res.data)
if (!validatedData.success) {
metricsGetContentPageRefs.validationError(validatedData.error)
return null
}
metricsGetContentPageRefs.success()
return validatedData.data
}
export function generatePageTags(
validatedData: ContentPageRefs,
lang: Lang
): string[] {
const connections = getConnections(validatedData)
return [
generateTagsFromSystem(lang, connections),
generateTag(lang, validatedData.content_page.system.uid),
].flat()
}
export function getConnections({ content_page }: ContentPageRefs) {
const connections: System["system"][] = [content_page.system]
if (content_page.blocks) {
content_page.blocks.forEach((block) => {
switch (block.__typename) {
case ContentPageEnum.ContentStack.blocks.Accordion: {
if (block.accordion.length) {
connections.push(...block.accordion)
}
break
}
case ContentPageEnum.ContentStack.blocks.Content:
{
if (block.content.length) {
connections.push(...block.content)
}
}
break
case ContentPageEnum.ContentStack.blocks.CardsGrid: {
if (block.cards_grid.length) {
connections.push(...block.cards_grid)
}
break
}
case ContentPageEnum.ContentStack.blocks.DynamicContent: {
if (block.dynamic_content.link) {
connections.push(block.dynamic_content.link)
}
break
}
case ContentPageEnum.ContentStack.blocks.Shortcuts: {
if (block.shortcuts.shortcuts.length) {
connections.push(...block.shortcuts.shortcuts)
}
break
}
case ContentPageEnum.ContentStack.blocks.TextCols: {
if (block.text_cols.length) {
connections.push(...block.text_cols)
}
break
}
case ContentPageEnum.ContentStack.blocks.UspGrid: {
if (block.usp_grid.length) {
connections.push(...block.usp_grid)
}
break
}
case ContentPageEnum.ContentStack.blocks.CardsGrid: {
if (block.cards_grid.length) {
block.cards_grid.forEach((card) => {
connections.push(card)
})
}
break
}
}
})
}
if (content_page.sidebar) {
content_page.sidebar.forEach((block) => {
switch (block.__typename) {
case ContentPageEnum.ContentStack.sidebar.Content:
if (block.content.length) {
connections.push(...block.content)
}
break
case ContentPageEnum.ContentStack.sidebar.JoinLoyaltyContact:
if (block.join_loyalty_contact?.button) {
connections.push(block.join_loyalty_contact.button)
}
break
case ContentPageEnum.ContentStack.sidebar.ScriptedCard:
if (block.scripted_card?.length) {
connections.push(...block.scripted_card)
}
break
case ContentPageEnum.ContentStack.sidebar.TeaserCard:
if (block.teaser_card?.length) {
connections.push(...block.teaser_card)
}
break
case ContentPageEnum.ContentStack.sidebar.QuickLinks:
if (block.shortcuts.shortcuts.length) {
connections.push(...block.shortcuts.shortcuts)
}
break
default:
break
}
})
}
return connections
}
const signupContentPageUid = "blt0e6bd6c4d7224f07"
const signupVerifyContentPageUid = "blt3247a2a29b34a8e8"
export function createPageType(uid: string): string {
switch (uid) {
case signupContentPageUid:
return "memberprofilecreatepage"
case signupVerifyContentPageUid:
return "memberprofilecreatesuccesspage"
default:
return "staticcontentpage"
}
}
export function createChannel(uid: string): TrackingChannelEnum {
switch (uid) {
case signupContentPageUid:
case signupVerifyContentPageUid:
return TrackingChannelEnum["scandic-friends"]
default:
return TrackingChannelEnum["static-content-page"]
}
}

View File

@@ -1,7 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { destinationCityPageQueryRouter } from "./query"
export const destinationCityPageRouter = mergeRouters(
destinationCityPageQueryRouter
)

View File

@@ -1,264 +0,0 @@
import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import { isDefined } from "@/server/utils"
import { removeMultipleSlashes } from "@/utils/url"
import {
accordionRefsSchema,
accordionSchema,
} from "../schemas/blocks/accordion"
import { contentRefsSchema, contentSchema } from "../schemas/blocks/content"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import { mapLocationSchema } from "../schemas/mapLocation"
import {
linkRefsUnionSchema,
linkUnionSchema,
transformPageLink,
} from "../schemas/pageLinks"
import { systemSchema } from "../schemas/system"
import type { ImageVaultAsset } from "@/types/components/imageVault"
import { DestinationCityPageEnum } from "@/types/enums/destinationCityPage"
const destinationCityPageDestinationSettingsSchema = z
.object({
city_denmark: z.string().optional().nullable(),
city_finland: z.string().optional().nullable(),
city_germany: z.string().optional().nullable(),
city_poland: z.string().optional().nullable(),
city_norway: z.string().optional().nullable(),
city_sweden: z.string().optional().nullable(),
location: mapLocationSchema,
})
.transform(
({
city_denmark,
city_finland,
city_germany,
city_norway,
city_poland,
city_sweden,
location,
}) => {
return {
city:
city_denmark ||
city_finland ||
city_germany ||
city_poland ||
city_norway ||
city_sweden,
location,
}
}
)
export const destinationCityListDataSchema = z
.object({
all_destination_city_page: z.object({
items: z.array(
z
.object({
heading: z.string(),
destination_settings: destinationCityPageDestinationSettingsSchema,
sort_order: z.number().nullable(),
preamble: z.string(),
experiences: z
.object({
destination_experiences: z.array(z.string()),
})
.transform(
({ destination_experiences }) => destination_experiences
)
.nullish(),
images: z
.array(z.object({ image: tempImageVaultAssetSchema }))
.transform((images) =>
images
.map((image) => image.image)
.filter((image): image is ImageVaultAsset => !!image)
)
.nullish(),
url: z.string(),
system: systemSchema,
})
.transform((data) => {
return {
...data,
url: removeMultipleSlashes(`/${data.system.locale}/${data.url}`),
}
})
),
}),
})
.transform(
({ all_destination_city_page }) => all_destination_city_page.items?.[0]
)
export const destinationCityPageContent = z
.object({
__typename: z.literal(DestinationCityPageEnum.ContentStack.blocks.Content),
})
.merge(contentSchema)
export const destinationCityPageAccordion = z
.object({
__typename: z.literal(
DestinationCityPageEnum.ContentStack.blocks.Accordion
),
})
.merge(accordionSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [
destinationCityPageAccordion,
destinationCityPageContent,
])
export const destinationCityPageSchema = z.object({
destination_city_page: z.object({
title: z.string(),
destination_settings: destinationCityPageDestinationSettingsSchema,
heading: z.string(),
preamble: z.string(),
experiences: z
.object({
destination_experiences: z.array(z.string()),
})
.nullish()
.transform((experiences) => experiences?.destination_experiences ?? []),
images: z
.array(z.object({ image: tempImageVaultAssetSchema }))
.transform((images) =>
images
.map((image) => image.image)
.filter((image): image is ImageVaultAsset => !!image)
)
.nullish(),
has_sidepeek: z.boolean().default(false),
sidepeek_button_text: z.string().nullish().default(""),
sidepeek_content: z
.object({
heading: z.string(),
content: z.object({
json: z.any(),
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkUnionSchema.transform((data) => {
const link = transformPageLink(data)
if (link) {
return link
}
return data
}),
})
),
}),
}),
})
.nullish(),
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
}),
trackingProps: z.object({
url: z.string(),
}),
})
export const cityPageCountSchema = z
.object({
all_destination_city_page: z.object({
total: z.number(),
}),
})
.transform(({ all_destination_city_page }) => all_destination_city_page.total)
export const cityPageUrlsSchema = z
.object({
all_destination_city_page: z.object({
items: z
.array(
z
.object({
url: z.string().nullish(),
destination_settings:
destinationCityPageDestinationSettingsSchema,
system: systemSchema,
})
.transform((data) => {
if (!data.destination_settings.city || !data.url) {
return null
}
return {
city: data.destination_settings.city,
url: removeMultipleSlashes(
`/${data.system.locale}/${data.url}`
),
}
})
)
.transform((data) => {
return data.filter(isDefined)
}),
}),
})
.transform(({ all_destination_city_page }) => all_destination_city_page.items)
export const batchedCityPageUrlsSchema = z
.array(
z.object({
data: cityPageUrlsSchema,
})
)
.transform((allItems) => {
return allItems.flatMap((item) => item.data)
})
/** REFS */
const destinationCityPageContentRefs = z
.object({
__typename: z.literal(DestinationCityPageEnum.ContentStack.blocks.Content),
})
.merge(contentRefsSchema)
const destinationCityPageAccordionRefs = z
.object({
__typename: z.literal(
DestinationCityPageEnum.ContentStack.blocks.Accordion
),
})
.merge(accordionRefsSchema)
const blocksRefsSchema = z.discriminatedUnion("__typename", [
destinationCityPageAccordionRefs,
destinationCityPageContentRefs,
])
export const destinationCityPageRefsSchema = z.object({
destination_city_page: z.object({
destination_settings: destinationCityPageDestinationSettingsSchema,
sidepeek_content: z
.object({
content: z.object({
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
}),
})
.nullish(),
blocks: discriminatedUnionArray(blocksRefsSchema.options).nullable(),
system: systemSchema,
}),
})

View File

@@ -1,151 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentStackUidWithServiceProcedure } from "@scandic-hotels/trpc/procedures"
import {
GetDestinationCityPage,
GetDestinationCityPageRefs,
} from "@/lib/graphql/Query/DestinationCityPage/DestinationCityPage.graphql"
import { request } from "@/lib/graphql/request"
import { generateRefsResponseTag } from "@/utils/generateTag"
import { getCityByCityIdentifier } from "../../hotels/utils"
import {
destinationCityPageRefsSchema,
destinationCityPageSchema,
} from "./output"
import { generatePageTags } from "./utils"
import {
TrackingChannelEnum,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type {
GetDestinationCityPageData,
GetDestinationCityPageRefsSchema,
} from "@/types/trpc/routers/contentstack/destinationCityPage"
export const destinationCityPageQueryRouter = router({
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
const { lang, uid, serviceToken } = ctx
const getDestinationCityPageRefsCounter = createCounter(
"trpc.contentstack",
"destinationCityPage.get.refs"
)
const metricsGetDestinationCityPageRefs =
getDestinationCityPageRefsCounter.init({ lang, uid })
metricsGetDestinationCityPageRefs.start()
const refsResponse = await request<GetDestinationCityPageRefsSchema>(
GetDestinationCityPageRefs,
{ locale: lang, uid },
{
key: generateRefsResponseTag(lang, uid),
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
metricsGetDestinationCityPageRefs.noDataError()
throw notFoundError
}
const validatedRefsData = destinationCityPageRefsSchema.safeParse(
refsResponse.data
)
if (!validatedRefsData.success) {
metricsGetDestinationCityPageRefs.validationError(validatedRefsData.error)
return null
}
metricsGetDestinationCityPageRefs.success()
const tags = generatePageTags(validatedRefsData.data, lang)
const getDestinationCityPageCounter = createCounter(
"trpc.contentstack",
"destinationCityPage.get"
)
const metricsGetDestinationCityPage = getDestinationCityPageCounter.init({
lang,
uid,
})
metricsGetDestinationCityPage.start()
const response = await request<GetDestinationCityPageData>(
GetDestinationCityPage,
{
locale: lang,
uid,
},
{
key: tags,
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetDestinationCityPage.noDataError()
throw notFoundError
}
const validatedResponse = destinationCityPageSchema.safeParse(response.data)
if (!validatedResponse.success) {
metricsGetDestinationCityPage.validationError(validatedResponse.error)
return null
}
const destinationCityPage = validatedResponse.data.destination_city_page
const cityIdentifier = destinationCityPage.destination_settings.city
if (!cityIdentifier) {
return null
}
const city = await getCityByCityIdentifier({
cityIdentifier,
lang,
serviceToken,
})
if (!city) {
metricsGetDestinationCityPage.dataError(
`Failed to get city data for ${cityIdentifier}`,
{
cityIdentifier,
}
)
return null
}
metricsGetDestinationCityPage.success()
const system = destinationCityPage.system
const pageName = `destinations|${city.country}|${city.name}`
const tracking: TrackingSDKPageData = {
pageId: system.uid,
domainLanguage: system.locale,
publishDate: system.updated_at,
createDate: system.created_at,
channel: TrackingChannelEnum.hotels,
pageType: "citypage",
pageName,
siteSections: pageName,
siteVersion: "new-web",
}
return {
destinationCityPage,
cityIdentifier,
city,
tracking,
}
}),
})

View File

@@ -1,153 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { GetCityPageCount } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityPageCount.graphql"
import { GetCityPageUrls } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityPageUrl.graphql"
import { request } from "@/lib/graphql/request"
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
import { batchedCityPageUrlsSchema, cityPageCountSchema } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import { DestinationCityPageEnum } from "@/types/enums/destinationCityPage"
import type { System } from "@/types/requests/system"
import type {
DestinationCityPageRefs,
GetCityPageCountData,
GetCityPageUrlsData,
} from "@/types/trpc/routers/contentstack/destinationCityPage"
export function generatePageTags(
validatedData: DestinationCityPageRefs,
lang: Lang
): string[] {
const connections = getConnections(validatedData)
return [
// This tag is added for the city list data on country pages to invalidate the list when city page changes.
generateTag(
lang,
`city_list_data:${validatedData.destination_city_page.destination_settings.city}`
),
generateTagsFromSystem(lang, connections),
generateTag(lang, validatedData.destination_city_page.system.uid),
].flat()
}
export function getConnections({
destination_city_page,
}: DestinationCityPageRefs) {
const connections: System["system"][] = [destination_city_page.system]
if (destination_city_page.blocks) {
destination_city_page.blocks.forEach((block) => {
switch (block.__typename) {
case DestinationCityPageEnum.ContentStack.blocks.Accordion: {
if (block.accordion.length) {
connections.push(...block.accordion)
}
break
}
case DestinationCityPageEnum.ContentStack.blocks.Content:
{
if (block.content.length) {
// TS has trouble infering the filtered types
// @ts-ignore
connections.push(...block.content)
}
}
break
}
})
}
if (destination_city_page.sidepeek_content) {
destination_city_page.sidepeek_content.content.embedded_itemsConnection.edges.forEach(
({ node }) => {
connections.push(node.system)
}
)
}
return connections
}
export async function getCityPageCount(lang: Lang) {
const getCityPageCountCounter = createCounter(
"trpc.contentstack",
"cityPageCount.get"
)
const metricsGetCityPageCount = getCityPageCountCounter.init({ lang })
metricsGetCityPageCount.start()
const response = await request<GetCityPageCountData>(
GetCityPageCount,
{
locale: lang,
},
{
key: `${lang}:city_page_count`,
ttl: "max",
}
)
if (!response.data) {
metricsGetCityPageCount.dataError(
`Failed to get city pages count for ${lang}`
)
return 0
}
const validatedResponse = cityPageCountSchema.safeParse(response.data)
if (!validatedResponse.success) {
metricsGetCityPageCount.validationError(validatedResponse.error)
return 0
}
metricsGetCityPageCount.success()
return validatedResponse.data
}
export async function getCityPageUrls(lang: Lang) {
const getCityPageUrlsCounter = createCounter(
"trpc.contentstack",
"cityPageUrls.get"
)
const metricsGetCityPageUrls = getCityPageUrlsCounter.init({ lang })
metricsGetCityPageUrls.start()
const count = await getCityPageCount(lang)
if (count === 0) {
return []
}
// Calculating the amount of requests needed to fetch all pages.
// Contentstack has a limit of 100 items per request.
// So we need to make multiple requests to fetch urls to all pages.
// The `batchRequest` function is not working here, because the arrayMerge is
// used for other purposes.
const amountOfRequests = Math.ceil(count / 100)
const batchedResponse = await Promise.all(
Array.from({ length: amountOfRequests }).map((_, i) =>
request<GetCityPageUrlsData>(
GetCityPageUrls,
{ locale: lang, skip: i * 100 },
{ key: `${lang}:city_page_urls_batch_${i}`, ttl: "max" }
)
)
)
const validatedResponse = batchedCityPageUrlsSchema.safeParse(batchedResponse)
if (!validatedResponse.success) {
metricsGetCityPageUrls.validationError(validatedResponse.error)
return []
}
metricsGetCityPageUrls.success()
return validatedResponse.data
}

View File

@@ -1,7 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { destinationCountryPageQueryRouter } from "./query"
export const destinationCountryPageRouter = mergeRouters(
destinationCountryPageQueryRouter
)

View File

@@ -1,7 +0,0 @@
import { z } from "zod"
import { Country } from "@/types/enums/country"
export const getCityPagesInput = z.object({
country: z.nativeEnum(Country),
})

View File

@@ -1,166 +0,0 @@
import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import { removeMultipleSlashes } from "@/utils/url"
import {
accordionRefsSchema,
accordionSchema,
} from "../schemas/blocks/accordion"
import { contentRefsSchema, contentSchema } from "../schemas/blocks/content"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import { mapLocationSchema } from "../schemas/mapLocation"
import {
linkRefsUnionSchema,
linkUnionSchema,
transformPageLink,
} from "../schemas/pageLinks"
import { systemSchema } from "../schemas/system"
import type { ImageVaultAsset } from "@/types/components/imageVault"
import { Country } from "@/types/enums/country"
import { DestinationCountryPageEnum } from "@/types/enums/destinationCountryPage"
export const destinationCountryPageContent = z
.object({
__typename: z.literal(
DestinationCountryPageEnum.ContentStack.blocks.Content
),
})
.merge(contentSchema)
export const destinationCountryPageAccordion = z
.object({
__typename: z.literal(
DestinationCountryPageEnum.ContentStack.blocks.Accordion
),
})
.merge(accordionSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [
destinationCountryPageAccordion,
destinationCountryPageContent,
])
export const destinationCountryPageSchema = z.object({
destination_country_page: z.object({
title: z.string(),
destination_settings: z.object({
country: z.nativeEnum(Country),
location: mapLocationSchema,
}),
heading: z.string(),
preamble: z.string(),
experiences: z
.object({
destination_experiences: z.array(z.string()),
})
.transform(({ destination_experiences }) => destination_experiences),
images: z
.array(z.object({ image: tempImageVaultAssetSchema }))
.transform((images) =>
images
.map((image) => image.image)
.filter((image): image is ImageVaultAsset => !!image)
)
.nullish(),
has_sidepeek: z.boolean().default(false),
sidepeek_button_text: z.string().nullish(),
sidepeek_content: z
.object({
heading: z.string(),
content: z.object({
json: z.any(),
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkUnionSchema.transform((data) => {
const link = transformPageLink(data)
if (link) {
return link
}
return data
}),
})
),
}),
}),
})
.nullish(),
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
}),
trackingProps: z.object({
url: z.string(),
}),
})
export const countryPageUrlsSchema = z
.object({
all_destination_country_page: z.object({
items: z.array(
z
.object({
url: z.string(),
destination_settings: z.object({
country: z.string(),
}),
system: systemSchema,
})
.transform((data) => {
return {
country: data.destination_settings.country,
url: removeMultipleSlashes(`/${data.system.locale}/${data.url}`),
}
})
),
}),
})
.transform(
({ all_destination_country_page }) => all_destination_country_page.items
)
/** REFS */
const destinationCountryPageContentRefs = z
.object({
__typename: z.literal(
DestinationCountryPageEnum.ContentStack.blocks.Content
),
})
.merge(contentRefsSchema)
const destinationCountryPageAccordionRefs = z
.object({
__typename: z.literal(
DestinationCountryPageEnum.ContentStack.blocks.Accordion
),
})
.merge(accordionRefsSchema)
const blocksRefsSchema = z.discriminatedUnion("__typename", [
destinationCountryPageAccordionRefs,
destinationCountryPageContentRefs,
])
export const destinationCountryPageRefsSchema = z.object({
destination_country_page: z.object({
sidepeek_content: z.object({
content: z.object({
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
}),
}),
blocks: discriminatedUnionArray(blocksRefsSchema.options).nullable(),
system: systemSchema,
}),
})

View File

@@ -1,148 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import {
contentStackBaseWithServiceProcedure,
contentstackExtendedProcedureUID,
} from "@scandic-hotels/trpc/procedures"
import {
GetDestinationCountryPage,
GetDestinationCountryPageRefs,
} from "@/lib/graphql/Query/DestinationCountryPage/DestinationCountryPage.graphql"
import { request } from "@/lib/graphql/request"
import { generateRefsResponseTag } from "@/utils/generateTag"
import { getCityPagesInput } from "./input"
import {
destinationCountryPageRefsSchema,
destinationCountryPageSchema,
} from "./output"
import { generatePageTags, getCityPages } from "./utils"
import {
TrackingChannelEnum,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import { ApiCountry } from "@/types/enums/country"
import type {
GetDestinationCountryPageData,
GetDestinationCountryPageRefsSchema,
} from "@/types/trpc/routers/contentstack/destinationCountryPage"
export const destinationCountryPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const { lang, uid } = ctx
const getDestinationCountryPageRefsCounter = createCounter(
"trpc.contentstack",
"destinationCountryPage.get.refs"
)
const metricsGetDestinationCountryPageRefs =
getDestinationCountryPageRefsCounter.init({ lang, uid })
metricsGetDestinationCountryPageRefs.start()
const refsResponse = await request<GetDestinationCountryPageRefsSchema>(
GetDestinationCountryPageRefs,
{ locale: lang, uid },
{
key: generateRefsResponseTag(lang, uid),
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
metricsGetDestinationCountryPageRefs.noDataError()
throw notFoundError
}
const validatedRefsData = destinationCountryPageRefsSchema.safeParse(
refsResponse.data
)
if (!validatedRefsData.success) {
metricsGetDestinationCountryPageRefs.validationError(
validatedRefsData.error
)
return null
}
metricsGetDestinationCountryPageRefs.success()
const tags = generatePageTags(validatedRefsData.data, lang)
const getDestinationCountryPageCounter = createCounter(
"trpc.contentstack",
"destinationCountryPage.get"
)
const metricsGetDestinationCountryPage =
getDestinationCountryPageCounter.init({ lang, uid })
metricsGetDestinationCountryPage.start()
const response = await request<GetDestinationCountryPageData>(
GetDestinationCountryPage,
{
locale: lang,
uid,
},
{
key: tags,
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetDestinationCountryPage.noDataError()
throw notFoundError
}
const validatedResponse = destinationCountryPageSchema.safeParse(
response.data
)
if (!validatedResponse.success) {
metricsGetDestinationCountryPage.validationError(validatedResponse.error)
return null
}
const destinationCountryPage =
validatedResponse.data.destination_country_page
const country = destinationCountryPage.destination_settings.country
metricsGetDestinationCountryPage.success()
const system = destinationCountryPage.system
const pageName = `destinations|${country}`
const tracking: TrackingSDKPageData = {
pageId: system.uid,
domainLanguage: system.locale,
publishDate: system.updated_at,
createDate: system.created_at,
channel: TrackingChannelEnum.hotels,
pageType: "countrypage",
pageName,
siteSections: pageName,
siteVersion: "new-web",
}
return {
destinationCountryPage,
translatedCountry: ApiCountry[lang][country],
tracking,
}
}),
cityPages: contentStackBaseWithServiceProcedure
.input(getCityPagesInput)
.query(async ({ ctx, input }) => {
const { lang, serviceToken } = ctx
const { country } = input
const cities = await getCityPages(lang, serviceToken, country)
return cities
}),
})

View File

@@ -1,192 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { GetDestinationCityListData } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityListData.graphql"
import { GetCountryPageUrls } from "@/lib/graphql/Query/DestinationCountryPage/DestinationCountryPageUrl.graphql"
import { request } from "@/lib/graphql/request"
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
import { getCitiesByCountry } from "../../hotels/utils"
import { destinationCityListDataSchema } from "../destinationCityPage/output"
import { countryPageUrlsSchema } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import { ApiCountry, type Country } from "@/types/enums/country"
import { DestinationCountryPageEnum } from "@/types/enums/destinationCountryPage"
import type { System } from "@/types/requests/system"
import type { GetDestinationCityListDataResponse } from "@/types/trpc/routers/contentstack/destinationCityPage"
import type {
DestinationCountryPageRefs,
GetCountryPageUrlsData,
} from "@/types/trpc/routers/contentstack/destinationCountryPage"
export function generatePageTags(
validatedData: DestinationCountryPageRefs,
lang: Lang
): string[] {
const connections = getConnections(validatedData)
return [
generateTagsFromSystem(lang, connections),
generateTag(lang, validatedData.destination_country_page.system.uid),
].flat()
}
export function getConnections({
destination_country_page,
}: DestinationCountryPageRefs) {
const connections: System["system"][] = [destination_country_page.system]
if (destination_country_page.blocks) {
destination_country_page.blocks.forEach((block) => {
switch (block.__typename) {
case DestinationCountryPageEnum.ContentStack.blocks.Accordion: {
if (block.accordion.length) {
connections.push(...block.accordion)
}
break
}
case DestinationCountryPageEnum.ContentStack.blocks.Content:
{
if (block.content.length) {
// TS has trouble infering the filtered types
// @ts-ignore
connections.push(...block.content)
}
}
break
}
})
}
if (destination_country_page.sidepeek_content) {
destination_country_page.sidepeek_content.content.embedded_itemsConnection.edges.forEach(
({ node }) => {
connections.push(node.system)
}
)
}
return connections
}
export async function getCityListDataByCityIdentifier(
lang: Lang,
cityIdentifier: string
) {
const getCityListDataCounter = createCounter(
"trpc.contentstack",
"cityListData.get"
)
const metricsGetCityListData = getCityListDataCounter.init({
lang,
cityIdentifier,
})
metricsGetCityListData.start()
const response = await request<GetDestinationCityListDataResponse>(
GetDestinationCityListData,
{
locale: lang,
cityIdentifier,
},
{
key: generateTag(lang, `city_list_data:${cityIdentifier}`),
ttl: "max",
}
)
if (!response.data) {
metricsGetCityListData.dataError(
`Failed to get destination city page for cityIdentifier: ${cityIdentifier}`
)
return null
}
const validatedResponse = destinationCityListDataSchema.safeParse(
response.data
)
if (!validatedResponse.success) {
metricsGetCityListData.validationError(validatedResponse.error)
return null
}
metricsGetCityListData.success()
return validatedResponse.data
}
export async function getCityPages(
lang: Lang,
serviceToken: string,
country: Country
) {
const apiCountry = ApiCountry[lang][country]
const cities = await getCitiesByCountry({
countries: [apiCountry],
lang,
serviceToken,
})
const publishedCities = cities[apiCountry].filter((city) => city.isPublished)
const cityPages = await Promise.all(
publishedCities.map(async (city) => {
if (!city.cityIdentifier) {
return null
}
const data = await getCityListDataByCityIdentifier(
lang,
city.cityIdentifier
)
return data ? { ...data, cityName: city.name } : null
})
)
return cityPages
.flat()
.filter((city): city is NonNullable<typeof city> => !!city)
}
export async function getCountryPageUrls(lang: Lang) {
const getCountryPageUrlsCounter = createCounter(
"trpc.contentstack",
"getCountryPageUrls"
)
const metricsGetCountryPageUrls = getCountryPageUrlsCounter.init({ lang })
metricsGetCountryPageUrls.start()
const tag = `${lang}:country_page_urls`
const response = await request<GetCountryPageUrlsData>(
GetCountryPageUrls,
{
locale: lang,
},
{
key: tag,
ttl: "max",
}
)
if (!response.data) {
metricsGetCountryPageUrls.dataError(
`Failed to get country pages for lang: ${lang}`
)
return []
}
const validatedCountryPageUrls = countryPageUrlsSchema.safeParse(
response.data
)
if (!validatedCountryPageUrls.success) {
metricsGetCountryPageUrls.validationError(validatedCountryPageUrls.error)
return []
}
metricsGetCountryPageUrls.success()
return validatedCountryPageUrls.data
}

View File

@@ -1,945 +0,0 @@
[
{
"country": "Danmark",
"countryUrl": "/da/destinationer/danmark",
"numberOfHotels": 27,
"cities": [
{
"id": "20c2ec6a-aedc-4d00-ad67-86a33664b185",
"name": "København",
"hotelIds": [
"715",
"739",
"719",
"749",
"714",
"744",
"731",
"721",
"724",
"727",
"723",
"716",
"718"
],
"hotelCount": 13,
"url": "/da/destinationer/danmark/kobenhavn"
},
{
"id": "4b00f90a-d76f-4aef-9959-f0574e7f3db7",
"name": "Kolding",
"hotelIds": ["737"],
"hotelCount": 1,
"url": "/da/destinationer/danmark/kolding"
},
{
"id": "2713ad8f-4e12-43eb-a11e-26072ae9d7be",
"name": "Esbjerg",
"hotelIds": ["732"],
"hotelCount": 1,
"url": "/da/destinationer/danmark/esbjerg"
},
{
"id": "e343ba54-ef17-424d-ad77-312cabf3d5eb",
"name": "Horsens",
"hotelIds": ["713"],
"hotelCount": 1,
"url": "/da/destinationer/danmark/horsens"
},
{
"id": "1c2557c6-f4fe-4de5-a1bf-db6b2d69e5d7",
"name": "Ringsted",
"hotelIds": ["733"],
"hotelCount": 1,
"url": "/da/destinationer/danmark/ringsted"
},
{
"id": "c785c63d-024b-423a-93cf-bdb66b74fcf1",
"name": "Aalborg",
"hotelIds": ["720", "735"],
"hotelCount": 2,
"url": "/da/destinationer/danmark/aalborg"
},
{
"id": "d3cb1d93-2dbb-4320-b66c-8f79799815df",
"name": "Roskilde",
"hotelIds": ["745"],
"hotelCount": 1,
"url": "/da/destinationer/danmark/roskilde"
},
{
"id": "66a6fa85-31b7-432d-beac-31ee87e555f8",
"name": "Odense",
"hotelIds": ["748"],
"hotelCount": 1,
"url": "/da/destinationer/danmark/odense"
},
{
"id": "31803fa2-8714-44e2-aaf9-8103ba8f68c2",
"name": "Herning",
"hotelIds": ["746"],
"hotelCount": 1,
"url": "/da/destinationer/danmark/herning"
},
{
"id": "b0d93658-79cd-45c8-ab74-675d1df712cc",
"name": "Silkeborg",
"hotelIds": ["747"],
"hotelCount": 1,
"url": "/da/destinationer/danmark/silkeborg"
},
{
"id": "26bdbf43-1888-466f-a805-1e7900b31936",
"name": "Aarhus",
"hotelIds": ["736", "726", "738"],
"hotelCount": 3,
"url": "/da/destinationer/danmark/aarhus"
},
{
"id": "e803306e-53bd-4ddf-9495-1cabe3882291",
"name": "Sønderborg",
"hotelIds": ["728"],
"hotelCount": 1,
"url": "/da/destinationer/danmark/sonderborg"
}
]
},
{
"country": "Finland",
"countryUrl": "/da/destinationer/finland",
"numberOfHotels": 52,
"cities": [
{
"id": "5e6afbbc-c2f4-4506-b770-230f9cab70d1",
"name": "Joensuu",
"hotelIds": ["688"],
"hotelCount": 1,
"url": "/da/destinationer/finland/joensuu"
},
{
"id": "12e3e8c5-2ed4-461d-8a92-92e4612a486e",
"name": "Helsinki",
"hotelIds": [
"638",
"665",
"663",
"622",
"660",
"662",
"661",
"603",
"666",
"605",
"601",
"697",
"634",
"639",
"698",
"643"
],
"hotelCount": 16,
"url": "/da/destinationer/finland/helsinki"
},
{
"id": "92edb3ae-1174-4c77-99bd-de5919abb927",
"name": "Imatra",
"hotelIds": ["696"],
"hotelCount": 1,
"url": "/da/destinationer/finland/imatra"
},
{
"id": "8fea27c8-ab24-4cb5-a8ab-03f422aed8de",
"name": "Hyvinkää",
"hotelIds": ["668"],
"hotelCount": 1,
"url": "/da/destinationer/finland/hyvinkaa"
},
{
"id": "a43392b9-928d-466d-a54f-8c4f50623b0f",
"name": "Kemi",
"hotelIds": ["693"],
"hotelCount": 1,
"url": "/da/destinationer/finland/kemi"
},
{
"id": "4cc21629-4006-4a2a-b3bd-26a1687ca36d",
"name": "Nokia",
"hotelIds": ["679"],
"hotelCount": 1,
"url": "/da/destinationer/finland/nokia"
},
{
"id": "d4513390-9892-4f82-82a1-f26c2c5907e3",
"name": "Kouvola",
"hotelIds": ["672"],
"hotelCount": 1,
"url": "/da/destinationer/finland/kouvola"
},
{
"id": "5438b466-d430-4d95-a1fd-4dd5e091155c",
"name": "Ruka Kuusamo",
"hotelIds": ["691"],
"hotelCount": 1,
"url": "/da/destinationer/finland/ruk-kuusamo"
},
{
"id": "ed1efb68-418c-4ed3-868d-0eaa892a90aa",
"name": "Oulu",
"hotelIds": ["692", "624"],
"hotelCount": 2,
"url": "/da/destinationer/finland/oulu"
},
{
"id": "b8b92fe2-068c-41d5-b60f-2afbbd76e871",
"name": "Hämeenlinna",
"hotelIds": ["669", "670"],
"hotelCount": 2,
"url": "/da/destinationer/finland/hameenlinna"
},
{
"id": "5b68f747-941a-4f54-8898-f230cbca544a",
"name": "Kuopio",
"hotelIds": ["689", "609"],
"hotelCount": 2,
"url": "/da/destinationer/finland/kuopio"
},
{
"id": "6abcbf56-7f7d-440b-94cd-f1ebb8c82180",
"name": "Turku",
"hotelIds": ["640", "619", "629"],
"hotelCount": 3,
"url": "/da/destinationer/finland/turku"
},
{
"id": "302f1cca-4f17-44f8-8fb9-ff30a3518122",
"name": "Jyväskylä",
"hotelIds": ["675", "676", "608"],
"hotelCount": 3,
"url": "/da/destinationer/finland/jyvaskyla"
},
{
"id": "982226bd-2e3d-4477-87a8-c2bb15b214c2",
"name": "Lahti",
"hotelIds": ["667"],
"hotelCount": 1,
"url": "/da/destinationer/finland/lahti"
},
{
"id": "a327b828-08b9-40a4-b353-2cd4e593e9d6",
"name": "Mikkeli",
"hotelIds": ["674"],
"hotelCount": 1,
"url": "/da/destinationer/finland/mikkeli"
},
{
"id": "bd727bf0-fab3-4059-a0e5-73f2aa31812e",
"name": "Rovaniemi",
"hotelIds": ["695", "694", "626"],
"hotelCount": 3,
"url": "/da/destinationer/finland/rovaniemi"
},
{
"id": "0d1e9054-8d69-44ad-8ce1-6ab310ff63f1",
"name": "Vaasa",
"hotelIds": ["637", "686"],
"hotelCount": 2,
"url": "/da/destinationer/finland/vaasa"
},
{
"id": "8edc7525-2c19-4ab3-8c5f-a7e0e600fb7f",
"name": "Seinäjoki",
"hotelIds": ["687"],
"hotelCount": 1,
"url": "/da/destinationer/finland/seinajoki"
},
{
"id": "ce945a1d-d267-47e7-a76e-c064c4a248e6",
"name": "Tampere",
"hotelIds": ["617", "677", "607", "678", "635"],
"hotelCount": 5,
"url": "/da/destinationer/finland/tampere"
},
{
"id": "9db040cf-660d-483c-89fb-d26ce8d108ba",
"name": "Rauma",
"hotelIds": ["684"],
"hotelCount": 1,
"url": "/da/destinationer/finland/rauma"
},
{
"id": "57db6f36-76f0-4d3a-b3d7-1ee5c86424e5",
"name": "Espoo",
"hotelIds": ["611"],
"hotelCount": 1,
"url": "/da/destinationer/finland/espoo"
},
{
"id": "f617ea8d-7cd1-4c94-a8ff-7ab83968a74f",
"name": "Lappeenranta",
"hotelIds": ["615"],
"hotelCount": 1,
"url": "/da/destinationer/finland/lappeenranta"
},
{
"id": "3f666b63-2d7b-4477-9bf1-b438d69063e5",
"name": "Pori",
"hotelIds": ["628"],
"hotelCount": 1,
"url": "/da/destinationer/finland/pori"
}
]
},
{
"country": "Norge",
"countryUrl": "/da/destinationer/norge",
"numberOfHotels": 81,
"cities": [
{
"id": "8ccac885-b41b-4444-8a2e-63690c36aacc",
"name": "Fredrikstad",
"hotelIds": ["360"],
"hotelCount": 1,
"url": "/da/destinationer/norge/fredrikstad"
},
{
"id": "38344052-799a-445e-ae51-e04ac11b39ab",
"name": "Bodø",
"hotelIds": ["312", "314"],
"hotelCount": 2,
"url": "/da/destinationer/norge/bodo"
},
{
"id": "b90c090a-9846-43d3-87f1-d76563486870",
"name": "Hønefoss",
"hotelIds": ["389"],
"hotelCount": 1,
"url": "/da/destinationer/norge/honefoss"
},
{
"id": "96cb5fdf-b75a-4535-aeff-76ef264efb03",
"name": "Haugesund",
"hotelIds": ["772"],
"hotelCount": 1,
"url": "/da/destinationer/norge/haugesund"
},
{
"id": "7ca5b1b6-1755-4bd9-bd87-5c33b54977df",
"name": "Harstad",
"hotelIds": ["363"],
"hotelCount": 1,
"url": "/da/destinationer/norge/harstad"
},
{
"id": "587a39de-1dd8-4aae-89f0-e038a3fb4e3a",
"name": "Førde",
"hotelIds": ["321"],
"hotelCount": 1,
"url": "/da/destinationer/norge/forde"
},
{
"id": "683a416f-768d-4c90-9a6f-7fc472a3fa26",
"name": "Hamar",
"hotelIds": ["756"],
"hotelCount": 1,
"url": "/da/destinationer/norge/hamar"
},
{
"id": "c32cc08b-e1d7-44ad-b477-80f97bf75a24",
"name": "Sarpsborg",
"hotelIds": ["345"],
"hotelCount": 1,
"url": "/da/destinationer/norge/sarpsborg"
},
{
"id": "070e2474-72a8-4994-a2ab-6f632a574b6f",
"name": "Molde",
"hotelIds": ["317", "793"],
"hotelCount": 2,
"url": "/da/destinationer/norge/molde"
},
{
"id": "59aa618b-ecea-49d1-8e2f-ca6ad2c7311d",
"name": "Fauske",
"hotelIds": ["374"],
"hotelCount": 1,
"url": "/da/destinationer/norge/fauske"
},
{
"id": "f0165c88-fcf1-4df6-80a1-a87c2a6703ae",
"name": "Trondheim",
"hotelIds": ["320", "764", "771", "380", "315", "316"],
"hotelCount": 6,
"url": "/da/destinationer/norge/trondheim"
},
{
"id": "37445e57-119c-4f04-aa67-8755f627305c",
"name": "Kristiansand",
"hotelIds": ["788", "780"],
"hotelCount": 2,
"url": "/da/destinationer/norge/kristiansand"
},
{
"id": "5d281d10-4e10-4cd2-aa0f-590b43a76484",
"name": "Hammerfest",
"hotelIds": ["307"],
"hotelCount": 1,
"url": "/da/destinationer/norge/hammerfest"
},
{
"id": "7e980f6b-215b-48d9-b335-c257878c2d6f",
"name": "Mo i Rana",
"hotelIds": ["367"],
"hotelCount": 1,
"url": "/da/destinationer/norge/mo-i-rana"
},
{
"id": "a153f15b-80ed-4e1f-b6ed-a5dc98f4aeb1",
"name": "Lillehammer",
"hotelIds": ["789", "790", "343"],
"hotelCount": 3,
"url": "/da/destinationer/norge/lillehammer"
},
{
"id": "4f4ee73b-c84b-4a1b-abaa-eca8ed5e098d",
"name": "Oslo",
"hotelIds": [
"339",
"340",
"766",
"342",
"751",
"390",
"333",
"776",
"337",
"391",
"332",
"784",
"759",
"760",
"336",
"773",
"334",
"774",
"765"
],
"hotelCount": 19,
"url": "/da/destinationer/norge/oslo"
},
{
"id": "2b961bce-ea4e-4ade-9157-6d7293f203d4",
"name": "Drammen",
"hotelIds": ["786"],
"hotelCount": 1,
"url": "/da/destinationer/norge/drammen"
},
{
"id": "019dc867-7668-4422-bc2f-cf1ff8f3402e",
"name": "Narvik",
"hotelIds": ["313"],
"hotelCount": 1,
"url": "/da/destinationer/norge/narvik"
},
{
"id": "770c186f-2857-49f1-b394-a4ae49e39f92",
"name": "Honningsvåg",
"hotelIds": ["304", "308", "303"],
"hotelCount": 3,
"url": "/da/destinationer/norge/honningsvag"
},
{
"id": "0e2b1f95-ed0f-433f-a7da-c34dd73f54d7",
"name": "Lofoten",
"hotelIds": ["791", "311", "387"],
"hotelCount": 3,
"url": "/da/destinationer/norge/lofoten"
},
{
"id": "5b4af0d6-f411-403d-8814-05d3c59824ad",
"name": "Sandnessjøen",
"hotelIds": ["365"],
"hotelCount": 1,
"url": "/da/destinationer/norge/sandnessjoen"
},
{
"id": "9d524265-8ce4-4e52-a935-1e6f1215b9b6",
"name": "Stavanger",
"hotelIds": ["795", "325", "781", "323", "775"],
"hotelCount": 5,
"url": "/da/destinationer/norge/stavanger"
},
{
"id": "8e23516f-f9e6-494f-8b32-389f7ab2396b",
"name": "Namsos",
"hotelIds": ["318"],
"hotelCount": 1,
"url": "/da/destinationer/norge/namsos"
},
{
"id": "9237f007-8c20-4ae2-b118-e4ca5f062964",
"name": "Karasjok",
"hotelIds": ["305"],
"hotelCount": 1,
"url": "/da/destinationer/norge/karasjok"
},
{
"id": "448a9763-9a67-4017-b5a5-014a5fa113cc",
"name": "Vadsø",
"hotelIds": ["302"],
"hotelCount": 1,
"url": "/da/destinationer/norge/vadso"
},
{
"id": "69a9e544-436f-4e35-959c-68930a42d660",
"name": "Bergen",
"hotelIds": ["757", "322", "770", "778", "785", "782", "326", "779"],
"hotelCount": 8,
"url": "/da/destinationer/norge/bergen"
},
{
"id": "d13dcbb3-3ff9-44b9-b2e6-9ce880722fdf",
"name": "Tromsø",
"hotelIds": ["310", "362", "796"],
"hotelCount": 3,
"url": "/da/destinationer/norge/tromso"
},
{
"id": "08e3ea79-5c31-496a-a00e-13f9b1f3b2d7",
"name": "Ålesund",
"hotelIds": ["368"],
"hotelCount": 1,
"url": "/da/destinationer/norge/alesund"
},
{
"id": "893efa9c-c1ed-40d2-be53-3f6f5dc415dd",
"name": "Alta",
"hotelIds": ["301"],
"hotelCount": 1,
"url": "/da/destinationer/norge/alta"
},
{
"id": "5aba4564-5cb2-4084-85ce-29ef896ada84",
"name": "Fagernes",
"hotelIds": ["787"],
"hotelCount": 1,
"url": "/da/destinationer/norge/fagernes"
},
{
"id": "25d89282-243f-4286-a024-0afa6e856a46",
"name": "Kirkenes",
"hotelIds": ["306"],
"hotelCount": 1,
"url": "/da/destinationer/norge/kirkenes"
},
{
"id": "385a64cd-963b-4a49-8992-06057ad80f09",
"name": "Mysen",
"hotelIds": ["388"],
"hotelCount": 1,
"url": "/da/destinationer/norge/mysen"
},
{
"id": "eae26fc6-55e5-4e77-96bf-6f5488c697f0",
"name": "Kristiansund",
"hotelIds": ["319"],
"hotelCount": 1,
"url": "/da/destinationer/norge/kristiansund"
},
{
"id": "a45d077c-d626-4e54-a1ff-199b3454d6c8",
"name": "Sortland",
"hotelIds": ["359"],
"hotelCount": 1,
"url": "/da/destinationer/norge/sortland"
},
{
"id": "3415e231-3de1-459d-aed4-d3b023ab8736",
"name": "Voss",
"hotelIds": ["792"],
"hotelCount": 1,
"url": "/da/destinationer/norge/voss"
},
{
"id": "b1e5c2f9-1efd-4cf6-ad04-cc74e530a559",
"name": "Sandefjord",
"hotelIds": ["329"],
"hotelCount": 1,
"url": "/da/destinationer/norge/sandefjord"
}
]
},
{
"country": "Polen",
"countryUrl": "/da/destinationer/polen",
"numberOfHotels": 2,
"cities": [
{
"id": "a37d9204-f407-435f-af1a-d29a577bf722",
"name": "Wroclaw",
"hotelIds": ["442"],
"hotelCount": 1,
"url": "/da/destinationer/polen/wroclaw"
},
{
"id": "ae62ef46-f085-46ee-b8e1-7d8c1f4ae524",
"name": "Gdansk",
"hotelIds": ["441"],
"hotelCount": 1,
"url": "/da/destinationer/polen/gdansk"
}
]
},
{
"country": "Sverige",
"countryUrl": "/da/destinationer/sverige",
"numberOfHotels": 89,
"cities": [
{
"id": "a882a807-f45b-46aa-98f0-c1013f263faf",
"name": "Kiruna",
"hotelIds": ["218"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/kiruna"
},
{
"id": "6bace0a2-5cb1-4f4f-9c6f-a226c66fad00",
"name": "Kalmar",
"hotelIds": ["847"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/kalmar"
},
{
"id": "37a32ca6-6467-4e75-8810-67edd75143cd",
"name": "Borlänge",
"hotelIds": ["824"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/borlange"
},
{
"id": "b3c820e4-a626-469d-a855-7dbb6d13c204",
"name": "Linkøping",
"hotelIds": ["863", "887", "872"],
"hotelCount": 3,
"url": "/da/destinationer/sverige/linkoping"
},
{
"id": "4911e413-6ef4-4050-84b9-a8aab23e9398",
"name": "Karlstad",
"hotelIds": ["848", "876", "832"],
"hotelCount": 3,
"url": "/da/destinationer/sverige/karlstad"
},
{
"id": "3c320b63-23e3-4a58-9303-8c26aff1d099",
"name": "Falun",
"hotelIds": ["844"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/falun"
},
{
"id": "0e8b7038-43cd-41e3-b326-458fc7f0ea55",
"name": "Bollnäs",
"hotelIds": ["873"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/bollnas"
},
{
"id": "b4abfc11-c7d8-4900-86c1-23dd06d2c1a8",
"name": "Karlskrona",
"hotelIds": ["843"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/karlskrona"
},
{
"id": "3d813c56-c135-4591-864b-40ea845df716",
"name": "Halmstad",
"hotelIds": ["839"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/halmstad"
},
{
"id": "8bd4b2d1-1fa2-44ea-8fe5-85d113087b0e",
"name": "Gävle",
"hotelIds": ["871", "883"],
"hotelCount": 2,
"url": "/da/destinationer/sverige/gavle"
},
{
"id": "afe92243-181e-4670-8b63-434c8a312ff6",
"name": "Borås",
"hotelIds": ["840"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/boras"
},
{
"id": "4d160eda-7b41-4b6f-a2c1-0b05f43c99cb",
"name": "Arvika",
"hotelIds": ["845"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/arvika"
},
{
"id": "133f953d-7f44-4a84-bc07-025d97968c7a",
"name": "Helsingborg",
"hotelIds": ["217", "855"],
"hotelCount": 2,
"url": "/da/destinationer/sverige/helsingborg"
},
{
"id": "4785b5f2-352b-4170-b897-0aa8730c8cde",
"name": "Skellefteå",
"hotelIds": ["823"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/skelleftea"
},
{
"id": "604a0898-6f74-4617-9e5c-b4696307f192",
"name": "Gøteborg",
"hotelIds": [
"841",
"851",
"867",
"806",
"216",
"817",
"801",
"816",
"215"
],
"hotelCount": 9,
"url": "/da/destinationer/sverige/goteborg"
},
{
"id": "da422de7-8b1c-4b09-afa9-be83c7926ba4",
"name": "Stockholm",
"hotelIds": [
"220",
"821",
"830",
"810",
"222",
"879",
"890",
"213",
"809",
"811",
"865",
"857",
"808",
"211",
"833",
"812",
"813",
"803",
"814",
"214",
"805",
"838",
"886",
"826",
"875",
"802",
"223"
],
"hotelCount": 27,
"url": "/da/destinationer/sverige/stockholm"
},
{
"id": "a8149b40-0684-45bc-8934-e92c15248936",
"name": "Gällivare",
"hotelIds": ["891"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/gallivare"
},
{
"id": "9ac2a592-736e-429e-9c14-e79afc90d5a1",
"name": "Østersund",
"hotelIds": ["859"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/ostersund"
},
{
"id": "ba0c9c38-e85e-4b7c-b446-0ee81292b86f",
"name": "Umeå",
"hotelIds": ["870", "882"],
"hotelCount": 2,
"url": "/da/destinationer/sverige/umea"
},
{
"id": "e39cd918-3839-4241-9a38-fb7dfa08864e",
"name": "Luleå",
"hotelIds": ["868"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/lulea"
},
{
"id": "3183d862-f2d6-46cf-b7dd-ebcc6b1095fd",
"name": "Örnsköldsvik",
"hotelIds": ["828"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/ornskoldsvik"
},
{
"id": "536841e9-9b73-4fd9-ad53-60a74e6369b5",
"name": "Trollhättan",
"hotelIds": ["850"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/trollhattan"
},
{
"id": "a96719fa-e987-4a8f-8526-30d21b456e6b",
"name": "Västerås",
"hotelIds": ["866"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/vasteras"
},
{
"id": "bcf7553e-d55d-4b66-bea4-b46ef38d920f",
"name": "Skövde",
"hotelIds": ["889"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/skovde"
},
{
"id": "44af613a-915c-421f-a0d2-0d6c91b6aa8c",
"name": "Norrköping",
"hotelIds": ["827", "852"],
"hotelCount": 2,
"url": "/da/destinationer/sverige/norrkoping"
},
{
"id": "76d6c322-3d4f-4c32-9482-d9732c8a9ed3",
"name": "Uppsala",
"hotelIds": ["861", "885"],
"hotelCount": 2,
"url": "/da/destinationer/sverige/uppsala"
},
{
"id": "49b7b0c0-d9fe-4ed9-afc7-bce59987598d",
"name": "Strömstad",
"hotelIds": ["888"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/stromstad"
},
{
"id": "789dceb5-6ae7-4747-adcc-1e7d0a97c30c",
"name": "Södertälje",
"hotelIds": ["854"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/sodertalje"
},
{
"id": "dfd5a6f7-3533-4759-99e5-025c04a4fa6b",
"name": "Lund",
"hotelIds": ["858"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/lund"
},
{
"id": "efc013bf-f9fb-4849-8b78-27b242b048f0",
"name": "Örebro",
"hotelIds": ["219", "869", "836"],
"hotelCount": 3,
"url": "/da/destinationer/sverige/orebro"
},
{
"id": "0cb510bc-b0d4-4319-9d08-46b36768ced3",
"name": "Sundsvall",
"hotelIds": ["834", "853"],
"hotelCount": 2,
"url": "/da/destinationer/sverige/sundsvall"
},
{
"id": "c823bc34-e52f-4266-b9c6-1241127c8e9f",
"name": "Värnamo",
"hotelIds": ["842"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/varnamo"
},
{
"id": "ee825ab9-9279-4317-9055-8103282e0fac",
"name": "Jönköping",
"hotelIds": ["856", "846"],
"hotelCount": 2,
"url": "/da/destinationer/sverige/jonkoping"
},
{
"id": "6a3f8b4f-add3-49ba-8f65-663d3214443b",
"name": "Malmø",
"hotelIds": ["881", "874", "878", "818", "849", "864"],
"hotelCount": 6,
"url": "/da/destinationer/sverige/malmo"
},
{
"id": "b75826c3-9a74-482a-9b2d-0d948e35c3b4",
"name": "Nyköping",
"hotelIds": ["829"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/nykoping"
},
{
"id": "05e249f9-fd97-4622-8cd5-d3ca43add3dc",
"name": "Visby",
"hotelIds": ["877"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/visby"
},
{
"id": "f22a6934-cae3-43b5-9d0b-7c49bfae499b",
"name": "Växjö",
"hotelIds": ["860"],
"hotelCount": 1,
"url": "/da/destinationer/sverige/vaxjo"
}
]
},
{
"country": "Tyskland",
"countryUrl": "/da/destinationer/tyskland",
"numberOfHotels": 8,
"cities": [
{
"id": "35b2df9b-261a-4086-ac20-b6d459f62e48",
"name": "Frankfurt",
"hotelIds": ["555", "556"],
"hotelCount": 2,
"url": "/da/destinationer/tyskland/frankfurt"
},
{
"id": "7a5f9827-4756-4855-ba9d-4b08154c8b16",
"name": "Berlin",
"hotelIds": ["554", "551"],
"hotelCount": 2,
"url": "/da/destinationer/tyskland/berlin"
},
{
"id": "7e096ff0-4532-4914-b810-91506e53bae9",
"name": "Hamborg",
"hotelIds": ["550"],
"hotelCount": 1,
"url": "/da/destinationer/tyskland/hamborg"
},
{
"id": "c1dc412f-a61b-447c-a7a7-961b78b4c91a",
"name": "München",
"hotelIds": ["557"],
"hotelCount": 1,
"url": "/da/destinationer/tyskland/munchen"
},
{
"id": "aa66312c-b502-4f28-bd68-6e8181e2ecce",
"name": "Nürnberg",
"hotelIds": ["558"],
"hotelCount": 1,
"url": "/da/destinationer/tyskland/nurnberg"
},
{
"id": "bb6dc0bf-9ecd-419b-97ce-9507ed68d445",
"name": "Stuttgart",
"hotelIds": ["559"],
"hotelCount": 1,
"url": "/da/destinationer/tyskland/stuttgart"
}
]
}
]

View File

@@ -1,952 +0,0 @@
[
{
"country": "Deutschland",
"countryUrl": "/de/reiseziele/deutschland",
"numberOfHotels": 8,
"cities": [
{
"id": "35b2df9b-261a-4086-ac20-b6d459f62e48",
"name": "Frankfurt",
"hotelIds": ["555", "556"],
"hotelCount": 2,
"url": "/de/reiseziele/deutschland/frankfurt"
},
{
"id": "7a5f9827-4756-4855-ba9d-4b08154c8b16",
"name": "Berlin",
"hotelIds": ["554", "551"],
"hotelCount": 2,
"url": "/de/reiseziele/deutschland/berlin"
},
{
"id": "7e096ff0-4532-4914-b810-91506e53bae9",
"name": "Hamburg",
"hotelIds": ["550"],
"hotelCount": 1,
"url": "/de/reiseziele/deutschland/hamburg"
},
{
"id": "c1dc412f-a61b-447c-a7a7-961b78b4c91a",
"name": "München",
"hotelIds": ["557"],
"hotelCount": 1,
"url": "/de/reiseziele/deutschland/munchen"
},
{
"id": "aa66312c-b502-4f28-bd68-6e8181e2ecce",
"name": "Nürnberg",
"hotelIds": ["558"],
"hotelCount": 1,
"url": "/de/reiseziele/deutschland/nurnberg"
},
{
"id": "3003ba64-db0e-4426-ac2e-4615f182ea32",
"name": "Stuttgart",
"hotelIds": [],
"hotelCount": 0,
"url": "/de/reiseziele/deutschland/stuttgart"
},
{
"id": "bb6dc0bf-9ecd-419b-97ce-9507ed68d445",
"name": "Stuttgart",
"hotelIds": ["559"],
"hotelCount": 1,
"url": "/de/reiseziele/deutschland/stuttgart"
}
]
},
{
"country": "Dänemark",
"countryUrl": "/de/reiseziele/danemark",
"numberOfHotels": 27,
"cities": [
{
"id": "20c2ec6a-aedc-4d00-ad67-86a33664b185",
"name": "Kopenhagen",
"hotelIds": [
"715",
"739",
"719",
"749",
"714",
"744",
"731",
"721",
"724",
"727",
"723",
"716",
"718"
],
"hotelCount": 13,
"url": "/de/reiseziele/danemark/kopenhagen"
},
{
"id": "4b00f90a-d76f-4aef-9959-f0574e7f3db7",
"name": "Kolding",
"hotelIds": ["737"],
"hotelCount": 1,
"url": "/de/reiseziele/danemark/kolding"
},
{
"id": "2713ad8f-4e12-43eb-a11e-26072ae9d7be",
"name": "Esbjerg",
"hotelIds": ["732"],
"hotelCount": 1,
"url": "/de/reiseziele/danemark/esbjerg"
},
{
"id": "e343ba54-ef17-424d-ad77-312cabf3d5eb",
"name": "Horsens",
"hotelIds": ["713"],
"hotelCount": 1,
"url": "/de/reiseziele/danemark/horsens"
},
{
"id": "1c2557c6-f4fe-4de5-a1bf-db6b2d69e5d7",
"name": "Ringsted",
"hotelIds": ["733"],
"hotelCount": 1,
"url": "/de/reiseziele/danemark/ringsted"
},
{
"id": "d3cb1d93-2dbb-4320-b66c-8f79799815df",
"name": "Roskilde",
"hotelIds": ["745"],
"hotelCount": 1,
"url": "/de/reiseziele/danemark/roskilde"
},
{
"id": "c785c63d-024b-423a-93cf-bdb66b74fcf1",
"name": "Aalborg",
"hotelIds": ["720", "735"],
"hotelCount": 2,
"url": "/de/reiseziele/danemark/aalborg"
},
{
"id": "66a6fa85-31b7-432d-beac-31ee87e555f8",
"name": "Odense",
"hotelIds": ["748"],
"hotelCount": 1,
"url": "/de/reiseziele/danemark/odense"
},
{
"id": "31803fa2-8714-44e2-aaf9-8103ba8f68c2",
"name": "Herning",
"hotelIds": ["746"],
"hotelCount": 1,
"url": "/de/reiseziele/danemark/herning"
},
{
"id": "b0d93658-79cd-45c8-ab74-675d1df712cc",
"name": "Silkeborg",
"hotelIds": ["747"],
"hotelCount": 1,
"url": "/de/reiseziele/danemark/silkeborg"
},
{
"id": "e803306e-53bd-4ddf-9495-1cabe3882291",
"name": "Sonderburg",
"hotelIds": ["728"],
"hotelCount": 1,
"url": "/de/reiseziele/danemark/sonderburg"
},
{
"id": "26bdbf43-1888-466f-a805-1e7900b31936",
"name": "Aarhus",
"hotelIds": ["736", "726", "738"],
"hotelCount": 3,
"url": "/de/reiseziele/danemark/aarhus"
}
]
},
{
"country": "Finnland",
"countryUrl": "/de/reiseziele/finnland",
"numberOfHotels": 52,
"cities": [
{
"id": "5e6afbbc-c2f4-4506-b770-230f9cab70d1",
"name": "Joensuu",
"hotelIds": ["688"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/joensuu"
},
{
"id": "12e3e8c5-2ed4-461d-8a92-92e4612a486e",
"name": "Helsinki",
"hotelIds": [
"638",
"665",
"663",
"622",
"660",
"662",
"661",
"603",
"666",
"605",
"601",
"697",
"634",
"639",
"698",
"643"
],
"hotelCount": 16,
"url": "/de/reiseziele/finnland/helsinki"
},
{
"id": "92edb3ae-1174-4c77-99bd-de5919abb927",
"name": "Imatra",
"hotelIds": ["696"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/imatra"
},
{
"id": "8fea27c8-ab24-4cb5-a8ab-03f422aed8de",
"name": "Hyvinkää",
"hotelIds": ["668"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/hyvinkaa"
},
{
"id": "4cc21629-4006-4a2a-b3bd-26a1687ca36d",
"name": "Nokia",
"hotelIds": ["679"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/nokia"
},
{
"id": "a43392b9-928d-466d-a54f-8c4f50623b0f",
"name": "Kemi",
"hotelIds": ["693"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/kemi"
},
{
"id": "d4513390-9892-4f82-82a1-f26c2c5907e3",
"name": "Kouvola",
"hotelIds": ["672"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/kouvola"
},
{
"id": "5438b466-d430-4d95-a1fd-4dd5e091155c",
"name": "Ruka Kuusamo",
"hotelIds": ["691"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/ruka-kuusamo"
},
{
"id": "ed1efb68-418c-4ed3-868d-0eaa892a90aa",
"name": "Oulu",
"hotelIds": ["692", "624"],
"hotelCount": 2,
"url": "/de/reiseziele/finnland/oulu"
},
{
"id": "b8b92fe2-068c-41d5-b60f-2afbbd76e871",
"name": "Hämeenlinna",
"hotelIds": ["669", "670"],
"hotelCount": 2,
"url": "/de/reiseziele/finnland/hameenlinna"
},
{
"id": "5b68f747-941a-4f54-8898-f230cbca544a",
"name": "Kuopio",
"hotelIds": ["689", "609"],
"hotelCount": 2,
"url": "/de/reiseziele/finnland/kuopio"
},
{
"id": "6abcbf56-7f7d-440b-94cd-f1ebb8c82180",
"name": "Turku",
"hotelIds": ["640", "619", "629"],
"hotelCount": 3,
"url": "/de/reiseziele/finnland/turku"
},
{
"id": "302f1cca-4f17-44f8-8fb9-ff30a3518122",
"name": "Jyväskylä",
"hotelIds": ["675", "676", "608"],
"hotelCount": 3,
"url": "/de/reiseziele/finnland/jyvaskyla"
},
{
"id": "982226bd-2e3d-4477-87a8-c2bb15b214c2",
"name": "Lahti",
"hotelIds": ["667"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/lahti"
},
{
"id": "a327b828-08b9-40a4-b353-2cd4e593e9d6",
"name": "Mikkeli",
"hotelIds": ["674"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/mikkeli"
},
{
"id": "bd727bf0-fab3-4059-a0e5-73f2aa31812e",
"name": "Rovaniemi",
"hotelIds": ["695", "694", "626"],
"hotelCount": 3,
"url": "/de/reiseziele/finnland/rovaniemi"
},
{
"id": "0d1e9054-8d69-44ad-8ce1-6ab310ff63f1",
"name": "Vaasa",
"hotelIds": ["637", "686"],
"hotelCount": 2,
"url": "/de/reiseziele/finnland/vaasa"
},
{
"id": "ce945a1d-d267-47e7-a76e-c064c4a248e6",
"name": "Tampere",
"hotelIds": ["617", "677", "607", "678", "635"],
"hotelCount": 5,
"url": "/de/reiseziele/finnland/tampere"
},
{
"id": "8edc7525-2c19-4ab3-8c5f-a7e0e600fb7f",
"name": "Seinäjoki",
"hotelIds": ["687"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/seinajoki"
},
{
"id": "9db040cf-660d-483c-89fb-d26ce8d108ba",
"name": "Rauma",
"hotelIds": ["684"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/rauma"
},
{
"id": "57db6f36-76f0-4d3a-b3d7-1ee5c86424e5",
"name": "Espoo",
"hotelIds": ["611"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/espoo"
},
{
"id": "f617ea8d-7cd1-4c94-a8ff-7ab83968a74f",
"name": "Lappeenranta",
"hotelIds": ["615"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/lappeenranta"
},
{
"id": "3f666b63-2d7b-4477-9bf1-b438d69063e5",
"name": "Pori",
"hotelIds": ["628"],
"hotelCount": 1,
"url": "/de/reiseziele/finnland/pori"
}
]
},
{
"country": "Norwegen",
"countryUrl": "/de/reiseziele/norwegen",
"numberOfHotels": 81,
"cities": [
{
"id": "8ccac885-b41b-4444-8a2e-63690c36aacc",
"name": "Fredrikstad",
"hotelIds": ["360"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/fredrikstad"
},
{
"id": "38344052-799a-445e-ae51-e04ac11b39ab",
"name": "Bodø",
"hotelIds": ["312", "314"],
"hotelCount": 2,
"url": "/de/reiseziele/norwegen/bodo"
},
{
"id": "b90c090a-9846-43d3-87f1-d76563486870",
"name": "Hønefoss",
"hotelIds": ["389"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/honefoss"
},
{
"id": "7ca5b1b6-1755-4bd9-bd87-5c33b54977df",
"name": "Harstad",
"hotelIds": ["363"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/harstad"
},
{
"id": "96cb5fdf-b75a-4535-aeff-76ef264efb03",
"name": "Haugesund",
"hotelIds": ["772"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/haugesund"
},
{
"id": "587a39de-1dd8-4aae-89f0-e038a3fb4e3a",
"name": "Forde",
"hotelIds": ["321"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/forde"
},
{
"id": "683a416f-768d-4c90-9a6f-7fc472a3fa26",
"name": "Hamar",
"hotelIds": ["756"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/hamar"
},
{
"id": "c32cc08b-e1d7-44ad-b477-80f97bf75a24",
"name": "Sarpsborg",
"hotelIds": ["345"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/sarpsborg"
},
{
"id": "070e2474-72a8-4994-a2ab-6f632a574b6f",
"name": "Molde",
"hotelIds": ["317", "793"],
"hotelCount": 2,
"url": "/de/reiseziele/norwegen/molde"
},
{
"id": "f0165c88-fcf1-4df6-80a1-a87c2a6703ae",
"name": "Trondheim",
"hotelIds": ["320", "764", "771", "380", "315", "316"],
"hotelCount": 6,
"url": "/de/reiseziele/norwegen/trondheim"
},
{
"id": "37445e57-119c-4f04-aa67-8755f627305c",
"name": "Kristiansand",
"hotelIds": ["788", "780"],
"hotelCount": 2,
"url": "/de/reiseziele/norwegen/kristiansand"
},
{
"id": "59aa618b-ecea-49d1-8e2f-ca6ad2c7311d",
"name": "Fauske",
"hotelIds": ["374"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/fauske"
},
{
"id": "5d281d10-4e10-4cd2-aa0f-590b43a76484",
"name": "Hammerfest",
"hotelIds": ["307"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/hammerfest"
},
{
"id": "7e980f6b-215b-48d9-b335-c257878c2d6f",
"name": "Mo i Rana",
"hotelIds": ["367"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/mo-i-rana"
},
{
"id": "a153f15b-80ed-4e1f-b6ed-a5dc98f4aeb1",
"name": "Lillehammer",
"hotelIds": ["789", "790", "343"],
"hotelCount": 3,
"url": "/de/reiseziele/norwegen/lillehammer"
},
{
"id": "2b961bce-ea4e-4ade-9157-6d7293f203d4",
"name": "Drammen",
"hotelIds": ["786"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/drammen"
},
{
"id": "4f4ee73b-c84b-4a1b-abaa-eca8ed5e098d",
"name": "Oslo",
"hotelIds": [
"339",
"340",
"766",
"342",
"751",
"390",
"333",
"776",
"337",
"391",
"332",
"784",
"759",
"760",
"336",
"773",
"334",
"774",
"765"
],
"hotelCount": 19,
"url": "/de/reiseziele/norwegen/oslo"
},
{
"id": "019dc867-7668-4422-bc2f-cf1ff8f3402e",
"name": "Narvik",
"hotelIds": ["313"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/narvik"
},
{
"id": "770c186f-2857-49f1-b394-a4ae49e39f92",
"name": "Honningsvag",
"hotelIds": ["304", "308", "303"],
"hotelCount": 3,
"url": "/de/reiseziele/norwegen/honningsvag"
},
{
"id": "0e2b1f95-ed0f-433f-a7da-c34dd73f54d7",
"name": "Lofoten",
"hotelIds": ["791", "311", "387"],
"hotelCount": 3,
"url": "/de/reiseziele/norwegen/lofoten"
},
{
"id": "5b4af0d6-f411-403d-8814-05d3c59824ad",
"name": "Sandnessjöen",
"hotelIds": ["365"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/sandnessjoen"
},
{
"id": "9d524265-8ce4-4e52-a935-1e6f1215b9b6",
"name": "Stavanger",
"hotelIds": ["795", "325", "781", "323", "775"],
"hotelCount": 5,
"url": "/de/reiseziele/norwegen/stavanger"
},
{
"id": "d13dcbb3-3ff9-44b9-b2e6-9ce880722fdf",
"name": "Tromso",
"hotelIds": ["362", "310", "796"],
"hotelCount": 3,
"url": "/de/reiseziele/norwegen/tromso"
},
{
"id": "9237f007-8c20-4ae2-b118-e4ca5f062964",
"name": "Karasjok",
"hotelIds": ["305"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/karasjok"
},
{
"id": "69a9e544-436f-4e35-959c-68930a42d660",
"name": "Bergen",
"hotelIds": ["757", "322", "770", "778", "785", "782", "326", "779"],
"hotelCount": 8,
"url": "/de/reiseziele/norwegen/bergen"
},
{
"id": "8e23516f-f9e6-494f-8b32-389f7ab2396b",
"name": "Namsos",
"hotelIds": ["318"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/namsos"
},
{
"id": "448a9763-9a67-4017-b5a5-014a5fa113cc",
"name": "Vadsø",
"hotelIds": ["302"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/vadso"
},
{
"id": "08e3ea79-5c31-496a-a00e-13f9b1f3b2d7",
"name": "Ålesund",
"hotelIds": ["368"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/alesund"
},
{
"id": "893efa9c-c1ed-40d2-be53-3f6f5dc415dd",
"name": "Alta",
"hotelIds": ["301"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/alta"
},
{
"id": "5aba4564-5cb2-4084-85ce-29ef896ada84",
"name": "Fagernes",
"hotelIds": ["787"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/fagernes"
},
{
"id": "25d89282-243f-4286-a024-0afa6e856a46",
"name": "Kirkenes",
"hotelIds": ["306"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/kirkenes"
},
{
"id": "b1e5c2f9-1efd-4cf6-ad04-cc74e530a559",
"name": "Sandefjord",
"hotelIds": ["329"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/sandefjord"
},
{
"id": "eae26fc6-55e5-4e77-96bf-6f5488c697f0",
"name": "Kristiansund",
"hotelIds": ["319"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/kristiansund"
},
{
"id": "a45d077c-d626-4e54-a1ff-199b3454d6c8",
"name": "Sortland",
"hotelIds": ["359"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/sortland"
},
{
"id": "385a64cd-963b-4a49-8992-06057ad80f09",
"name": "Mysen",
"hotelIds": ["388"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/mysen"
},
{
"id": "3415e231-3de1-459d-aed4-d3b023ab8736",
"name": "Voss",
"hotelIds": ["792"],
"hotelCount": 1,
"url": "/de/reiseziele/norwegen/voss"
}
]
},
{
"country": "Polen",
"countryUrl": "/de/reiseziele/polen",
"numberOfHotels": 2,
"cities": [
{
"id": "a37d9204-f407-435f-af1a-d29a577bf722",
"name": "Breslau",
"hotelIds": ["442"],
"hotelCount": 1,
"url": "/de/reiseziele/polen/breslau"
},
{
"id": "ae62ef46-f085-46ee-b8e1-7d8c1f4ae524",
"name": "Danzig",
"hotelIds": ["441"],
"hotelCount": 1,
"url": "/de/reiseziele/polen/danzig"
}
]
},
{
"country": "Schweden",
"countryUrl": "/de/reiseziele/schweden",
"numberOfHotels": 89,
"cities": [
{
"id": "a882a807-f45b-46aa-98f0-c1013f263faf",
"name": "Kiruna",
"hotelIds": ["218"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/kiruna"
},
{
"id": "37a32ca6-6467-4e75-8810-67edd75143cd",
"name": "Borlänge",
"hotelIds": ["824"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/borlange"
},
{
"id": "6bace0a2-5cb1-4f4f-9c6f-a226c66fad00",
"name": "Kalmar",
"hotelIds": ["847"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/kalmar"
},
{
"id": "b3c820e4-a626-469d-a855-7dbb6d13c204",
"name": "Linköping",
"hotelIds": ["863", "887", "872"],
"hotelCount": 3,
"url": "/de/reiseziele/schweden/linkoping"
},
{
"id": "3c320b63-23e3-4a58-9303-8c26aff1d099",
"name": "Falun",
"hotelIds": ["844"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/falun"
},
{
"id": "b4abfc11-c7d8-4900-86c1-23dd06d2c1a8",
"name": "Karlskrona",
"hotelIds": ["843"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/karlskrona"
},
{
"id": "4911e413-6ef4-4050-84b9-a8aab23e9398",
"name": "Karlstad",
"hotelIds": ["848", "876", "832"],
"hotelCount": 3,
"url": "/de/reiseziele/schweden/karlstad"
},
{
"id": "3d813c56-c135-4591-864b-40ea845df716",
"name": "Halmstad",
"hotelIds": ["839"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/halmstad"
},
{
"id": "0e8b7038-43cd-41e3-b326-458fc7f0ea55",
"name": "Bollnäs",
"hotelIds": ["873"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/bollnas"
},
{
"id": "8bd4b2d1-1fa2-44ea-8fe5-85d113087b0e",
"name": "Gävle",
"hotelIds": ["871", "883"],
"hotelCount": 2,
"url": "/de/reiseziele/schweden/gavle"
},
{
"id": "afe92243-181e-4670-8b63-434c8a312ff6",
"name": "Borås",
"hotelIds": ["840"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/boras"
},
{
"id": "4785b5f2-352b-4170-b897-0aa8730c8cde",
"name": "Skellefteå",
"hotelIds": ["823"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/skelleftea"
},
{
"id": "4d160eda-7b41-4b6f-a2c1-0b05f43c99cb",
"name": "Arvika",
"hotelIds": ["845"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/arvika"
},
{
"id": "133f953d-7f44-4a84-bc07-025d97968c7a",
"name": "Helsingborg",
"hotelIds": ["217", "855"],
"hotelCount": 2,
"url": "/de/reiseziele/schweden/helsingborg"
},
{
"id": "9ac2a592-736e-429e-9c14-e79afc90d5a1",
"name": "Östersund",
"hotelIds": ["859"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/ostersund"
},
{
"id": "a8149b40-0684-45bc-8934-e92c15248936",
"name": "Gällivare",
"hotelIds": ["891"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/gallivare"
},
{
"id": "604a0898-6f74-4617-9e5c-b4696307f192",
"name": "Göteborg",
"hotelIds": [
"841",
"851",
"867",
"806",
"216",
"817",
"801",
"816",
"215"
],
"hotelCount": 9,
"url": "/de/reiseziele/schweden/goteborg"
},
{
"id": "da422de7-8b1c-4b09-afa9-be83c7926ba4",
"name": "Stockholm",
"hotelIds": [
"220",
"821",
"830",
"810",
"222",
"879",
"890",
"213",
"809",
"811",
"865",
"857",
"808",
"211",
"833",
"812",
"813",
"803",
"814",
"214",
"805",
"838",
"886",
"826",
"875",
"802",
"223"
],
"hotelCount": 27,
"url": "/de/reiseziele/schweden/stockholm"
},
{
"id": "e39cd918-3839-4241-9a38-fb7dfa08864e",
"name": "Lulea",
"hotelIds": ["868"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/lulea"
},
{
"id": "ba0c9c38-e85e-4b7c-b446-0ee81292b86f",
"name": "Umeå",
"hotelIds": ["870", "882"],
"hotelCount": 2,
"url": "/de/reiseziele/schweden/umea"
},
{
"id": "3183d862-f2d6-46cf-b7dd-ebcc6b1095fd",
"name": "Örnsköldsvik",
"hotelIds": ["828"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/ornskoldsvik"
},
{
"id": "44af613a-915c-421f-a0d2-0d6c91b6aa8c",
"name": "Norrköping",
"hotelIds": ["827", "852"],
"hotelCount": 2,
"url": "/de/reiseziele/schweden/norrkoping"
},
{
"id": "536841e9-9b73-4fd9-ad53-60a74e6369b5",
"name": "Trollhättan",
"hotelIds": ["850"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/trollhattan"
},
{
"id": "76d6c322-3d4f-4c32-9482-d9732c8a9ed3",
"name": "Uppsala",
"hotelIds": ["861", "885"],
"hotelCount": 2,
"url": "/de/reiseziele/schweden/uppsala"
},
{
"id": "a96719fa-e987-4a8f-8526-30d21b456e6b",
"name": "Vasteras",
"hotelIds": ["866"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/vasteras"
},
{
"id": "bcf7553e-d55d-4b66-bea4-b46ef38d920f",
"name": "Skovde",
"hotelIds": ["889"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/skovde"
},
{
"id": "49b7b0c0-d9fe-4ed9-afc7-bce59987598d",
"name": "Strömstad",
"hotelIds": ["888"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/stromstad"
},
{
"id": "789dceb5-6ae7-4747-adcc-1e7d0a97c30c",
"name": "Södertälje",
"hotelIds": ["854"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/sodertalje"
},
{
"id": "dfd5a6f7-3533-4759-99e5-025c04a4fa6b",
"name": "Lund",
"hotelIds": ["858"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/lund"
},
{
"id": "efc013bf-f9fb-4849-8b78-27b242b048f0",
"name": "Örebro",
"hotelIds": ["219", "869", "836"],
"hotelCount": 3,
"url": "/de/reiseziele/schweden/orebro"
},
{
"id": "0cb510bc-b0d4-4319-9d08-46b36768ced3",
"name": "Sundsvall",
"hotelIds": ["834", "853"],
"hotelCount": 2,
"url": "/de/reiseziele/schweden/sundsvall"
},
{
"id": "c823bc34-e52f-4266-b9c6-1241127c8e9f",
"name": "Värnamo",
"hotelIds": ["842"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/varnamo"
},
{
"id": "ee825ab9-9279-4317-9055-8103282e0fac",
"name": "Jonkoping",
"hotelIds": ["856", "846"],
"hotelCount": 2,
"url": "/de/reiseziele/schweden/jonkoping"
},
{
"id": "6a3f8b4f-add3-49ba-8f65-663d3214443b",
"name": "Malmö",
"hotelIds": ["881", "874", "878", "818", "849", "864"],
"hotelCount": 6,
"url": "/de/reiseziele/schweden/malmo"
},
{
"id": "b75826c3-9a74-482a-9b2d-0d948e35c3b4",
"name": "Nyköping",
"hotelIds": ["829"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/nykoping"
},
{
"id": "05e249f9-fd97-4622-8cd5-d3ca43add3dc",
"name": "Visby",
"hotelIds": ["877"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/visby"
},
{
"id": "f22a6934-cae3-43b5-9d0b-7c49bfae499b",
"name": "Växjö",
"hotelIds": ["860"],
"hotelCount": 1,
"url": "/de/reiseziele/schweden/vaxjo"
}
]
}
]

View File

@@ -1,945 +0,0 @@
[
{
"country": "Denmark",
"countryUrl": "/en/destinations/denmark",
"numberOfHotels": 27,
"cities": [
{
"id": "20c2ec6a-aedc-4d00-ad67-86a33664b185",
"name": "Copenhagen",
"hotelIds": [
"715",
"739",
"719",
"749",
"714",
"744",
"731",
"721",
"724",
"727",
"723",
"716",
"718"
],
"hotelCount": 13,
"url": "/en/destinations/denmark/copenhagen"
},
{
"id": "4b00f90a-d76f-4aef-9959-f0574e7f3db7",
"name": "Kolding",
"hotelIds": ["737"],
"hotelCount": 1,
"url": "/en/destinations/denmark/kolding"
},
{
"id": "e343ba54-ef17-424d-ad77-312cabf3d5eb",
"name": "Horsens",
"hotelIds": ["713"],
"hotelCount": 1,
"url": "/en/destinations/denmark/horsens"
},
{
"id": "1c2557c6-f4fe-4de5-a1bf-db6b2d69e5d7",
"name": "Ringsted",
"hotelIds": ["733"],
"hotelCount": 1,
"url": "/en/destinations/denmark/ringsted"
},
{
"id": "2713ad8f-4e12-43eb-a11e-26072ae9d7be",
"name": "Esbjerg",
"hotelIds": ["732"],
"hotelCount": 1,
"url": "/en/destinations/denmark/esbjerg"
},
{
"id": "d3cb1d93-2dbb-4320-b66c-8f79799815df",
"name": "Roskilde",
"hotelIds": ["745"],
"hotelCount": 1,
"url": "/en/destinations/denmark/roskilde"
},
{
"id": "c785c63d-024b-423a-93cf-bdb66b74fcf1",
"name": "Aalborg",
"hotelIds": ["720", "735"],
"hotelCount": 2,
"url": "/en/destinations/denmark/aalborg"
},
{
"id": "31803fa2-8714-44e2-aaf9-8103ba8f68c2",
"name": "Herning",
"hotelIds": ["746"],
"hotelCount": 1,
"url": "/en/destinations/denmark/herning"
},
{
"id": "66a6fa85-31b7-432d-beac-31ee87e555f8",
"name": "Odense",
"hotelIds": ["748"],
"hotelCount": 1,
"url": "/en/destinations/denmark/odense"
},
{
"id": "b0d93658-79cd-45c8-ab74-675d1df712cc",
"name": "Silkeborg",
"hotelIds": ["747"],
"hotelCount": 1,
"url": "/en/destinations/denmark/silkeborg"
},
{
"id": "e803306e-53bd-4ddf-9495-1cabe3882291",
"name": "Sønderborg",
"hotelIds": ["728"],
"hotelCount": 1,
"url": "/en/destinations/denmark/sonderborg"
},
{
"id": "26bdbf43-1888-466f-a805-1e7900b31936",
"name": "Aarhus",
"hotelIds": ["736", "726", "738"],
"hotelCount": 3,
"url": "/en/destinations/denmark/aarhus"
}
]
},
{
"country": "Finland",
"countryUrl": "/en/destinations/finland",
"numberOfHotels": 52,
"cities": [
{
"id": "12e3e8c5-2ed4-461d-8a92-92e4612a486e",
"name": "Helsinki",
"hotelIds": [
"638",
"665",
"663",
"622",
"660",
"662",
"661",
"603",
"666",
"605",
"601",
"697",
"634",
"639",
"698",
"643"
],
"hotelCount": 16,
"url": "/en/destinations/finland/helsinki"
},
{
"id": "5e6afbbc-c2f4-4506-b770-230f9cab70d1",
"name": "Joensuu",
"hotelIds": ["688"],
"hotelCount": 1,
"url": "/en/destinations/finland/joensuu"
},
{
"id": "4cc21629-4006-4a2a-b3bd-26a1687ca36d",
"name": "Nokia",
"hotelIds": ["679"],
"hotelCount": 1,
"url": "/en/destinations/finland/nokia"
},
{
"id": "8fea27c8-ab24-4cb5-a8ab-03f422aed8de",
"name": "Hyvinkää",
"hotelIds": ["668"],
"hotelCount": 1,
"url": "/en/destinations/finland/hyvinkaa"
},
{
"id": "92edb3ae-1174-4c77-99bd-de5919abb927",
"name": "Imatra",
"hotelIds": ["696"],
"hotelCount": 1,
"url": "/en/destinations/finland/imatra"
},
{
"id": "a43392b9-928d-466d-a54f-8c4f50623b0f",
"name": "Kemi",
"hotelIds": ["693"],
"hotelCount": 1,
"url": "/en/destinations/finland/kemi"
},
{
"id": "d4513390-9892-4f82-82a1-f26c2c5907e3",
"name": "Kouvola",
"hotelIds": ["672"],
"hotelCount": 1,
"url": "/en/destinations/finland/kouvola"
},
{
"id": "5438b466-d430-4d95-a1fd-4dd5e091155c",
"name": "Ruka Kuusamo",
"hotelIds": ["691"],
"hotelCount": 1,
"url": "/en/destinations/finland/ruka-kuusamo"
},
{
"id": "ed1efb68-418c-4ed3-868d-0eaa892a90aa",
"name": "Oulu",
"hotelIds": ["692", "624"],
"hotelCount": 2,
"url": "/en/destinations/finland/oulu"
},
{
"id": "b8b92fe2-068c-41d5-b60f-2afbbd76e871",
"name": "Hämeenlinna",
"hotelIds": ["669", "670"],
"hotelCount": 2,
"url": "/en/destinations/finland/hameenlinna"
},
{
"id": "5b68f747-941a-4f54-8898-f230cbca544a",
"name": "Kuopio",
"hotelIds": ["689", "609"],
"hotelCount": 2,
"url": "/en/destinations/finland/kuopio"
},
{
"id": "6abcbf56-7f7d-440b-94cd-f1ebb8c82180",
"name": "Turku",
"hotelIds": ["640", "619", "629"],
"hotelCount": 3,
"url": "/en/destinations/finland/turku"
},
{
"id": "302f1cca-4f17-44f8-8fb9-ff30a3518122",
"name": "Jyväskylä",
"hotelIds": ["675", "676", "608"],
"hotelCount": 3,
"url": "/en/destinations/finland/jyvaskyla"
},
{
"id": "982226bd-2e3d-4477-87a8-c2bb15b214c2",
"name": "Lahti",
"hotelIds": ["667"],
"hotelCount": 1,
"url": "/en/destinations/finland/lahti"
},
{
"id": "bd727bf0-fab3-4059-a0e5-73f2aa31812e",
"name": "Rovaniemi",
"hotelIds": ["695", "694", "626"],
"hotelCount": 3,
"url": "/en/destinations/finland/rovaniemi"
},
{
"id": "a327b828-08b9-40a4-b353-2cd4e593e9d6",
"name": "Mikkeli",
"hotelIds": ["674"],
"hotelCount": 1,
"url": "/en/destinations/finland/mikkeli"
},
{
"id": "8edc7525-2c19-4ab3-8c5f-a7e0e600fb7f",
"name": "Seinäjoki",
"hotelIds": ["687"],
"hotelCount": 1,
"url": "/en/destinations/finland/seinajoki"
},
{
"id": "0d1e9054-8d69-44ad-8ce1-6ab310ff63f1",
"name": "Vaasa",
"hotelIds": ["637", "686"],
"hotelCount": 2,
"url": "/en/destinations/finland/vaasa"
},
{
"id": "ce945a1d-d267-47e7-a76e-c064c4a248e6",
"name": "Tampere",
"hotelIds": ["617", "677", "607", "678", "635"],
"hotelCount": 5,
"url": "/en/destinations/finland/tampere"
},
{
"id": "9db040cf-660d-483c-89fb-d26ce8d108ba",
"name": "Rauma",
"hotelIds": ["684"],
"hotelCount": 1,
"url": "/en/destinations/finland/rauma"
},
{
"id": "57db6f36-76f0-4d3a-b3d7-1ee5c86424e5",
"name": "Espoo",
"hotelIds": ["611"],
"hotelCount": 1,
"url": "/en/destinations/finland/espoo"
},
{
"id": "f617ea8d-7cd1-4c94-a8ff-7ab83968a74f",
"name": "Lappeenranta",
"hotelIds": ["615"],
"hotelCount": 1,
"url": "/en/destinations/finland/lappeenranta"
},
{
"id": "3f666b63-2d7b-4477-9bf1-b438d69063e5",
"name": "Pori",
"hotelIds": ["628"],
"hotelCount": 1,
"url": "/en/destinations/finland/pori"
}
]
},
{
"country": "Germany",
"countryUrl": "/en/destinations/germany",
"numberOfHotels": 8,
"cities": [
{
"id": "35b2df9b-261a-4086-ac20-b6d459f62e48",
"name": "Frankfurt",
"hotelIds": ["555", "556"],
"hotelCount": 2,
"url": "/en/destinations/germany/frankfurt"
},
{
"id": "7a5f9827-4756-4855-ba9d-4b08154c8b16",
"name": "Berlin",
"hotelIds": ["554", "551"],
"hotelCount": 2,
"url": "/en/destinations/germany/berlin"
},
{
"id": "7e096ff0-4532-4914-b810-91506e53bae9",
"name": "Hamburg",
"hotelIds": ["550"],
"hotelCount": 1,
"url": "/en/destinations/germany/hamburg"
},
{
"id": "c1dc412f-a61b-447c-a7a7-961b78b4c91a",
"name": "Munich",
"hotelIds": ["557"],
"hotelCount": 1,
"url": "/en/destinations/germany/munich"
},
{
"id": "aa66312c-b502-4f28-bd68-6e8181e2ecce",
"name": "Nuremberg",
"hotelIds": ["558"],
"hotelCount": 1,
"url": "/en/destinations/germany/nuremberg"
},
{
"id": "bb6dc0bf-9ecd-419b-97ce-9507ed68d445",
"name": "Stuttgart",
"hotelIds": ["559"],
"hotelCount": 1,
"url": "/en/destinations/germany/stuttgart"
}
]
},
{
"country": "Norway",
"countryUrl": "/en/destinations/norway",
"numberOfHotels": 81,
"cities": [
{
"id": "8ccac885-b41b-4444-8a2e-63690c36aacc",
"name": "Fredrikstad",
"hotelIds": ["360"],
"hotelCount": 1,
"url": "/en/destinations/norway/fredrikstad"
},
{
"id": "38344052-799a-445e-ae51-e04ac11b39ab",
"name": "Bodo",
"hotelIds": ["312", "314"],
"hotelCount": 2,
"url": "/en/destinations/norway/bodo"
},
{
"id": "b90c090a-9846-43d3-87f1-d76563486870",
"name": "Hønefoss",
"hotelIds": ["389"],
"hotelCount": 1,
"url": "/en/destinations/norway/honefoss"
},
{
"id": "7ca5b1b6-1755-4bd9-bd87-5c33b54977df",
"name": "Harstad",
"hotelIds": ["363"],
"hotelCount": 1,
"url": "/en/destinations/norway/harstad"
},
{
"id": "c32cc08b-e1d7-44ad-b477-80f97bf75a24",
"name": "Sarpsborg",
"hotelIds": ["345"],
"hotelCount": 1,
"url": "/en/destinations/norway/sarpsborg"
},
{
"id": "683a416f-768d-4c90-9a6f-7fc472a3fa26",
"name": "Hamar",
"hotelIds": ["756"],
"hotelCount": 1,
"url": "/en/destinations/norway/hamar"
},
{
"id": "96cb5fdf-b75a-4535-aeff-76ef264efb03",
"name": "Haugesund",
"hotelIds": ["772"],
"hotelCount": 1,
"url": "/en/destinations/norway/haugesund"
},
{
"id": "587a39de-1dd8-4aae-89f0-e038a3fb4e3a",
"name": "Forde",
"hotelIds": ["321"],
"hotelCount": 1,
"url": "/en/destinations/norway/forde"
},
{
"id": "070e2474-72a8-4994-a2ab-6f632a574b6f",
"name": "Molde",
"hotelIds": ["317", "793"],
"hotelCount": 2,
"url": "/en/destinations/norway/molde"
},
{
"id": "f0165c88-fcf1-4df6-80a1-a87c2a6703ae",
"name": "Trondheim",
"hotelIds": ["320", "764", "771", "380", "315", "316"],
"hotelCount": 6,
"url": "/en/destinations/norway/trondheim"
},
{
"id": "59aa618b-ecea-49d1-8e2f-ca6ad2c7311d",
"name": "Fauske",
"hotelIds": ["374"],
"hotelCount": 1,
"url": "/en/destinations/norway/fauske"
},
{
"id": "37445e57-119c-4f04-aa67-8755f627305c",
"name": "Kristiansand",
"hotelIds": ["788", "780"],
"hotelCount": 2,
"url": "/en/destinations/norway/kristiansand"
},
{
"id": "5d281d10-4e10-4cd2-aa0f-590b43a76484",
"name": "Hammerfest",
"hotelIds": ["307"],
"hotelCount": 1,
"url": "/en/destinations/norway/hammerfest"
},
{
"id": "7e980f6b-215b-48d9-b335-c257878c2d6f",
"name": "Mo i Rana",
"hotelIds": ["367"],
"hotelCount": 1,
"url": "/en/destinations/norway/mo-i-rana"
},
{
"id": "2b961bce-ea4e-4ade-9157-6d7293f203d4",
"name": "Drammen",
"hotelIds": ["786"],
"hotelCount": 1,
"url": "/en/destinations/norway/drammen"
},
{
"id": "a153f15b-80ed-4e1f-b6ed-a5dc98f4aeb1",
"name": "Lillehammer",
"hotelIds": ["789", "790", "343"],
"hotelCount": 3,
"url": "/en/destinations/norway/lillehammer"
},
{
"id": "4f4ee73b-c84b-4a1b-abaa-eca8ed5e098d",
"name": "Oslo",
"hotelIds": [
"339",
"340",
"766",
"342",
"751",
"390",
"333",
"776",
"337",
"391",
"332",
"784",
"759",
"760",
"336",
"773",
"334",
"774",
"765"
],
"hotelCount": 19,
"url": "/en/destinations/norway/oslo"
},
{
"id": "019dc867-7668-4422-bc2f-cf1ff8f3402e",
"name": "Narvik",
"hotelIds": ["313"],
"hotelCount": 1,
"url": "/en/destinations/norway/narvik"
},
{
"id": "770c186f-2857-49f1-b394-a4ae49e39f92",
"name": "Honningsvag",
"hotelIds": ["304", "308", "303"],
"hotelCount": 3,
"url": "/en/destinations/norway/honningsvag"
},
{
"id": "5b4af0d6-f411-403d-8814-05d3c59824ad",
"name": "Sandnessjoen",
"hotelIds": ["365"],
"hotelCount": 1,
"url": "/en/destinations/norway/sandnessjoen"
},
{
"id": "0e2b1f95-ed0f-433f-a7da-c34dd73f54d7",
"name": "Lofoten",
"hotelIds": ["791", "311", "387"],
"hotelCount": 3,
"url": "/en/destinations/norway/lofoten"
},
{
"id": "9d524265-8ce4-4e52-a935-1e6f1215b9b6",
"name": "Stavanger",
"hotelIds": ["795", "325", "781", "323", "775"],
"hotelCount": 5,
"url": "/en/destinations/norway/stavanger"
},
{
"id": "69a9e544-436f-4e35-959c-68930a42d660",
"name": "Bergen",
"hotelIds": ["757", "322", "770", "778", "785", "782", "326", "779"],
"hotelCount": 8,
"url": "/en/destinations/norway/bergen"
},
{
"id": "9237f007-8c20-4ae2-b118-e4ca5f062964",
"name": "Karasjok",
"hotelIds": ["305"],
"hotelCount": 1,
"url": "/en/destinations/norway/karasjok"
},
{
"id": "448a9763-9a67-4017-b5a5-014a5fa113cc",
"name": "Vadsø",
"hotelIds": ["302"],
"hotelCount": 1,
"url": "/en/destinations/norway/vadso"
},
{
"id": "d13dcbb3-3ff9-44b9-b2e6-9ce880722fdf",
"name": "Tromso",
"hotelIds": ["362", "310", "796"],
"hotelCount": 3,
"url": "/en/destinations/norway/tromso"
},
{
"id": "08e3ea79-5c31-496a-a00e-13f9b1f3b2d7",
"name": "Alesund",
"hotelIds": ["368"],
"hotelCount": 1,
"url": "/en/destinations/norway/alesund"
},
{
"id": "8e23516f-f9e6-494f-8b32-389f7ab2396b",
"name": "Namsos",
"hotelIds": ["318"],
"hotelCount": 1,
"url": "/en/destinations/norway/namsos"
},
{
"id": "893efa9c-c1ed-40d2-be53-3f6f5dc415dd",
"name": "Alta",
"hotelIds": ["301"],
"hotelCount": 1,
"url": "/en/destinations/norway/alta"
},
{
"id": "5aba4564-5cb2-4084-85ce-29ef896ada84",
"name": "Fagernes",
"hotelIds": ["787"],
"hotelCount": 1,
"url": "/en/destinations/norway/fagernes"
},
{
"id": "25d89282-243f-4286-a024-0afa6e856a46",
"name": "Kirkenes",
"hotelIds": ["306"],
"hotelCount": 1,
"url": "/en/destinations/norway/kirkenes"
},
{
"id": "385a64cd-963b-4a49-8992-06057ad80f09",
"name": "Mysen",
"hotelIds": ["388"],
"hotelCount": 1,
"url": "/en/destinations/norway/mysen"
},
{
"id": "eae26fc6-55e5-4e77-96bf-6f5488c697f0",
"name": "Kristiansund",
"hotelIds": ["319"],
"hotelCount": 1,
"url": "/en/destinations/norway/kristiansund"
},
{
"id": "3415e231-3de1-459d-aed4-d3b023ab8736",
"name": "Voss",
"hotelIds": ["792"],
"hotelCount": 1,
"url": "/en/destinations/norway/voss"
},
{
"id": "b1e5c2f9-1efd-4cf6-ad04-cc74e530a559",
"name": "Sandefjord",
"hotelIds": ["329"],
"hotelCount": 1,
"url": "/en/destinations/norway/sandefjord"
},
{
"id": "a45d077c-d626-4e54-a1ff-199b3454d6c8",
"name": "Sortland",
"hotelIds": ["359"],
"hotelCount": 1,
"url": "/en/destinations/norway/sortland"
}
]
},
{
"country": "Poland",
"countryUrl": "/en/destinations/poland",
"numberOfHotels": 2,
"cities": [
{
"id": "a37d9204-f407-435f-af1a-d29a577bf722",
"name": "Wroclaw",
"hotelIds": ["442"],
"hotelCount": 1,
"url": "/en/destinations/poland/wroclaw"
},
{
"id": "ae62ef46-f085-46ee-b8e1-7d8c1f4ae524",
"name": "Gdansk",
"hotelIds": ["441"],
"hotelCount": 1,
"url": "/en/destinations/poland/gdansk"
}
]
},
{
"country": "Sweden",
"countryUrl": "/en/destinations/sweden",
"numberOfHotels": 89,
"cities": [
{
"id": "3d813c56-c135-4591-864b-40ea845df716",
"name": "Halmstad",
"hotelIds": ["839"],
"hotelCount": 1,
"url": "/en/destinations/sweden/halmstad"
},
{
"id": "4911e413-6ef4-4050-84b9-a8aab23e9398",
"name": "Karlstad",
"hotelIds": ["848", "876", "832"],
"hotelCount": 3,
"url": "/en/destinations/sweden/karlstad"
},
{
"id": "8bd4b2d1-1fa2-44ea-8fe5-85d113087b0e",
"name": "Gavle",
"hotelIds": ["871", "883"],
"hotelCount": 2,
"url": "/en/destinations/sweden/gavle"
},
{
"id": "b3c820e4-a626-469d-a855-7dbb6d13c204",
"name": "Linkoping",
"hotelIds": ["863", "887", "872"],
"hotelCount": 3,
"url": "/en/destinations/sweden/linkoping"
},
{
"id": "b4abfc11-c7d8-4900-86c1-23dd06d2c1a8",
"name": "Karlskrona",
"hotelIds": ["843"],
"hotelCount": 1,
"url": "/en/destinations/sweden/karlskrona"
},
{
"id": "a882a807-f45b-46aa-98f0-c1013f263faf",
"name": "Kiruna",
"hotelIds": ["218"],
"hotelCount": 1,
"url": "/en/destinations/sweden/kiruna"
},
{
"id": "37a32ca6-6467-4e75-8810-67edd75143cd",
"name": "Borlange",
"hotelIds": ["824"],
"hotelCount": 1,
"url": "/en/destinations/sweden/borlange"
},
{
"id": "0e8b7038-43cd-41e3-b326-458fc7f0ea55",
"name": "Bollnas",
"hotelIds": ["873"],
"hotelCount": 1,
"url": "/en/destinations/sweden/bollnas"
},
{
"id": "3c320b63-23e3-4a58-9303-8c26aff1d099",
"name": "Falun",
"hotelIds": ["844"],
"hotelCount": 1,
"url": "/en/destinations/sweden/falun"
},
{
"id": "6bace0a2-5cb1-4f4f-9c6f-a226c66fad00",
"name": "Kalmar",
"hotelIds": ["847"],
"hotelCount": 1,
"url": "/en/destinations/sweden/kalmar"
},
{
"id": "4d160eda-7b41-4b6f-a2c1-0b05f43c99cb",
"name": "Arvika",
"hotelIds": ["845"],
"hotelCount": 1,
"url": "/en/destinations/sweden/arvika"
},
{
"id": "afe92243-181e-4670-8b63-434c8a312ff6",
"name": "Boras",
"hotelIds": ["840"],
"hotelCount": 1,
"url": "/en/destinations/sweden/boras"
},
{
"id": "133f953d-7f44-4a84-bc07-025d97968c7a",
"name": "Helsingborg",
"hotelIds": ["217", "855"],
"hotelCount": 2,
"url": "/en/destinations/sweden/helsingborg"
},
{
"id": "4785b5f2-352b-4170-b897-0aa8730c8cde",
"name": "Skelleftea",
"hotelIds": ["823"],
"hotelCount": 1,
"url": "/en/destinations/sweden/skelleftea"
},
{
"id": "a8149b40-0684-45bc-8934-e92c15248936",
"name": "Gällivare",
"hotelIds": ["891"],
"hotelCount": 1,
"url": "/en/destinations/sweden/gallivare"
},
{
"id": "ba0c9c38-e85e-4b7c-b446-0ee81292b86f",
"name": "Umea",
"hotelIds": ["870", "882"],
"hotelCount": 2,
"url": "/en/destinations/sweden/umea"
},
{
"id": "604a0898-6f74-4617-9e5c-b4696307f192",
"name": "Gothenburg",
"hotelIds": [
"841",
"851",
"867",
"806",
"216",
"817",
"801",
"816",
"215"
],
"hotelCount": 9,
"url": "/en/destinations/sweden/gothenburg"
},
{
"id": "9ac2a592-736e-429e-9c14-e79afc90d5a1",
"name": "Ostersund",
"hotelIds": ["859"],
"hotelCount": 1,
"url": "/en/destinations/sweden/ostersund"
},
{
"id": "da422de7-8b1c-4b09-afa9-be83c7926ba4",
"name": "Stockholm",
"hotelIds": [
"220",
"821",
"830",
"810",
"222",
"879",
"890",
"213",
"809",
"811",
"865",
"857",
"808",
"211",
"833",
"812",
"813",
"803",
"814",
"214",
"805",
"838",
"886",
"826",
"875",
"802",
"223"
],
"hotelCount": 27,
"url": "/en/destinations/sweden/stockholm"
},
{
"id": "e39cd918-3839-4241-9a38-fb7dfa08864e",
"name": "Lulea",
"hotelIds": ["868"],
"hotelCount": 1,
"url": "/en/destinations/sweden/lulea"
},
{
"id": "3183d862-f2d6-46cf-b7dd-ebcc6b1095fd",
"name": "Ornskoldsvik",
"hotelIds": ["828"],
"hotelCount": 1,
"url": "/en/destinations/sweden/ornskoldsvik"
},
{
"id": "536841e9-9b73-4fd9-ad53-60a74e6369b5",
"name": "Trollhattan",
"hotelIds": ["850"],
"hotelCount": 1,
"url": "/en/destinations/sweden/trollhattan"
},
{
"id": "44af613a-915c-421f-a0d2-0d6c91b6aa8c",
"name": "Norrkoping",
"hotelIds": ["827", "852"],
"hotelCount": 2,
"url": "/en/destinations/sweden/norrkoping"
},
{
"id": "76d6c322-3d4f-4c32-9482-d9732c8a9ed3",
"name": "Uppsala",
"hotelIds": ["861", "885"],
"hotelCount": 2,
"url": "/en/destinations/sweden/uppsala"
},
{
"id": "a96719fa-e987-4a8f-8526-30d21b456e6b",
"name": "Vasteras",
"hotelIds": ["866"],
"hotelCount": 1,
"url": "/en/destinations/sweden/vasteras"
},
{
"id": "bcf7553e-d55d-4b66-bea4-b46ef38d920f",
"name": "Skovde",
"hotelIds": ["889"],
"hotelCount": 1,
"url": "/en/destinations/sweden/skovde"
},
{
"id": "789dceb5-6ae7-4747-adcc-1e7d0a97c30c",
"name": "Södertälje",
"hotelIds": ["854"],
"hotelCount": 1,
"url": "/en/destinations/sweden/sodertalje"
},
{
"id": "efc013bf-f9fb-4849-8b78-27b242b048f0",
"name": "Orebro",
"hotelIds": ["219", "869", "836"],
"hotelCount": 3,
"url": "/en/destinations/sweden/orebro"
},
{
"id": "49b7b0c0-d9fe-4ed9-afc7-bce59987598d",
"name": "Stromstad",
"hotelIds": ["888"],
"hotelCount": 1,
"url": "/en/destinations/sweden/stromstad"
},
{
"id": "dfd5a6f7-3533-4759-99e5-025c04a4fa6b",
"name": "Lund",
"hotelIds": ["858"],
"hotelCount": 1,
"url": "/en/destinations/sweden/lund"
},
{
"id": "0cb510bc-b0d4-4319-9d08-46b36768ced3",
"name": "Sundsvall",
"hotelIds": ["834", "853"],
"hotelCount": 2,
"url": "/en/destinations/sweden/sundsvall"
},
{
"id": "c823bc34-e52f-4266-b9c6-1241127c8e9f",
"name": "Varnamo",
"hotelIds": ["842"],
"hotelCount": 1,
"url": "/en/destinations/sweden/varnamo"
},
{
"id": "ee825ab9-9279-4317-9055-8103282e0fac",
"name": "Jonkoping",
"hotelIds": ["856", "846"],
"hotelCount": 2,
"url": "/en/destinations/sweden/jonkoping"
},
{
"id": "6a3f8b4f-add3-49ba-8f65-663d3214443b",
"name": "Malmo",
"hotelIds": ["881", "874", "878", "818", "849", "864"],
"hotelCount": 6,
"url": "/en/destinations/sweden/malmo"
},
{
"id": "b75826c3-9a74-482a-9b2d-0d948e35c3b4",
"name": "Nykoping",
"hotelIds": ["829"],
"hotelCount": 1,
"url": "/en/destinations/sweden/nykoping"
},
{
"id": "f22a6934-cae3-43b5-9d0b-7c49bfae499b",
"name": "Vaxjo",
"hotelIds": ["860"],
"hotelCount": 1,
"url": "/en/destinations/sweden/vaxjo"
},
{
"id": "05e249f9-fd97-4622-8cd5-d3ca43add3dc",
"name": "Visby",
"hotelIds": ["877"],
"hotelCount": 1,
"url": "/en/destinations/sweden/visby"
}
]
}
]

View File

@@ -1,945 +0,0 @@
[
{
"country": "Suomi",
"countryUrl": "/fi/kohteet/suomi",
"numberOfHotels": 52,
"cities": [
{
"id": "5e6afbbc-c2f4-4506-b770-230f9cab70d1",
"name": "Joensuu",
"hotelIds": ["688"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/joensuu"
},
{
"id": "12e3e8c5-2ed4-461d-8a92-92e4612a486e",
"name": "Helsinki",
"hotelIds": [
"638",
"665",
"663",
"622",
"660",
"662",
"661",
"603",
"666",
"605",
"601",
"697",
"634",
"639",
"698",
"643"
],
"hotelCount": 16,
"url": "/fi/kohteet/suomi/helsinki"
},
{
"id": "92edb3ae-1174-4c77-99bd-de5919abb927",
"name": "Imatra",
"hotelIds": ["696"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/imatra"
},
{
"id": "8fea27c8-ab24-4cb5-a8ab-03f422aed8de",
"name": "Hyvinkää",
"hotelIds": ["668"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/hyvinkaa"
},
{
"id": "a43392b9-928d-466d-a54f-8c4f50623b0f",
"name": "Kemi",
"hotelIds": ["693"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/kemi"
},
{
"id": "4cc21629-4006-4a2a-b3bd-26a1687ca36d",
"name": "Nokia",
"hotelIds": ["679"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/nokia"
},
{
"id": "d4513390-9892-4f82-82a1-f26c2c5907e3",
"name": "Kouvola",
"hotelIds": ["672"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/kouvola"
},
{
"id": "5438b466-d430-4d95-a1fd-4dd5e091155c",
"name": "Ruka Kuusamo",
"hotelIds": ["691"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/ruka-kuusamo"
},
{
"id": "ed1efb68-418c-4ed3-868d-0eaa892a90aa",
"name": "Oulu",
"hotelIds": ["692", "624"],
"hotelCount": 2,
"url": "/fi/kohteet/suomi/oulu"
},
{
"id": "b8b92fe2-068c-41d5-b60f-2afbbd76e871",
"name": "Hämeenlinna",
"hotelIds": ["669", "670"],
"hotelCount": 2,
"url": "/fi/kohteet/suomi/hameenlinna"
},
{
"id": "5b68f747-941a-4f54-8898-f230cbca544a",
"name": "Kuopio",
"hotelIds": ["689", "609"],
"hotelCount": 2,
"url": "/fi/kohteet/suomi/kuopio"
},
{
"id": "6abcbf56-7f7d-440b-94cd-f1ebb8c82180",
"name": "Turku",
"hotelIds": ["640", "619", "629"],
"hotelCount": 3,
"url": "/fi/kohteet/suomi/turku"
},
{
"id": "302f1cca-4f17-44f8-8fb9-ff30a3518122",
"name": "Jyväskylä",
"hotelIds": ["675", "676", "608"],
"hotelCount": 3,
"url": "/fi/kohteet/suomi/jyvaskyla"
},
{
"id": "982226bd-2e3d-4477-87a8-c2bb15b214c2",
"name": "Lahti",
"hotelIds": ["667"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/lahti"
},
{
"id": "a327b828-08b9-40a4-b353-2cd4e593e9d6",
"name": "Mikkeli",
"hotelIds": ["674"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/mikkeli"
},
{
"id": "bd727bf0-fab3-4059-a0e5-73f2aa31812e",
"name": "Rovaniemi",
"hotelIds": ["695", "694", "626"],
"hotelCount": 3,
"url": "/fi/kohteet/suomi/rovaniemi"
},
{
"id": "0d1e9054-8d69-44ad-8ce1-6ab310ff63f1",
"name": "Vaasa",
"hotelIds": ["637", "686"],
"hotelCount": 2,
"url": "/fi/kohteet/suomi/vaasa"
},
{
"id": "8edc7525-2c19-4ab3-8c5f-a7e0e600fb7f",
"name": "Seinäjoki",
"hotelIds": ["687"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/seinajoki"
},
{
"id": "ce945a1d-d267-47e7-a76e-c064c4a248e6",
"name": "Tampere",
"hotelIds": ["617", "677", "607", "678", "635"],
"hotelCount": 5,
"url": "/fi/kohteet/suomi/tampere"
},
{
"id": "9db040cf-660d-483c-89fb-d26ce8d108ba",
"name": "Rauma",
"hotelIds": ["684"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/rauma"
},
{
"id": "57db6f36-76f0-4d3a-b3d7-1ee5c86424e5",
"name": "Espoo",
"hotelIds": ["611"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/espoo"
},
{
"id": "f617ea8d-7cd1-4c94-a8ff-7ab83968a74f",
"name": "Lappeenranta",
"hotelIds": ["615"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/lappeenranta"
},
{
"id": "3f666b63-2d7b-4477-9bf1-b438d69063e5",
"name": "Pori",
"hotelIds": ["628"],
"hotelCount": 1,
"url": "/fi/kohteet/suomi/pori"
}
]
},
{
"country": "Norja",
"countryUrl": "/fi/kohteet/norja",
"numberOfHotels": 81,
"cities": [
{
"id": "8ccac885-b41b-4444-8a2e-63690c36aacc",
"name": "Fredrikstad",
"hotelIds": ["360"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/fredrikstad"
},
{
"id": "38344052-799a-445e-ae51-e04ac11b39ab",
"name": "Bodø",
"hotelIds": ["312", "314"],
"hotelCount": 2,
"url": "/fi/kohteet/norja/bodo"
},
{
"id": "b90c090a-9846-43d3-87f1-d76563486870",
"name": "Hønefoss",
"hotelIds": ["389"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/honefoss"
},
{
"id": "7ca5b1b6-1755-4bd9-bd87-5c33b54977df",
"name": "Harstad",
"hotelIds": ["363"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/harstad"
},
{
"id": "96cb5fdf-b75a-4535-aeff-76ef264efb03",
"name": "Haugesund",
"hotelIds": ["772"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/haugesund"
},
{
"id": "587a39de-1dd8-4aae-89f0-e038a3fb4e3a",
"name": "Forde",
"hotelIds": ["321"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/forde"
},
{
"id": "683a416f-768d-4c90-9a6f-7fc472a3fa26",
"name": "Hamar",
"hotelIds": ["756"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/hamar"
},
{
"id": "c32cc08b-e1d7-44ad-b477-80f97bf75a24",
"name": "Sarpsborg",
"hotelIds": ["345"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/sarpsborg"
},
{
"id": "070e2474-72a8-4994-a2ab-6f632a574b6f",
"name": "Molde",
"hotelIds": ["317", "793"],
"hotelCount": 2,
"url": "/fi/kohteet/norja/molde"
},
{
"id": "f0165c88-fcf1-4df6-80a1-a87c2a6703ae",
"name": "Trondheim",
"hotelIds": ["320", "764", "771", "380", "315", "316"],
"hotelCount": 6,
"url": "/fi/kohteet/norja/trondheim"
},
{
"id": "59aa618b-ecea-49d1-8e2f-ca6ad2c7311d",
"name": "Fauske",
"hotelIds": ["374"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/fauske"
},
{
"id": "37445e57-119c-4f04-aa67-8755f627305c",
"name": "Kristiansand",
"hotelIds": ["788", "780"],
"hotelCount": 2,
"url": "/fi/kohteet/norja/kristiansand"
},
{
"id": "5d281d10-4e10-4cd2-aa0f-590b43a76484",
"name": "Hammerfest",
"hotelIds": ["307"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/hammerfest"
},
{
"id": "7e980f6b-215b-48d9-b335-c257878c2d6f",
"name": "Mo i Rana",
"hotelIds": ["367"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/mo-i-rana"
},
{
"id": "2b961bce-ea4e-4ade-9157-6d7293f203d4",
"name": "Drammen",
"hotelIds": ["786"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/drammen"
},
{
"id": "a153f15b-80ed-4e1f-b6ed-a5dc98f4aeb1",
"name": "Lillehammer",
"hotelIds": ["789", "790", "343"],
"hotelCount": 3,
"url": "/fi/kohteet/norja/lillehammer"
},
{
"id": "4f4ee73b-c84b-4a1b-abaa-eca8ed5e098d",
"name": "Oslo",
"hotelIds": [
"339",
"340",
"766",
"342",
"751",
"390",
"333",
"776",
"337",
"391",
"332",
"784",
"759",
"760",
"336",
"773",
"334",
"774",
"765"
],
"hotelCount": 19,
"url": "/fi/kohteet/norja/oslo"
},
{
"id": "019dc867-7668-4422-bc2f-cf1ff8f3402e",
"name": "Narvik",
"hotelIds": ["313"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/narvik"
},
{
"id": "770c186f-2857-49f1-b394-a4ae49e39f92",
"name": "Honningsvåg",
"hotelIds": ["304", "308", "303"],
"hotelCount": 3,
"url": "/fi/kohteet/norja/honningsvag"
},
{
"id": "0e2b1f95-ed0f-433f-a7da-c34dd73f54d7",
"name": "Lofootit",
"hotelIds": ["791", "311", "387"],
"hotelCount": 3,
"url": "/fi/kohteet/norja/lofootit"
},
{
"id": "5b4af0d6-f411-403d-8814-05d3c59824ad",
"name": "Sandnessjoen",
"hotelIds": ["365"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/sandnessjoen"
},
{
"id": "9d524265-8ce4-4e52-a935-1e6f1215b9b6",
"name": "Stavanger",
"hotelIds": ["795", "325", "781", "323", "775"],
"hotelCount": 5,
"url": "/fi/kohteet/norja/stavanger"
},
{
"id": "69a9e544-436f-4e35-959c-68930a42d660",
"name": "Bergen",
"hotelIds": ["757", "322", "770", "778", "785", "782", "326", "779"],
"hotelCount": 8,
"url": "/fi/kohteet/norja/bergen"
},
{
"id": "8e23516f-f9e6-494f-8b32-389f7ab2396b",
"name": "Namsos",
"hotelIds": ["318"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/namsos"
},
{
"id": "9237f007-8c20-4ae2-b118-e4ca5f062964",
"name": "Kaarasjoki",
"hotelIds": ["305"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/kaarasjoki"
},
{
"id": "d13dcbb3-3ff9-44b9-b2e6-9ce880722fdf",
"name": "Tromssa",
"hotelIds": ["310", "362", "796"],
"hotelCount": 3,
"url": "/fi/kohteet/norja/tromssa"
},
{
"id": "448a9763-9a67-4017-b5a5-014a5fa113cc",
"name": "Vadso",
"hotelIds": ["302"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/vadso"
},
{
"id": "08e3ea79-5c31-496a-a00e-13f9b1f3b2d7",
"name": "Ålesund",
"hotelIds": ["368"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/alesund"
},
{
"id": "893efa9c-c1ed-40d2-be53-3f6f5dc415dd",
"name": "Alta",
"hotelIds": ["301"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/alta"
},
{
"id": "5aba4564-5cb2-4084-85ce-29ef896ada84",
"name": "Fagernes",
"hotelIds": ["787"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/fagernes"
},
{
"id": "25d89282-243f-4286-a024-0afa6e856a46",
"name": "Kirkkoniemi",
"hotelIds": ["306"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/kirkkoniemi"
},
{
"id": "eae26fc6-55e5-4e77-96bf-6f5488c697f0",
"name": "Kristiansund",
"hotelIds": ["319"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/kristiansund"
},
{
"id": "a45d077c-d626-4e54-a1ff-199b3454d6c8",
"name": "Sortland",
"hotelIds": ["359"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/sortland"
},
{
"id": "385a64cd-963b-4a49-8992-06057ad80f09",
"name": "Mysen",
"hotelIds": ["388"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/mysen"
},
{
"id": "3415e231-3de1-459d-aed4-d3b023ab8736",
"name": "Voss",
"hotelIds": ["792"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/voss"
},
{
"id": "b1e5c2f9-1efd-4cf6-ad04-cc74e530a559",
"name": "Sandefjord",
"hotelIds": ["329"],
"hotelCount": 1,
"url": "/fi/kohteet/norja/sandefjord"
}
]
},
{
"country": "Puola",
"countryUrl": "/fi/kohteet/puola",
"numberOfHotels": 2,
"cities": [
{
"id": "a37d9204-f407-435f-af1a-d29a577bf722",
"name": "Wroclaw",
"hotelIds": ["442"],
"hotelCount": 1,
"url": "/fi/kohteet/puola/wroclaw"
},
{
"id": "ae62ef46-f085-46ee-b8e1-7d8c1f4ae524",
"name": "Gdansk",
"hotelIds": ["441"],
"hotelCount": 1,
"url": "/fi/kohteet/Puola/gdansk"
}
]
},
{
"country": "Ruotsi",
"countryUrl": "/fi/kohteet/ruotsi",
"numberOfHotels": 89,
"cities": [
{
"id": "a882a807-f45b-46aa-98f0-c1013f263faf",
"name": "Kiiruna",
"hotelIds": ["218"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/kiiruna"
},
{
"id": "6bace0a2-5cb1-4f4f-9c6f-a226c66fad00",
"name": "Kalmar",
"hotelIds": ["847"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/kalmar"
},
{
"id": "37a32ca6-6467-4e75-8810-67edd75143cd",
"name": "Borlänge",
"hotelIds": ["824"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/borlange"
},
{
"id": "b3c820e4-a626-469d-a855-7dbb6d13c204",
"name": "Linköping",
"hotelIds": ["863", "887", "872"],
"hotelCount": 3,
"url": "/fi/kohteet/ruotsi/linkoping"
},
{
"id": "3c320b63-23e3-4a58-9303-8c26aff1d099",
"name": "Falun",
"hotelIds": ["844"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/falun"
},
{
"id": "0e8b7038-43cd-41e3-b326-458fc7f0ea55",
"name": "Bollnas",
"hotelIds": ["873"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/bollnas"
},
{
"id": "b4abfc11-c7d8-4900-86c1-23dd06d2c1a8",
"name": "Karlskrona",
"hotelIds": ["843"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/karlskrona"
},
{
"id": "3d813c56-c135-4591-864b-40ea845df716",
"name": "Halmstad",
"hotelIds": ["839"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/halmstad"
},
{
"id": "4911e413-6ef4-4050-84b9-a8aab23e9398",
"name": "Karlstad",
"hotelIds": ["848", "876", "832"],
"hotelCount": 3,
"url": "/fi/kohteet/ruotsi/karlstad"
},
{
"id": "8bd4b2d1-1fa2-44ea-8fe5-85d113087b0e",
"name": "Gävle",
"hotelIds": ["871", "883"],
"hotelCount": 2,
"url": "/fi/kohteet/ruotsi/gavle"
},
{
"id": "afe92243-181e-4670-8b63-434c8a312ff6",
"name": "Borås",
"hotelIds": ["840"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/boras"
},
{
"id": "4d160eda-7b41-4b6f-a2c1-0b05f43c99cb",
"name": "Arvika",
"hotelIds": ["845"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/arvika"
},
{
"id": "133f953d-7f44-4a84-bc07-025d97968c7a",
"name": "Helsingborg",
"hotelIds": ["217", "855"],
"hotelCount": 2,
"url": "/fi/kohteet/ruotsi/helsingborg"
},
{
"id": "4785b5f2-352b-4170-b897-0aa8730c8cde",
"name": "Skellefteå",
"hotelIds": ["823"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/skelleftea"
},
{
"id": "da422de7-8b1c-4b09-afa9-be83c7926ba4",
"name": "Tukholma",
"hotelIds": [
"220",
"821",
"830",
"810",
"222",
"879",
"890",
"213",
"809",
"811",
"865",
"857",
"808",
"211",
"833",
"812",
"813",
"803",
"814",
"214",
"805",
"838",
"886",
"826",
"875",
"802",
"223"
],
"hotelCount": 27,
"url": "/fi/kohteet/ruotsi/tukholma"
},
{
"id": "604a0898-6f74-4617-9e5c-b4696307f192",
"name": "Göteborg",
"hotelIds": [
"841",
"851",
"867",
"806",
"216",
"817",
"801",
"816",
"215"
],
"hotelCount": 9,
"url": "/fi/kohteet/ruotsi/goteborg"
},
{
"id": "a8149b40-0684-45bc-8934-e92c15248936",
"name": "Gällivare",
"hotelIds": ["891"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/gallivare"
},
{
"id": "9ac2a592-736e-429e-9c14-e79afc90d5a1",
"name": "Östersund",
"hotelIds": ["859"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/ostersund"
},
{
"id": "e39cd918-3839-4241-9a38-fb7dfa08864e",
"name": "Luulaja",
"hotelIds": ["868"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/luulaja"
},
{
"id": "ba0c9c38-e85e-4b7c-b446-0ee81292b86f",
"name": "Uumaja",
"hotelIds": ["870", "882"],
"hotelCount": 2,
"url": "/fi/kohteet/ruotsi/uumaja"
},
{
"id": "3183d862-f2d6-46cf-b7dd-ebcc6b1095fd",
"name": "Örnsköldsvik",
"hotelIds": ["828"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/ornskoldsvik"
},
{
"id": "536841e9-9b73-4fd9-ad53-60a74e6369b5",
"name": "Trollhättan",
"hotelIds": ["850"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/trollhattan"
},
{
"id": "44af613a-915c-421f-a0d2-0d6c91b6aa8c",
"name": "Norrköping",
"hotelIds": ["827", "852"],
"hotelCount": 2,
"url": "/fi/kohteet/ruotsi/norrkoping"
},
{
"id": "76d6c322-3d4f-4c32-9482-d9732c8a9ed3",
"name": "Uppsala",
"hotelIds": ["861", "885"],
"hotelCount": 2,
"url": "/fi/kohteet/ruotsi/uppsala"
},
{
"id": "a96719fa-e987-4a8f-8526-30d21b456e6b",
"name": "Västerås",
"hotelIds": ["866"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/vasteras"
},
{
"id": "bcf7553e-d55d-4b66-bea4-b46ef38d920f",
"name": "Skövde",
"hotelIds": ["889"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/skovde"
},
{
"id": "dfd5a6f7-3533-4759-99e5-025c04a4fa6b",
"name": "Lund",
"hotelIds": ["858"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/lund"
},
{
"id": "789dceb5-6ae7-4747-adcc-1e7d0a97c30c",
"name": "Södertälje",
"hotelIds": ["854"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/sodertalje"
},
{
"id": "49b7b0c0-d9fe-4ed9-afc7-bce59987598d",
"name": "Strömstad",
"hotelIds": ["888"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/stromstad"
},
{
"id": "efc013bf-f9fb-4849-8b78-27b242b048f0",
"name": "Örebro",
"hotelIds": ["219", "869", "836"],
"hotelCount": 3,
"url": "/fi/kohteet/ruotsi/orebro"
},
{
"id": "0cb510bc-b0d4-4319-9d08-46b36768ced3",
"name": "Sundsvall",
"hotelIds": ["834", "853"],
"hotelCount": 2,
"url": "/fi/kohteet/ruotsi/sundsvall"
},
{
"id": "c823bc34-e52f-4266-b9c6-1241127c8e9f",
"name": "Värnamo",
"hotelIds": ["842"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/varnamo"
},
{
"id": "ee825ab9-9279-4317-9055-8103282e0fac",
"name": "Jönköping",
"hotelIds": ["856", "846"],
"hotelCount": 2,
"url": "/fi/kohteet/ruotsi/jonkoping"
},
{
"id": "6a3f8b4f-add3-49ba-8f65-663d3214443b",
"name": "Malmo",
"hotelIds": ["881", "874", "878", "818", "849", "864"],
"hotelCount": 6,
"url": "/fi/kohteet/ruotsi/malmo"
},
{
"id": "b75826c3-9a74-482a-9b2d-0d948e35c3b4",
"name": "Nyköping",
"hotelIds": ["829"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/nykoping"
},
{
"id": "f22a6934-cae3-43b5-9d0b-7c49bfae499b",
"name": "Växjö",
"hotelIds": ["860"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/vaxjo"
},
{
"id": "05e249f9-fd97-4622-8cd5-d3ca43add3dc",
"name": "Visby",
"hotelIds": ["877"],
"hotelCount": 1,
"url": "/fi/kohteet/ruotsi/visby"
}
]
},
{
"country": "Saksa",
"countryUrl": "/fi/kohteet/saksa",
"numberOfHotels": 8,
"cities": [
{
"id": "35b2df9b-261a-4086-ac20-b6d459f62e48",
"name": "Frankfurt",
"hotelIds": ["555", "556"],
"hotelCount": 2,
"url": "/fi/kohteet/saksa/frankfurt"
},
{
"id": "7a5f9827-4756-4855-ba9d-4b08154c8b16",
"name": "Berliini",
"hotelIds": ["554", "551"],
"hotelCount": 2,
"url": "/fi/kohteet/saksa/berliini"
},
{
"id": "7e096ff0-4532-4914-b810-91506e53bae9",
"name": "Hampuri",
"hotelIds": ["550"],
"hotelCount": 1,
"url": "/fi/kohteet/saksa/hampuri"
},
{
"id": "c1dc412f-a61b-447c-a7a7-961b78b4c91a",
"name": "München",
"hotelIds": ["557"],
"hotelCount": 1,
"url": "/fi/kohteet/saksa/munchen"
},
{
"id": "aa66312c-b502-4f28-bd68-6e8181e2ecce",
"name": "Nürnberg",
"hotelIds": ["558"],
"hotelCount": 1,
"url": "/fi/kohteet/saksa/nurnberg"
},
{
"id": "bb6dc0bf-9ecd-419b-97ce-9507ed68d445",
"name": "Stuttgart",
"hotelIds": ["559"],
"hotelCount": 1,
"url": "/fi/kohteet/saksa/stuttgart"
}
]
},
{
"country": "Tanska",
"countryUrl": "/fi/kohteet/tanska",
"numberOfHotels": 27,
"cities": [
{
"id": "4b00f90a-d76f-4aef-9959-f0574e7f3db7",
"name": "Kolding",
"hotelIds": ["737"],
"hotelCount": 1,
"url": "/fi/kohteet/tanska/kolding"
},
{
"id": "20c2ec6a-aedc-4d00-ad67-86a33664b185",
"name": "Kööpenhamina",
"hotelIds": [
"715",
"739",
"719",
"749",
"714",
"744",
"731",
"721",
"724",
"727",
"723",
"716",
"718"
],
"hotelCount": 13,
"url": "/fi/kohteet/tanska/koopenhamina"
},
{
"id": "2713ad8f-4e12-43eb-a11e-26072ae9d7be",
"name": "Esbjerg",
"hotelIds": ["732"],
"hotelCount": 1,
"url": "/fi/kohteet/tanska/esbjerg"
},
{
"id": "e343ba54-ef17-424d-ad77-312cabf3d5eb",
"name": "Horsens",
"hotelIds": ["713"],
"hotelCount": 1,
"url": "/fi/kohteet/tanska/horsens"
},
{
"id": "1c2557c6-f4fe-4de5-a1bf-db6b2d69e5d7",
"name": "Ringsted",
"hotelIds": ["733"],
"hotelCount": 1,
"url": "/fi/kohteet/tanska/ringsted"
},
{
"id": "c785c63d-024b-423a-93cf-bdb66b74fcf1",
"name": "Aalborg",
"hotelIds": ["720", "735"],
"hotelCount": 2,
"url": "/fi/kohteet/tanska/aalborg"
},
{
"id": "d3cb1d93-2dbb-4320-b66c-8f79799815df",
"name": "Roskilde",
"hotelIds": ["745"],
"hotelCount": 1,
"url": "/fi/kohteet/tanska/roskilde"
},
{
"id": "66a6fa85-31b7-432d-beac-31ee87e555f8",
"name": "Odense",
"hotelIds": ["748"],
"hotelCount": 1,
"url": "/fi/kohteet/tanska/odense"
},
{
"id": "31803fa2-8714-44e2-aaf9-8103ba8f68c2",
"name": "Herning",
"hotelIds": ["746"],
"hotelCount": 1,
"url": "/fi/kohteet/tanska/herning"
},
{
"id": "b0d93658-79cd-45c8-ab74-675d1df712cc",
"name": "Silkeborg",
"hotelIds": ["747"],
"hotelCount": 1,
"url": "/fi/kohteet/tanska/silkeborg"
},
{
"id": "e803306e-53bd-4ddf-9495-1cabe3882291",
"name": "Sønderborg",
"hotelIds": ["728"],
"hotelCount": 1,
"url": "/fi/kohteet/tanska/sonderborg"
},
{
"id": "26bdbf43-1888-466f-a805-1e7900b31936",
"name": "Aarhus",
"hotelIds": ["736", "726", "738"],
"hotelCount": 3,
"url": "/fi/kohteet/tanska/aarhus"
}
]
}
]

View File

@@ -1,945 +0,0 @@
[
{
"country": "Norge",
"countryUrl": "/no/destinasjoner/norge",
"numberOfHotels": 81,
"cities": [
{
"id": "8ccac885-b41b-4444-8a2e-63690c36aacc",
"name": "Fredrikstad",
"hotelIds": ["360"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/fredrikstad"
},
{
"id": "38344052-799a-445e-ae51-e04ac11b39ab",
"name": "Bodø",
"hotelIds": ["312", "314"],
"hotelCount": 2,
"url": "/no/destinasjoner/norge/bodo"
},
{
"id": "b90c090a-9846-43d3-87f1-d76563486870",
"name": "Hønefoss",
"hotelIds": ["389"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/honefoss"
},
{
"id": "7ca5b1b6-1755-4bd9-bd87-5c33b54977df",
"name": "Harstad",
"hotelIds": ["363"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/harstad"
},
{
"id": "587a39de-1dd8-4aae-89f0-e038a3fb4e3a",
"name": "Førde",
"hotelIds": ["321"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/forde"
},
{
"id": "96cb5fdf-b75a-4535-aeff-76ef264efb03",
"name": "Haugesund",
"hotelIds": ["772"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/haugesund"
},
{
"id": "683a416f-768d-4c90-9a6f-7fc472a3fa26",
"name": "Hamar",
"hotelIds": ["756"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/hamar"
},
{
"id": "c32cc08b-e1d7-44ad-b477-80f97bf75a24",
"name": "Sarpsborg",
"hotelIds": ["345"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/sarpsborg"
},
{
"id": "070e2474-72a8-4994-a2ab-6f632a574b6f",
"name": "Molde",
"hotelIds": ["317", "793"],
"hotelCount": 2,
"url": "/no/destinasjoner/norge/molde"
},
{
"id": "59aa618b-ecea-49d1-8e2f-ca6ad2c7311d",
"name": "Fauske",
"hotelIds": ["374"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/fauske"
},
{
"id": "f0165c88-fcf1-4df6-80a1-a87c2a6703ae",
"name": "Trondheim",
"hotelIds": ["320", "764", "771", "380", "315", "316"],
"hotelCount": 6,
"url": "/no/destinasjoner/norge/trondheim"
},
{
"id": "37445e57-119c-4f04-aa67-8755f627305c",
"name": "Kristiansand",
"hotelIds": ["788", "780"],
"hotelCount": 2,
"url": "/no/destinasjoner/norge/kristiansand"
},
{
"id": "7e980f6b-215b-48d9-b335-c257878c2d6f",
"name": "Mo i Rana",
"hotelIds": ["367"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/mo-i-rana"
},
{
"id": "5d281d10-4e10-4cd2-aa0f-590b43a76484",
"name": "Hammerfest",
"hotelIds": ["307"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/hammerfest"
},
{
"id": "2b961bce-ea4e-4ade-9157-6d7293f203d4",
"name": "Drammen",
"hotelIds": ["786"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/drammen"
},
{
"id": "a153f15b-80ed-4e1f-b6ed-a5dc98f4aeb1",
"name": "Lillehammer",
"hotelIds": ["789", "790", "343"],
"hotelCount": 3,
"url": "/no/destinasjoner/norge/lillehammer"
},
{
"id": "4f4ee73b-c84b-4a1b-abaa-eca8ed5e098d",
"name": "Oslo",
"hotelIds": [
"339",
"340",
"766",
"342",
"751",
"390",
"333",
"776",
"337",
"391",
"332",
"784",
"759",
"760",
"336",
"773",
"334",
"774",
"765"
],
"hotelCount": 19,
"url": "/no/destinasjoner/norge/oslo"
},
{
"id": "019dc867-7668-4422-bc2f-cf1ff8f3402e",
"name": "Narvik",
"hotelIds": ["313"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/narvik"
},
{
"id": "770c186f-2857-49f1-b394-a4ae49e39f92",
"name": "Honningsvåg",
"hotelIds": ["304", "308", "303"],
"hotelCount": 3,
"url": "/no/destinasjoner/norge/honningsvag"
},
{
"id": "0e2b1f95-ed0f-433f-a7da-c34dd73f54d7",
"name": "Lofoten",
"hotelIds": ["791", "311", "387"],
"hotelCount": 3,
"url": "/no/destinasjoner/norge/lofoten"
},
{
"id": "5b4af0d6-f411-403d-8814-05d3c59824ad",
"name": "Sandnessjøen",
"hotelIds": ["365"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/sandnessjoen"
},
{
"id": "9d524265-8ce4-4e52-a935-1e6f1215b9b6",
"name": "Stavanger",
"hotelIds": ["795", "325", "781", "323", "775"],
"hotelCount": 5,
"url": "/no/destinasjoner/norge/stavanger"
},
{
"id": "448a9763-9a67-4017-b5a5-014a5fa113cc",
"name": "Vadsø",
"hotelIds": ["302"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/vadso"
},
{
"id": "08e3ea79-5c31-496a-a00e-13f9b1f3b2d7",
"name": "Ålesund",
"hotelIds": ["368"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/alesund"
},
{
"id": "69a9e544-436f-4e35-959c-68930a42d660",
"name": "Bergen",
"hotelIds": ["757", "322", "770", "778", "785", "782", "326", "779"],
"hotelCount": 8,
"url": "/no/destinasjoner/norge/bergen"
},
{
"id": "d13dcbb3-3ff9-44b9-b2e6-9ce880722fdf",
"name": "Tromsø",
"hotelIds": ["310", "362", "796"],
"hotelCount": 3,
"url": "/no/destinasjoner/norge/tromso"
},
{
"id": "9237f007-8c20-4ae2-b118-e4ca5f062964",
"name": "Karasjok",
"hotelIds": ["305"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/karasjok"
},
{
"id": "8e23516f-f9e6-494f-8b32-389f7ab2396b",
"name": "Namsos",
"hotelIds": ["318"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/namsos"
},
{
"id": "893efa9c-c1ed-40d2-be53-3f6f5dc415dd",
"name": "Alta",
"hotelIds": ["301"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/alta"
},
{
"id": "5aba4564-5cb2-4084-85ce-29ef896ada84",
"name": "Fagernes",
"hotelIds": ["787"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/fagernes"
},
{
"id": "25d89282-243f-4286-a024-0afa6e856a46",
"name": "Kirkenes",
"hotelIds": ["306"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/kirkenes"
},
{
"id": "385a64cd-963b-4a49-8992-06057ad80f09",
"name": "Mysen",
"hotelIds": ["388"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/mysen"
},
{
"id": "eae26fc6-55e5-4e77-96bf-6f5488c697f0",
"name": "Kristiansund",
"hotelIds": ["319"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/kristiansund"
},
{
"id": "3415e231-3de1-459d-aed4-d3b023ab8736",
"name": "Voss",
"hotelIds": ["792"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/voss"
},
{
"id": "b1e5c2f9-1efd-4cf6-ad04-cc74e530a559",
"name": "Sandefjord",
"hotelIds": ["329"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/sandefjord"
},
{
"id": "a45d077c-d626-4e54-a1ff-199b3454d6c8",
"name": "Sortland",
"hotelIds": ["359"],
"hotelCount": 1,
"url": "/no/destinasjoner/norge/sortland"
}
]
},
{
"country": "Danmark",
"countryUrl": "/no/destinasjoner/danmark",
"numberOfHotels": 27,
"cities": [
{
"id": "20c2ec6a-aedc-4d00-ad67-86a33664b185",
"name": "København",
"hotelIds": [
"715",
"739",
"719",
"749",
"714",
"744",
"731",
"721",
"724",
"727",
"723",
"716",
"718"
],
"hotelCount": 13,
"url": "/no/destinasjoner/danmark/kobenhavn"
},
{
"id": "4b00f90a-d76f-4aef-9959-f0574e7f3db7",
"name": "Kolding",
"hotelIds": ["737"],
"hotelCount": 1,
"url": "/no/destinasjoner/danmark/kolding"
},
{
"id": "2713ad8f-4e12-43eb-a11e-26072ae9d7be",
"name": "Esbjerg",
"hotelIds": ["732"],
"hotelCount": 1,
"url": "/no/destinasjoner/danmark/esbjerg"
},
{
"id": "e343ba54-ef17-424d-ad77-312cabf3d5eb",
"name": "Horsens",
"hotelIds": ["713"],
"hotelCount": 1,
"url": "/no/destinasjoner/danmark/horsens"
},
{
"id": "1c2557c6-f4fe-4de5-a1bf-db6b2d69e5d7",
"name": "Ringsted",
"hotelIds": ["733"],
"hotelCount": 1,
"url": "/no/destinasjoner/danmark/ringsted"
},
{
"id": "c785c63d-024b-423a-93cf-bdb66b74fcf1",
"name": "Aalborg",
"hotelIds": ["720", "735"],
"hotelCount": 2,
"url": "/no/destinasjoner/danmark/aalborg"
},
{
"id": "d3cb1d93-2dbb-4320-b66c-8f79799815df",
"name": "Roskilde",
"hotelIds": ["745"],
"hotelCount": 1,
"url": "/no/destinasjoner/danmark/roskilde"
},
{
"id": "66a6fa85-31b7-432d-beac-31ee87e555f8",
"name": "Odense",
"hotelIds": ["748"],
"hotelCount": 1,
"url": "/no/destinasjoner/danmark/odense"
},
{
"id": "31803fa2-8714-44e2-aaf9-8103ba8f68c2",
"name": "Herning",
"hotelIds": ["746"],
"hotelCount": 1,
"url": "/no/destinasjoner/danmark/herning"
},
{
"id": "b0d93658-79cd-45c8-ab74-675d1df712cc",
"name": "Silkeborg",
"hotelIds": ["747"],
"hotelCount": 1,
"url": "/no/destinasjoner/danmark/silkeborg"
},
{
"id": "e803306e-53bd-4ddf-9495-1cabe3882291",
"name": "Sønderborg",
"hotelIds": ["728"],
"hotelCount": 1,
"url": "/no/destinasjoner/danmark/sonderborg"
},
{
"id": "26bdbf43-1888-466f-a805-1e7900b31936",
"name": "Aarhus",
"hotelIds": ["736", "726", "738"],
"hotelCount": 3,
"url": "/no/destinasjoner/danmark/aarhus"
}
]
},
{
"country": "Finland",
"countryUrl": "/no/destinasjoner/finland",
"numberOfHotels": 52,
"cities": [
{
"id": "5e6afbbc-c2f4-4506-b770-230f9cab70d1",
"name": "Joensuu",
"hotelIds": ["688"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/joensuu"
},
{
"id": "12e3e8c5-2ed4-461d-8a92-92e4612a486e",
"name": "Helsinki",
"hotelIds": [
"638",
"665",
"663",
"622",
"660",
"662",
"661",
"603",
"666",
"605",
"601",
"697",
"634",
"639",
"698",
"643"
],
"hotelCount": 16,
"url": "/no/destinasjoner/finland/helsinki"
},
{
"id": "92edb3ae-1174-4c77-99bd-de5919abb927",
"name": "Imatra",
"hotelIds": ["696"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/imatra"
},
{
"id": "8fea27c8-ab24-4cb5-a8ab-03f422aed8de",
"name": "Hyvinkää",
"hotelIds": ["668"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/hyvinkaa"
},
{
"id": "a43392b9-928d-466d-a54f-8c4f50623b0f",
"name": "Kemi",
"hotelIds": ["693"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/kemi"
},
{
"id": "4cc21629-4006-4a2a-b3bd-26a1687ca36d",
"name": "Nokia",
"hotelIds": ["679"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/nokia"
},
{
"id": "d4513390-9892-4f82-82a1-f26c2c5907e3",
"name": "Kouvola",
"hotelIds": ["672"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/kouvola"
},
{
"id": "5438b466-d430-4d95-a1fd-4dd5e091155c",
"name": "Ruka Kuusamo",
"hotelIds": ["691"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/ruka-kuusamo"
},
{
"id": "ed1efb68-418c-4ed3-868d-0eaa892a90aa",
"name": "Oulu",
"hotelIds": ["692", "624"],
"hotelCount": 2,
"url": "/no/destinasjoner/finland/oulu"
},
{
"id": "b8b92fe2-068c-41d5-b60f-2afbbd76e871",
"name": "Hämeenlinna",
"hotelIds": ["669", "670"],
"hotelCount": 2,
"url": "/no/destinasjoner/finland/hameenlinna"
},
{
"id": "5b68f747-941a-4f54-8898-f230cbca544a",
"name": "Kuopio",
"hotelIds": ["689", "609"],
"hotelCount": 2,
"url": "/no/destinasjoner/finland/kuopio"
},
{
"id": "6abcbf56-7f7d-440b-94cd-f1ebb8c82180",
"name": "Turku",
"hotelIds": ["640", "619", "629"],
"hotelCount": 3,
"url": "/no/destinasjoner/finland/turku"
},
{
"id": "302f1cca-4f17-44f8-8fb9-ff30a3518122",
"name": "Jyväskylä",
"hotelIds": ["675", "676", "608"],
"hotelCount": 3,
"url": "/no/destinasjoner/finland/jyvaskyla"
},
{
"id": "a327b828-08b9-40a4-b353-2cd4e593e9d6",
"name": "Mikkeli",
"hotelIds": ["674"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/mikkeli"
},
{
"id": "bd727bf0-fab3-4059-a0e5-73f2aa31812e",
"name": "Rovaniemi",
"hotelIds": ["695", "694", "626"],
"hotelCount": 3,
"url": "/no/destinasjoner/finland/rovaniemi"
},
{
"id": "982226bd-2e3d-4477-87a8-c2bb15b214c2",
"name": "Lahti",
"hotelIds": ["667"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/lahti"
},
{
"id": "0d1e9054-8d69-44ad-8ce1-6ab310ff63f1",
"name": "Vaasa",
"hotelIds": ["637", "686"],
"hotelCount": 2,
"url": "/no/destinasjoner/finland/vaasa"
},
{
"id": "8edc7525-2c19-4ab3-8c5f-a7e0e600fb7f",
"name": "Seinäjoki",
"hotelIds": ["687"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/seinajoki"
},
{
"id": "ce945a1d-d267-47e7-a76e-c064c4a248e6",
"name": "Tampere",
"hotelIds": ["617", "677", "607", "678", "635"],
"hotelCount": 5,
"url": "/no/destinasjoner/finland/tampere"
},
{
"id": "9db040cf-660d-483c-89fb-d26ce8d108ba",
"name": "Rauma",
"hotelIds": ["684"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/rauma"
},
{
"id": "57db6f36-76f0-4d3a-b3d7-1ee5c86424e5",
"name": "Espoo",
"hotelIds": ["611"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/espoo"
},
{
"id": "f617ea8d-7cd1-4c94-a8ff-7ab83968a74f",
"name": "Lappeenranta",
"hotelIds": ["615"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/lappeenranta"
},
{
"id": "3f666b63-2d7b-4477-9bf1-b438d69063e5",
"name": "Pori",
"hotelIds": ["628"],
"hotelCount": 1,
"url": "/no/destinasjoner/finland/pori"
}
]
},
{
"country": "Polen",
"countryUrl": "/no/destinasjoner/polen",
"numberOfHotels": 2,
"cities": [
{
"id": "a37d9204-f407-435f-af1a-d29a577bf722",
"name": "Wroclaw",
"hotelIds": ["442"],
"hotelCount": 1,
"url": "/no/destinasjoner/polen/wroclaw"
},
{
"id": "ae62ef46-f085-46ee-b8e1-7d8c1f4ae524",
"name": "Gdansk",
"hotelIds": ["441"],
"hotelCount": 1,
"url": "/no/destinasjoner/polen/gdansk"
}
]
},
{
"country": "Sverige",
"countryUrl": "/no/destinasjoner/sverige",
"numberOfHotels": 89,
"cities": [
{
"id": "6bace0a2-5cb1-4f4f-9c6f-a226c66fad00",
"name": "Kalmar",
"hotelIds": ["847"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/kalmar"
},
{
"id": "37a32ca6-6467-4e75-8810-67edd75143cd",
"name": "Borlange",
"hotelIds": ["824"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/borlange"
},
{
"id": "a882a807-f45b-46aa-98f0-c1013f263faf",
"name": "Kiruna",
"hotelIds": ["218"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/kiruna"
},
{
"id": "b3c820e4-a626-469d-a855-7dbb6d13c204",
"name": "Linköping",
"hotelIds": ["863", "887", "872"],
"hotelCount": 3,
"url": "/no/destinasjoner/sverige/linkoping"
},
{
"id": "4911e413-6ef4-4050-84b9-a8aab23e9398",
"name": "Karlstad",
"hotelIds": ["848", "876", "832"],
"hotelCount": 3,
"url": "/no/destinasjoner/sverige/karlstad"
},
{
"id": "3c320b63-23e3-4a58-9303-8c26aff1d099",
"name": "Falun",
"hotelIds": ["844"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/falun"
},
{
"id": "b4abfc11-c7d8-4900-86c1-23dd06d2c1a8",
"name": "Karlskrona",
"hotelIds": ["843"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/karlskrona"
},
{
"id": "3d813c56-c135-4591-864b-40ea845df716",
"name": "Halmstad",
"hotelIds": ["839"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/halmstad"
},
{
"id": "0e8b7038-43cd-41e3-b326-458fc7f0ea55",
"name": "Bollnas",
"hotelIds": ["873"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/bollnas"
},
{
"id": "8bd4b2d1-1fa2-44ea-8fe5-85d113087b0e",
"name": "Gävle",
"hotelIds": ["871", "883"],
"hotelCount": 2,
"url": "/no/destinasjoner/sverige/gavle"
},
{
"id": "afe92243-181e-4670-8b63-434c8a312ff6",
"name": "Borås",
"hotelIds": ["840"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/boras"
},
{
"id": "4d160eda-7b41-4b6f-a2c1-0b05f43c99cb",
"name": "Arvika",
"hotelIds": ["845"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/arvika"
},
{
"id": "4785b5f2-352b-4170-b897-0aa8730c8cde",
"name": "Skellefteå",
"hotelIds": ["823"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/skelleftea"
},
{
"id": "133f953d-7f44-4a84-bc07-025d97968c7a",
"name": "Helsingborg",
"hotelIds": ["217", "855"],
"hotelCount": 2,
"url": "/no/destinasjoner/sverige/helsingborg"
},
{
"id": "604a0898-6f74-4617-9e5c-b4696307f192",
"name": "Gøteborg",
"hotelIds": [
"841",
"851",
"867",
"806",
"216",
"817",
"801",
"816",
"215"
],
"hotelCount": 9,
"url": "/no/destinasjoner/sverige/goteborg"
},
{
"id": "a8149b40-0684-45bc-8934-e92c15248936",
"name": "Gällivare",
"hotelIds": ["891"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/gallivare"
},
{
"id": "da422de7-8b1c-4b09-afa9-be83c7926ba4",
"name": "Stockholm",
"hotelIds": [
"220",
"821",
"830",
"810",
"222",
"879",
"890",
"213",
"809",
"811",
"865",
"857",
"808",
"211",
"833",
"812",
"813",
"803",
"814",
"214",
"805",
"838",
"886",
"826",
"875",
"802",
"223"
],
"hotelCount": 27,
"url": "/no/destinasjoner/sverige/stockholm"
},
{
"id": "9ac2a592-736e-429e-9c14-e79afc90d5a1",
"name": "Östersund",
"hotelIds": ["859"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/ostersund"
},
{
"id": "ba0c9c38-e85e-4b7c-b446-0ee81292b86f",
"name": "Umeå",
"hotelIds": ["870", "882"],
"hotelCount": 2,
"url": "/no/destinasjoner/sverige/umea"
},
{
"id": "e39cd918-3839-4241-9a38-fb7dfa08864e",
"name": "Luleå",
"hotelIds": ["868"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/lulea"
},
{
"id": "3183d862-f2d6-46cf-b7dd-ebcc6b1095fd",
"name": "Örnsköldsvik",
"hotelIds": ["828"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/ornskoldsvik"
},
{
"id": "536841e9-9b73-4fd9-ad53-60a74e6369b5",
"name": "Trollhättan",
"hotelIds": ["850"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/trollhattan"
},
{
"id": "44af613a-915c-421f-a0d2-0d6c91b6aa8c",
"name": "Norrköping",
"hotelIds": ["827", "852"],
"hotelCount": 2,
"url": "/no/destinasjoner/sverige/norrkoping"
},
{
"id": "bcf7553e-d55d-4b66-bea4-b46ef38d920f",
"name": "Skovde",
"hotelIds": ["889"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/skovde"
},
{
"id": "76d6c322-3d4f-4c32-9482-d9732c8a9ed3",
"name": "Uppsala",
"hotelIds": ["861", "885"],
"hotelCount": 2,
"url": "/no/destinasjoner/sverige/uppsala"
},
{
"id": "a96719fa-e987-4a8f-8526-30d21b456e6b",
"name": "Västerås",
"hotelIds": ["866"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/vasteras"
},
{
"id": "789dceb5-6ae7-4747-adcc-1e7d0a97c30c",
"name": "Södertälje",
"hotelIds": ["854"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/sodertalje"
},
{
"id": "dfd5a6f7-3533-4759-99e5-025c04a4fa6b",
"name": "Lund",
"hotelIds": ["858"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/lund"
},
{
"id": "49b7b0c0-d9fe-4ed9-afc7-bce59987598d",
"name": "Strømstad",
"hotelIds": ["888"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/stromstad"
},
{
"id": "efc013bf-f9fb-4849-8b78-27b242b048f0",
"name": "Örebro",
"hotelIds": ["219", "869", "836"],
"hotelCount": 3,
"url": "/no/destinasjoner/sverige/orebro"
},
{
"id": "0cb510bc-b0d4-4319-9d08-46b36768ced3",
"name": "Sundsvall",
"hotelIds": ["834", "853"],
"hotelCount": 2,
"url": "/no/destinasjoner/sverige/sundsvall"
},
{
"id": "c823bc34-e52f-4266-b9c6-1241127c8e9f",
"name": "Varnamo",
"hotelIds": ["842"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/varnamo"
},
{
"id": "ee825ab9-9279-4317-9055-8103282e0fac",
"name": "Jönköping",
"hotelIds": ["856", "846"],
"hotelCount": 2,
"url": "/no/destinasjoner/sverige/jonkoping"
},
{
"id": "6a3f8b4f-add3-49ba-8f65-663d3214443b",
"name": "Malmö",
"hotelIds": ["881", "874", "878", "818", "849", "864"],
"hotelCount": 6,
"url": "/no/destinasjoner/sverige/malmo"
},
{
"id": "b75826c3-9a74-482a-9b2d-0d948e35c3b4",
"name": "Nyköping",
"hotelIds": ["829"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/nykoping"
},
{
"id": "05e249f9-fd97-4622-8cd5-d3ca43add3dc",
"name": "Visby",
"hotelIds": ["877"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/visby"
},
{
"id": "f22a6934-cae3-43b5-9d0b-7c49bfae499b",
"name": "Växjö",
"hotelIds": ["860"],
"hotelCount": 1,
"url": "/no/destinasjoner/sverige/vaxjo"
}
]
},
{
"country": "Tyskland",
"countryUrl": "/no/destinasjoner/tyskland",
"numberOfHotels": 8,
"cities": [
{
"id": "35b2df9b-261a-4086-ac20-b6d459f62e48",
"name": "Frankfurt",
"hotelIds": ["555", "556"],
"hotelCount": 2,
"url": "/no/destinasjoner/tyskland/frankfurt"
},
{
"id": "7a5f9827-4756-4855-ba9d-4b08154c8b16",
"name": "Berlin",
"hotelIds": ["554", "551"],
"hotelCount": 2,
"url": "/no/destinasjoner/tyskland/berlin"
},
{
"id": "7e096ff0-4532-4914-b810-91506e53bae9",
"name": "Hamburg",
"hotelIds": ["550"],
"hotelCount": 1,
"url": "/no/destinasjoner/tyskland/hamburg"
},
{
"id": "c1dc412f-a61b-447c-a7a7-961b78b4c91a",
"name": "München",
"hotelIds": ["557"],
"hotelCount": 1,
"url": "/no/destinasjoner/tyskland/munchen"
},
{
"id": "aa66312c-b502-4f28-bd68-6e8181e2ecce",
"name": "Nürnberg",
"hotelIds": ["558"],
"hotelCount": 1,
"url": "/no/destinasjoner/tyskland/nurnberg"
},
{
"id": "bb6dc0bf-9ecd-419b-97ce-9507ed68d445",
"name": "Stuttgart",
"hotelIds": ["559"],
"hotelCount": 1,
"url": "/no/destinasjoner/tyskland/stuttgart"
}
]
}
]

View File

@@ -1,945 +0,0 @@
[
{
"country": "Sverige",
"countryUrl": "/sv/destinationer/sverige",
"numberOfHotels": 89,
"cities": [
{
"id": "37a32ca6-6467-4e75-8810-67edd75143cd",
"name": "Borlänge",
"hotelIds": ["824"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/borlange"
},
{
"id": "3c320b63-23e3-4a58-9303-8c26aff1d099",
"name": "Falun",
"hotelIds": ["844"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/falun"
},
{
"id": "b3c820e4-a626-469d-a855-7dbb6d13c204",
"name": "Linköping",
"hotelIds": ["863", "887", "872"],
"hotelCount": 3,
"url": "/sv/destinationer/sverige/linkoping"
},
{
"id": "a882a807-f45b-46aa-98f0-c1013f263faf",
"name": "Kiruna",
"hotelIds": ["218"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/kiruna"
},
{
"id": "3d813c56-c135-4591-864b-40ea845df716",
"name": "Halmstad",
"hotelIds": ["839"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/halmstad"
},
{
"id": "b4abfc11-c7d8-4900-86c1-23dd06d2c1a8",
"name": "Karlskrona",
"hotelIds": ["843"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/karlskrona"
},
{
"id": "4911e413-6ef4-4050-84b9-a8aab23e9398",
"name": "Karlstad",
"hotelIds": ["848", "876", "832"],
"hotelCount": 3,
"url": "/sv/destinationer/sverige/karlstad"
},
{
"id": "0e8b7038-43cd-41e3-b326-458fc7f0ea55",
"name": "Bollnäs",
"hotelIds": ["873"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/bollnas"
},
{
"id": "8bd4b2d1-1fa2-44ea-8fe5-85d113087b0e",
"name": "Gävle",
"hotelIds": ["871", "883"],
"hotelCount": 2,
"url": "/sv/destinationer/sverige/gavle"
},
{
"id": "6bace0a2-5cb1-4f4f-9c6f-a226c66fad00",
"name": "Kalmar",
"hotelIds": ["847"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/kalmar"
},
{
"id": "4d160eda-7b41-4b6f-a2c1-0b05f43c99cb",
"name": "Arvika",
"hotelIds": ["845"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/arvika"
},
{
"id": "afe92243-181e-4670-8b63-434c8a312ff6",
"name": "Borås",
"hotelIds": ["840"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/boras"
},
{
"id": "4785b5f2-352b-4170-b897-0aa8730c8cde",
"name": "Skellefteå",
"hotelIds": ["823"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/skelleftea"
},
{
"id": "133f953d-7f44-4a84-bc07-025d97968c7a",
"name": "Helsingborg",
"hotelIds": ["217", "855"],
"hotelCount": 2,
"url": "/sv/destinationer/sverige/helsingborg"
},
{
"id": "da422de7-8b1c-4b09-afa9-be83c7926ba4",
"name": "Stockholm",
"hotelIds": [
"220",
"821",
"830",
"810",
"222",
"879",
"890",
"213",
"809",
"811",
"865",
"857",
"808",
"211",
"833",
"812",
"813",
"803",
"814",
"214",
"805",
"838",
"886",
"826",
"875",
"802",
"223"
],
"hotelCount": 27,
"url": "/sv/destinationer/sverige/stockholm"
},
{
"id": "604a0898-6f74-4617-9e5c-b4696307f192",
"name": "Göteborg",
"hotelIds": [
"841",
"851",
"867",
"806",
"216",
"817",
"801",
"816",
"215"
],
"hotelCount": 9,
"url": "/sv/destinationer/sverige/goteborg"
},
{
"id": "a8149b40-0684-45bc-8934-e92c15248936",
"name": "Gällivare",
"hotelIds": ["891"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/gallivare"
},
{
"id": "ba0c9c38-e85e-4b7c-b446-0ee81292b86f",
"name": "Umeå",
"hotelIds": ["870", "882"],
"hotelCount": 2,
"url": "/sv/destinationer/sverige/umea"
},
{
"id": "9ac2a592-736e-429e-9c14-e79afc90d5a1",
"name": "Östersund",
"hotelIds": ["859"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/ostersund"
},
{
"id": "e39cd918-3839-4241-9a38-fb7dfa08864e",
"name": "Luleå",
"hotelIds": ["868"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/lulea"
},
{
"id": "3183d862-f2d6-46cf-b7dd-ebcc6b1095fd",
"name": "Örnsköldsvik",
"hotelIds": ["828"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/ornskoldsvik"
},
{
"id": "536841e9-9b73-4fd9-ad53-60a74e6369b5",
"name": "Trollhättan",
"hotelIds": ["850"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/trollhattan"
},
{
"id": "44af613a-915c-421f-a0d2-0d6c91b6aa8c",
"name": "Norrköping",
"hotelIds": ["827", "852"],
"hotelCount": 2,
"url": "/sv/destinationer/sverige/norrkoping"
},
{
"id": "a96719fa-e987-4a8f-8526-30d21b456e6b",
"name": "Västerås",
"hotelIds": ["866"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/vasteras"
},
{
"id": "76d6c322-3d4f-4c32-9482-d9732c8a9ed3",
"name": "Uppsala",
"hotelIds": ["861", "885"],
"hotelCount": 2,
"url": "/sv/destinationer/sverige/uppsala"
},
{
"id": "bcf7553e-d55d-4b66-bea4-b46ef38d920f",
"name": "Skövde",
"hotelIds": ["889"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/skovde"
},
{
"id": "789dceb5-6ae7-4747-adcc-1e7d0a97c30c",
"name": "Södertälje",
"hotelIds": ["854"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/sodertalje"
},
{
"id": "efc013bf-f9fb-4849-8b78-27b242b048f0",
"name": "Örebro",
"hotelIds": ["219", "869", "836"],
"hotelCount": 3,
"url": "/sv/destinationer/sverige/orebro"
},
{
"id": "dfd5a6f7-3533-4759-99e5-025c04a4fa6b",
"name": "Lund",
"hotelIds": ["858"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/lund"
},
{
"id": "49b7b0c0-d9fe-4ed9-afc7-bce59987598d",
"name": "Strömstad",
"hotelIds": ["888"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/stromstad"
},
{
"id": "0cb510bc-b0d4-4319-9d08-46b36768ced3",
"name": "Sundsvall",
"hotelIds": ["834", "853"],
"hotelCount": 2,
"url": "/sv/destinationer/sverige/sundsvall"
},
{
"id": "c823bc34-e52f-4266-b9c6-1241127c8e9f",
"name": "Värnamo",
"hotelIds": ["842"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/varnamo"
},
{
"id": "ee825ab9-9279-4317-9055-8103282e0fac",
"name": "Jönköping",
"hotelIds": ["856", "846"],
"hotelCount": 2,
"url": "/sv/destinationer/sverige/jonkoping"
},
{
"id": "6a3f8b4f-add3-49ba-8f65-663d3214443b",
"name": "Malmö",
"hotelIds": ["881", "874", "878", "818", "849", "864"],
"hotelCount": 6,
"url": "/sv/destinationer/sverige/malmo"
},
{
"id": "b75826c3-9a74-482a-9b2d-0d948e35c3b4",
"name": "Nyköping",
"hotelIds": ["829"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/nykoping"
},
{
"id": "05e249f9-fd97-4622-8cd5-d3ca43add3dc",
"name": "Visby",
"hotelIds": ["877"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/visby"
},
{
"id": "f22a6934-cae3-43b5-9d0b-7c49bfae499b",
"name": "Växjö",
"hotelIds": ["860"],
"hotelCount": 1,
"url": "/sv/destinationer/sverige/vaxjo"
}
]
},
{
"country": "Danmark",
"countryUrl": "/sv/destinationer/danmark",
"numberOfHotels": 27,
"cities": [
{
"id": "20c2ec6a-aedc-4d00-ad67-86a33664b185",
"name": "Köpenhamn",
"hotelIds": [
"715",
"739",
"719",
"749",
"714",
"744",
"731",
"721",
"724",
"727",
"723",
"716",
"718"
],
"hotelCount": 13,
"url": "/sv/destinationer/danmark/kopenhamn"
},
{
"id": "4b00f90a-d76f-4aef-9959-f0574e7f3db7",
"name": "Kolding",
"hotelIds": ["737"],
"hotelCount": 1,
"url": "/sv/destinationer/danmark/kolding"
},
{
"id": "1c2557c6-f4fe-4de5-a1bf-db6b2d69e5d7",
"name": "Ringsted",
"hotelIds": ["733"],
"hotelCount": 1,
"url": "/sv/destinationer/danmark/ringsted"
},
{
"id": "e343ba54-ef17-424d-ad77-312cabf3d5eb",
"name": "Horsens",
"hotelIds": ["713"],
"hotelCount": 1,
"url": "/sv/destinationer/danmark/horsens"
},
{
"id": "2713ad8f-4e12-43eb-a11e-26072ae9d7be",
"name": "Esbjerg",
"hotelIds": ["732"],
"hotelCount": 1,
"url": "/sv/destinationer/danmark/esbjerg"
},
{
"id": "d3cb1d93-2dbb-4320-b66c-8f79799815df",
"name": "Roskilde",
"hotelIds": ["745"],
"hotelCount": 1,
"url": "/sv/destinationer/danmark/roskilde"
},
{
"id": "c785c63d-024b-423a-93cf-bdb66b74fcf1",
"name": "Ålborg",
"hotelIds": ["720", "735"],
"hotelCount": 2,
"url": "/sv/destinationer/danmark/alborg"
},
{
"id": "66a6fa85-31b7-432d-beac-31ee87e555f8",
"name": "Odense",
"hotelIds": ["748"],
"hotelCount": 1,
"url": "/sv/destinationer/danmark/odense"
},
{
"id": "31803fa2-8714-44e2-aaf9-8103ba8f68c2",
"name": "Herning",
"hotelIds": ["746"],
"hotelCount": 1,
"url": "/sv/destinationer/danmark/herning"
},
{
"id": "b0d93658-79cd-45c8-ab74-675d1df712cc",
"name": "Silkeborg",
"hotelIds": ["747"],
"hotelCount": 1,
"url": "/sv/destinationer/danmark/silkeborg"
},
{
"id": "e803306e-53bd-4ddf-9495-1cabe3882291",
"name": "Sønderborg",
"hotelIds": ["728"],
"hotelCount": 1,
"url": "/sv/destinationer/danmark/sonderborg"
},
{
"id": "26bdbf43-1888-466f-a805-1e7900b31936",
"name": "Århus",
"hotelIds": ["736", "726", "738"],
"hotelCount": 3,
"url": "/sv/destinationer/danmark/arhus"
}
]
},
{
"country": "Finland",
"countryUrl": "/sv/destinationer/finland",
"numberOfHotels": 52,
"cities": [
{
"id": "5e6afbbc-c2f4-4506-b770-230f9cab70d1",
"name": "Joensuu",
"hotelIds": ["688"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/joensuu"
},
{
"id": "12e3e8c5-2ed4-461d-8a92-92e4612a486e",
"name": "Helsingfors",
"hotelIds": [
"638",
"665",
"663",
"622",
"660",
"662",
"661",
"603",
"666",
"605",
"601",
"697",
"634",
"639",
"698",
"643"
],
"hotelCount": 16,
"url": "/sv/destinationer/finland/helsingfors"
},
{
"id": "8fea27c8-ab24-4cb5-a8ab-03f422aed8de",
"name": "Hyvinge",
"hotelIds": ["668"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/hyvinge"
},
{
"id": "92edb3ae-1174-4c77-99bd-de5919abb927",
"name": "Imatra",
"hotelIds": ["696"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/imatra"
},
{
"id": "4cc21629-4006-4a2a-b3bd-26a1687ca36d",
"name": "Nokia",
"hotelIds": ["679"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/nokia"
},
{
"id": "a43392b9-928d-466d-a54f-8c4f50623b0f",
"name": "Kemi",
"hotelIds": ["693"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/kemi"
},
{
"id": "d4513390-9892-4f82-82a1-f26c2c5907e3",
"name": "Kouvola",
"hotelIds": ["672"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/kouvola"
},
{
"id": "5438b466-d430-4d95-a1fd-4dd5e091155c",
"name": "Ruka Kuusamo",
"hotelIds": ["691"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/ruka-kuusamo"
},
{
"id": "ed1efb68-418c-4ed3-868d-0eaa892a90aa",
"name": "Uleåborg",
"hotelIds": ["692", "624"],
"hotelCount": 2,
"url": "/sv/destinationer/finland/uleaborg"
},
{
"id": "b8b92fe2-068c-41d5-b60f-2afbbd76e871",
"name": "Tavastehus",
"hotelIds": ["669", "670"],
"hotelCount": 2,
"url": "/sv/destinationer/finland/tavastehus"
},
{
"id": "5b68f747-941a-4f54-8898-f230cbca544a",
"name": "Kuopio",
"hotelIds": ["689", "609"],
"hotelCount": 2,
"url": "/sv/destinationer/finland/kuopio"
},
{
"id": "6abcbf56-7f7d-440b-94cd-f1ebb8c82180",
"name": "Åbo",
"hotelIds": ["640", "619", "629"],
"hotelCount": 3,
"url": "/sv/destinationer/finland/abo"
},
{
"id": "302f1cca-4f17-44f8-8fb9-ff30a3518122",
"name": "Jyväskylä",
"hotelIds": ["675", "676", "608"],
"hotelCount": 3,
"url": "/sv/destinationer/finland/jyvaskyla"
},
{
"id": "982226bd-2e3d-4477-87a8-c2bb15b214c2",
"name": "Lahtis",
"hotelIds": ["667"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/lahtis"
},
{
"id": "bd727bf0-fab3-4059-a0e5-73f2aa31812e",
"name": "Rovaniemi",
"hotelIds": ["695", "694", "626"],
"hotelCount": 3,
"url": "/sv/destinationer/finland/rovaniemi"
},
{
"id": "a327b828-08b9-40a4-b353-2cd4e593e9d6",
"name": "Mikkeli",
"hotelIds": ["674"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/mikkeli"
},
{
"id": "0d1e9054-8d69-44ad-8ce1-6ab310ff63f1",
"name": "Vasa",
"hotelIds": ["637", "686"],
"hotelCount": 2,
"url": "/sv/destinationer/finland/vasa"
},
{
"id": "ce945a1d-d267-47e7-a76e-c064c4a248e6",
"name": "Tammerfors",
"hotelIds": ["617", "677", "607", "678", "635"],
"hotelCount": 5,
"url": "/sv/destinationer/finland/tammerfors"
},
{
"id": "8edc7525-2c19-4ab3-8c5f-a7e0e600fb7f",
"name": "Seinäjoki",
"hotelIds": ["687"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/seinajoki"
},
{
"id": "9db040cf-660d-483c-89fb-d26ce8d108ba",
"name": "Raumo",
"hotelIds": ["684"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/raumo"
},
{
"id": "57db6f36-76f0-4d3a-b3d7-1ee5c86424e5",
"name": "Esbo",
"hotelIds": ["611"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/esbo"
},
{
"id": "f617ea8d-7cd1-4c94-a8ff-7ab83968a74f",
"name": "Villmanstrand",
"hotelIds": ["615"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/villmanstrand"
},
{
"id": "3f666b63-2d7b-4477-9bf1-b438d69063e5",
"name": "Björneborg",
"hotelIds": ["628"],
"hotelCount": 1,
"url": "/sv/destinationer/finland/bjorneborg"
}
]
},
{
"country": "Norge",
"countryUrl": "/sv/destinationer/norge",
"numberOfHotels": 81,
"cities": [
{
"id": "8ccac885-b41b-4444-8a2e-63690c36aacc",
"name": "Fredrikstad",
"hotelIds": ["360"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/fredrikstad"
},
{
"id": "38344052-799a-445e-ae51-e04ac11b39ab",
"name": "Bodø",
"hotelIds": ["312", "314"],
"hotelCount": 2,
"url": "/sv/destinationer/norge/bodo"
},
{
"id": "b90c090a-9846-43d3-87f1-d76563486870",
"name": "Hønefoss",
"hotelIds": ["389"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/honefoss"
},
{
"id": "96cb5fdf-b75a-4535-aeff-76ef264efb03",
"name": "Haugesund",
"hotelIds": ["772"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/haugesund"
},
{
"id": "683a416f-768d-4c90-9a6f-7fc472a3fa26",
"name": "Hamar",
"hotelIds": ["756"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/hamar"
},
{
"id": "c32cc08b-e1d7-44ad-b477-80f97bf75a24",
"name": "Sarpsborg",
"hotelIds": ["345"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/sarpsborg"
},
{
"id": "7ca5b1b6-1755-4bd9-bd87-5c33b54977df",
"name": "Harstad",
"hotelIds": ["363"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/harstad"
},
{
"id": "587a39de-1dd8-4aae-89f0-e038a3fb4e3a",
"name": "Förde",
"hotelIds": ["321"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/forde"
},
{
"id": "f0165c88-fcf1-4df6-80a1-a87c2a6703ae",
"name": "Trondheim",
"hotelIds": ["320", "764", "771", "380", "315", "316"],
"hotelCount": 6,
"url": "/sv/destinationer/norge/trondheim"
},
{
"id": "070e2474-72a8-4994-a2ab-6f632a574b6f",
"name": "Molde",
"hotelIds": ["317", "793"],
"hotelCount": 2,
"url": "/sv/destinationer/norge/molde"
},
{
"id": "59aa618b-ecea-49d1-8e2f-ca6ad2c7311d",
"name": "Fauske",
"hotelIds": ["374"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/fauske"
},
{
"id": "37445e57-119c-4f04-aa67-8755f627305c",
"name": "Kristiansand",
"hotelIds": ["788", "780"],
"hotelCount": 2,
"url": "/sv/destinationer/norge/kristiansand"
},
{
"id": "5d281d10-4e10-4cd2-aa0f-590b43a76484",
"name": "Hammerfest",
"hotelIds": ["307"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/hammerfest"
},
{
"id": "7e980f6b-215b-48d9-b335-c257878c2d6f",
"name": "Mo i Rana",
"hotelIds": ["367"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/mo-i-rana"
},
{
"id": "4f4ee73b-c84b-4a1b-abaa-eca8ed5e098d",
"name": "Oslo",
"hotelIds": [
"339",
"340",
"766",
"342",
"751",
"390",
"333",
"776",
"337",
"391",
"332",
"784",
"759",
"760",
"336",
"773",
"334",
"774",
"765"
],
"hotelCount": 19,
"url": "/sv/destinationer/norge/oslo"
},
{
"id": "019dc867-7668-4422-bc2f-cf1ff8f3402e",
"name": "Narvik",
"hotelIds": ["313"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/narvik"
},
{
"id": "2b961bce-ea4e-4ade-9157-6d7293f203d4",
"name": "Drammen",
"hotelIds": ["786"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/drammen"
},
{
"id": "a153f15b-80ed-4e1f-b6ed-a5dc98f4aeb1",
"name": "Lillehammer",
"hotelIds": ["789", "790", "343"],
"hotelCount": 3,
"url": "/sv/destinationer/norge/lillehammer"
},
{
"id": "770c186f-2857-49f1-b394-a4ae49e39f92",
"name": "Honningsvåg",
"hotelIds": ["304", "308", "303"],
"hotelCount": 3,
"url": "/sv/destinationer/norge/honningsvag"
},
{
"id": "0e2b1f95-ed0f-433f-a7da-c34dd73f54d7",
"name": "Lofoten",
"hotelIds": ["791", "311", "387"],
"hotelCount": 3,
"url": "/sv/destinationer/norge/lofoten"
},
{
"id": "5b4af0d6-f411-403d-8814-05d3c59824ad",
"name": "Sandnessjöen",
"hotelIds": ["365"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/sandnessjoen"
},
{
"id": "9d524265-8ce4-4e52-a935-1e6f1215b9b6",
"name": "Stavanger",
"hotelIds": ["795", "325", "781", "323", "775"],
"hotelCount": 5,
"url": "/sv/destinationer/norge/stavanger"
},
{
"id": "69a9e544-436f-4e35-959c-68930a42d660",
"name": "Bergen",
"hotelIds": ["757", "322", "770", "778", "785", "782", "326", "779"],
"hotelCount": 8,
"url": "/sv/destinationer/norge/bergen"
},
{
"id": "9237f007-8c20-4ae2-b118-e4ca5f062964",
"name": "Karasjok",
"hotelIds": ["305"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/karasjok"
},
{
"id": "08e3ea79-5c31-496a-a00e-13f9b1f3b2d7",
"name": "Ålesund",
"hotelIds": ["368"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/alesund"
},
{
"id": "448a9763-9a67-4017-b5a5-014a5fa113cc",
"name": "Vadsö",
"hotelIds": ["302"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/vadso"
},
{
"id": "d13dcbb3-3ff9-44b9-b2e6-9ce880722fdf",
"name": "Tromsø",
"hotelIds": ["310", "362", "796"],
"hotelCount": 3,
"url": "/sv/destinationer/norge/tromso"
},
{
"id": "8e23516f-f9e6-494f-8b32-389f7ab2396b",
"name": "Namsos",
"hotelIds": ["318"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/namsos"
},
{
"id": "893efa9c-c1ed-40d2-be53-3f6f5dc415dd",
"name": "Alta",
"hotelIds": ["301"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/alta"
},
{
"id": "5aba4564-5cb2-4084-85ce-29ef896ada84",
"name": "Fagernes",
"hotelIds": ["787"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/fagernes"
},
{
"id": "25d89282-243f-4286-a024-0afa6e856a46",
"name": "Kirkenes",
"hotelIds": ["306"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/kirkenes"
},
{
"id": "385a64cd-963b-4a49-8992-06057ad80f09",
"name": "Mysen",
"hotelIds": ["388"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/mysen"
},
{
"id": "eae26fc6-55e5-4e77-96bf-6f5488c697f0",
"name": "Kristiansund",
"hotelIds": ["319"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/kristiansund"
},
{
"id": "3415e231-3de1-459d-aed4-d3b023ab8736",
"name": "Voss",
"hotelIds": ["792"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/voss"
},
{
"id": "b1e5c2f9-1efd-4cf6-ad04-cc74e530a559",
"name": "Sandefjord",
"hotelIds": ["329"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/sandefjord"
},
{
"id": "a45d077c-d626-4e54-a1ff-199b3454d6c8",
"name": "Sortland",
"hotelIds": ["359"],
"hotelCount": 1,
"url": "/sv/destinationer/norge/sortland"
}
]
},
{
"country": "Polen",
"countryUrl": "/sv/destinationer/polen",
"numberOfHotels": 2,
"cities": [
{
"id": "a37d9204-f407-435f-af1a-d29a577bf722",
"name": "Wroclaw",
"hotelIds": ["442"],
"hotelCount": 1,
"url": "/sv/destinationer/polen/wroclaw"
},
{
"id": "ae62ef46-f085-46ee-b8e1-7d8c1f4ae524",
"name": "Gdansk",
"hotelIds": ["441"],
"hotelCount": 1,
"url": "/sv/destinationer/polen/gdansk"
}
]
},
{
"country": "Tyskland",
"countryUrl": "/sv/destinationer/tyskland",
"numberOfHotels": 8,
"cities": [
{
"id": "35b2df9b-261a-4086-ac20-b6d459f62e48",
"name": "Frankfurt",
"hotelIds": ["555", "556"],
"hotelCount": 2,
"url": "/sv/destinationer/tyskland/frankfurt"
},
{
"id": "7a5f9827-4756-4855-ba9d-4b08154c8b16",
"name": "Berlin",
"hotelIds": ["554", "551"],
"hotelCount": 2,
"url": "/sv/destinationer/tyskland/berlin"
},
{
"id": "7e096ff0-4532-4914-b810-91506e53bae9",
"name": "Hamburg",
"hotelIds": ["550"],
"hotelCount": 1,
"url": "/sv/destinationer/tyskland/hamburg"
},
{
"id": "c1dc412f-a61b-447c-a7a7-961b78b4c91a",
"name": "München",
"hotelIds": ["557"],
"hotelCount": 1,
"url": "/sv/destinationer/tyskland/munchen"
},
{
"id": "aa66312c-b502-4f28-bd68-6e8181e2ecce",
"name": "Nürnberg",
"hotelIds": ["558"],
"hotelCount": 1,
"url": "/sv/destinationer/tyskland/nurnberg"
},
{
"id": "bb6dc0bf-9ecd-419b-97ce-9507ed68d445",
"name": "Stuttgart",
"hotelIds": ["559"],
"hotelCount": 1,
"url": "/sv/destinationer/tyskland/stuttgart"
}
]
}
]

View File

@@ -1,7 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { destinationOverviewPageQueryRouter } from "./query"
export const destinationOverviewPageRouter = mergeRouters(
destinationOverviewPageQueryRouter
)

View File

@@ -1,61 +0,0 @@
import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import {
cardGalleryRefsSchema,
cardGallerySchema,
} from "../schemas/blocks/cardGallery"
import { mapLocationSchema } from "../schemas/mapLocation"
import { systemSchema } from "../schemas/system"
import { DestinationOverviewPageEnum } from "@/types/enums/destinationOverviewPage"
const destinationOverviewPageCardGallery = z
.object({
__typename: z.literal(
DestinationOverviewPageEnum.ContentStack.blocks.CardGallery
),
})
.merge(cardGallerySchema)
export const blocksSchema = z.discriminatedUnion("__typename", [
destinationOverviewPageCardGallery,
])
export const destinationOverviewPageSchema = z.object({
destination_overview_page: z.object({
heading: z.string().nullish(),
blocks: discriminatedUnionArray(blocksSchema.options),
location: mapLocationSchema,
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
}),
trackingProps: z.object({
url: z.string(),
}),
})
/** REFS */
const destinationOverviewPageCardGalleryRef = z
.object({
__typename: z.literal(
DestinationOverviewPageEnum.ContentStack.blocks.CardGallery
),
})
.merge(cardGalleryRefsSchema)
const blocksRefsSchema = z.discriminatedUnion("__typename", [
destinationOverviewPageCardGalleryRef,
])
export const destinationOverviewPageRefsSchema = z.object({
destination_overview_page: z.object({
blocks: discriminatedUnionArray(blocksRefsSchema.options).nullable(),
system: systemSchema,
}),
})

View File

@@ -1,276 +0,0 @@
import { Lang } from "@scandic-hotels/common/constants/language"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import {
contentstackExtendedProcedureUID,
serviceProcedure,
} from "@scandic-hotels/trpc/procedures"
import {
GetDestinationOverviewPage,
GetDestinationOverviewPageRefs,
} from "@/lib/graphql/Query/DestinationOverviewPage/DestinationOverviewPage.graphql"
import { request } from "@/lib/graphql/request"
import { generateRefsResponseTag, generateTag } from "@/utils/generateTag"
import {
getCitiesByCountry,
getCountries,
getHotelIdsByCityId,
} from "../../hotels/utils"
import { getCityPageUrls } from "../destinationCityPage/utils"
import { getCountryPageUrls } from "../destinationCountryPage/utils"
import destinationsDataDa from "./destinations-da.json" with { assert: "json" }
import destinationsDataDe from "./destinations-de.json" with { assert: "json" }
import destinationsDataEn from "./destinations-en.json" with { assert: "json" }
import destinationsDataFi from "./destinations-fi.json" with { assert: "json" }
import destinationsDataNo from "./destinations-no.json" with { assert: "json" }
import destinationsDataSv from "./destinations-sv.json" with { assert: "json" }
import {
destinationOverviewPageRefsSchema,
destinationOverviewPageSchema,
} from "./output"
import { getSortedDestinationsByLanguage } from "./utils"
import type {
City,
DestinationsData,
} from "@/types/components/destinationOverviewPage/destinationsList/destinationsData"
import {
TrackingChannelEnum,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import { ApiCountry, type Country } from "@/types/enums/country"
import type {
GetDestinationOverviewPageData,
GetDestinationOverviewPageRefsSchema,
} from "@/types/trpc/routers/contentstack/destinationOverviewPage"
export const destinationOverviewPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const { lang, uid } = ctx
const getDestinationOverviewPageRefsCounter = createCounter(
"trpc.contentstack",
"destinationOverviewPage.get.refs"
)
const metricsGetDestinationOverviewPageRefs =
getDestinationOverviewPageRefsCounter.init({ lang, uid })
metricsGetDestinationOverviewPageRefs.start()
const refsResponse = await request<GetDestinationOverviewPageRefsSchema>(
GetDestinationOverviewPageRefs,
{
locale: lang,
uid,
},
{
key: generateRefsResponseTag(lang, uid),
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
metricsGetDestinationOverviewPageRefs.noDataError()
throw notFoundError
}
const validatedRefsData = destinationOverviewPageRefsSchema.safeParse(
refsResponse.data
)
if (!validatedRefsData.success) {
metricsGetDestinationOverviewPageRefs.validationError(
validatedRefsData.error
)
return null
}
metricsGetDestinationOverviewPageRefs.success()
const getDestinationOverviewPageCounter = createCounter(
"trpc.contentstack",
"destinationOverviewPage.get"
)
const metricsGetDestinationOverviewPage =
getDestinationOverviewPageCounter.init({ lang, uid })
metricsGetDestinationOverviewPage.start()
const response = await request<GetDestinationOverviewPageData>(
GetDestinationOverviewPage,
{
locale: lang,
uid,
},
{
key: generateTag(lang, uid),
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetDestinationOverviewPage.noDataError()
throw notFoundError
}
const destinationOverviewPage = destinationOverviewPageSchema.safeParse(
response.data
)
if (!destinationOverviewPage.success) {
metricsGetDestinationOverviewPage.validationError(
destinationOverviewPage.error
)
return null
}
metricsGetDestinationOverviewPage.success()
const system = destinationOverviewPage.data.destination_overview_page.system
const tracking: TrackingSDKPageData = {
pageId: system.uid,
domainLanguage: lang,
publishDate: system.updated_at,
createDate: system.created_at,
channel: TrackingChannelEnum.hotels,
pageType: "destinationoverviewpage",
pageName: "destinations|overview",
siteSections: "destinations|overview",
siteVersion: "new-web",
}
return {
destinationOverviewPage:
destinationOverviewPage.data.destination_overview_page,
tracking,
}
}),
destinations: router({
get: serviceProcedure.query(async function ({
ctx,
}): Promise<DestinationsData> {
// For go live we are using static data here, as it rarely changes.
// This also improves operational reliance as we are not hammering
// a lot of endpoints for a lot of data.
// Re-implement once we have better API support and established caching
// patterns and mechanisms.
// NOTE: To update the static data set `useStaticData = false`.
// Then go to the "Hotels & Destinations" page and visit every language.
// At the time of commit http://localhost:3000/en/destinations.
// This will update the JSON file locally, each page load for each language,
// if all data loads correctly.
// Set back `useStaticData = true` again and test with the updated JSON file.
// Add, commit and push the updated JSON files with useStaticData = true here.
const useStaticData = true
if (useStaticData) {
switch (ctx.lang) {
case Lang.da:
return getSortedDestinationsByLanguage(destinationsDataDa, ctx.lang)
case Lang.de:
return getSortedDestinationsByLanguage(destinationsDataDe, ctx.lang)
case Lang.fi:
return getSortedDestinationsByLanguage(destinationsDataFi, ctx.lang)
case Lang.en:
return getSortedDestinationsByLanguage(destinationsDataEn, ctx.lang)
case Lang.no:
return getSortedDestinationsByLanguage(destinationsDataNo, ctx.lang)
case Lang.sv:
return getSortedDestinationsByLanguage(destinationsDataSv, ctx.lang)
default:
return []
}
} else {
return await updateJSONOnDisk()
}
async function updateJSONOnDisk() {
const { lang } = ctx
const countries = await getCountries({
lang,
serviceToken: ctx.serviceToken,
})
if (!countries) {
return []
}
const countryNames = countries.data.map((country) => country.name)
const citiesByCountry = await getCitiesByCountry({
lang,
countries: countryNames,
serviceToken: ctx.serviceToken,
})
const cityPages = await getCityPageUrls(lang)
const destinations = await Promise.all(
Object.entries(citiesByCountry).map(async ([country, cities]) => {
const activeCitiesWithHotelCount: (City | null)[] =
await Promise.all(
cities.map(async (city) => {
const [hotels] = await safeTry(
getHotelIdsByCityId({
cityId: city.id,
serviceToken: ctx.serviceToken,
})
)
const cityPage = cityPages.find(
(cityPage) => cityPage.city === city.cityIdentifier
)
return cityPage?.url
? {
id: city.id,
name: city.name,
hotelIds: hotels || [],
hotelCount: hotels ? hotels.length : 0,
url: cityPage.url,
}
: null
})
)
const filteredActiveCitiesWithHotelCount: City[] =
activeCitiesWithHotelCount.filter((c): c is City => !!c)
const countryPages = await getCountryPageUrls(lang)
const countryPage = countryPages.find(
(countryPage) =>
ApiCountry[lang][countryPage.country as Country] === country
)
return {
country,
countryUrl: countryPage?.url,
numberOfHotels: filteredActiveCitiesWithHotelCount.reduce(
(acc, city) => acc + city.hotelCount,
0
),
cities: filteredActiveCitiesWithHotelCount,
}
})
)
const data = getSortedDestinationsByLanguage(destinations, lang)
const fs = await import("node:fs")
fs.writeFileSync(
`./server/routers/contentstack/destinationOverviewPage/destinations-${lang}.json`,
JSON.stringify(data),
{
encoding: "utf-8",
}
)
return data
}
}),
}),
})

View File

@@ -1,56 +0,0 @@
import { Lang } from "@scandic-hotels/common/constants/language"
import type { DestinationsData } from "@/types/components/destinationOverviewPage/destinationsList/destinationsData"
import { ApiCountry, Country } from "@/types/enums/country"
/**
* Sorts destination data based on language preference:
* - en: alphabetical order
* - de: Germany first, then alphabetical
* - da: Denmark first, then alphabetical
* - no: Norway first, then alphabetical
* - sv: Sweden first, then alphabetical
* - fi: Finland first, then alphabetical
*
* @param destinations DestinationsData
* @param language Lang
* @returns Sorted array of destinations
*/
export function getSortedDestinationsByLanguage(
destinations: DestinationsData,
language: Lang
) {
const destinationsToSort = [...destinations]
const firstCountryByLanguage: Record<Lang, Country | null> = {
[Lang.de]: Country.Germany,
[Lang.da]: Country.Denmark,
[Lang.no]: Country.Norway,
[Lang.sv]: Country.Sweden,
[Lang.fi]: Country.Finland,
[Lang.en]: null,
}
const firstCountry = firstCountryByLanguage[language]
// If no country is defined for this language, sort alphabetically
if (!firstCountry) {
return destinationsToSort.sort((a, b) =>
a.country.localeCompare(b.country, language)
)
}
// Get the localized name of the first country
const localizedCountryName = ApiCountry[language][firstCountry]
return destinationsToSort.sort((a, b) => {
if (a.country === localizedCountryName) {
return -1
}
if (b.country === localizedCountryName) {
return 1
}
return a.country.localeCompare(b.country, language)
})
}

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { hotelPageQueryRouter } from "./query"
export const hotelPageRouter = mergeRouters(hotelPageQueryRouter)

View File

@@ -1,156 +0,0 @@
import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import { removeMultipleSlashes } from "@/utils/url"
import {
activitiesCardRefSchema,
activitiesCardSchema,
} from "../schemas/blocks/activitiesCard"
import { hotelFaqRefsSchema, hotelFaqSchema } from "../schemas/blocks/hotelFaq"
import { spaPageRefSchema, spaPageSchema } from "../schemas/blocks/spaPage"
import { systemSchema } from "../schemas/system"
import { HotelPageEnum } from "@/types/enums/hotelPage"
import type {
ActivitiesCard,
SpaPage,
} from "@/types/trpc/routers/contentstack/hotelPage"
const contentBlockActivities = z
.object({
__typename: z.literal(HotelPageEnum.ContentStack.blocks.ActivitiesCard),
})
.merge(activitiesCardSchema)
const contentBlockSpaPage = z
.object({
__typename: z.literal(HotelPageEnum.ContentStack.blocks.SpaPage),
})
.merge(spaPageSchema)
export const contentBlock = z.discriminatedUnion("__typename", [
contentBlockActivities,
contentBlockSpaPage,
])
export const hotelPageSchema = z.object({
hotel_page: z
.object({
hotel_navigation: z
.object({
overview: z.string().nullish(),
rooms: z.string().nullish(),
restaurant_bar: z.string().nullish(),
conferences_meetings: z.string().nullish(),
health_wellness: z.string().nullish(),
activities: z.string().nullish(),
offers: z.string().nullish(),
faq: z.string().nullish(),
})
.nullish(),
content: discriminatedUnionArray(contentBlock.options)
.nullable()
.transform((data) => {
let spaPage: SpaPage | undefined
let activitiesCards: ActivitiesCard[] = []
data?.map((block) => {
switch (block.typename) {
case HotelPageEnum.ContentStack.blocks.ActivitiesCard:
activitiesCards.push(block)
break
case HotelPageEnum.ContentStack.blocks.SpaPage:
spaPage = block
break
default:
break
}
})
return { spaPage, activitiesCards }
}),
faq: hotelFaqSchema.nullable(),
hotel_page_id: z.string(),
title: z.string(),
url: z.string(),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
})
.transform(({ hotel_navigation, ...rest }) => ({
sectionHeadings: hotel_navigation,
...rest,
})),
})
/** REFS */
const hotelPageActivitiesCardRefs = z
.object({
__typename: z.literal(HotelPageEnum.ContentStack.blocks.ActivitiesCard),
})
.merge(activitiesCardRefSchema)
const hotelPageSpaPageRefs = z
.object({
__typename: z.literal(HotelPageEnum.ContentStack.blocks.SpaPage),
})
.merge(spaPageRefSchema)
const hotelPageBlockRefsItem = z.discriminatedUnion("__typename", [
hotelPageActivitiesCardRefs,
hotelPageSpaPageRefs,
])
export const hotelPageRefsSchema = z.object({
hotel_page: z.object({
content: discriminatedUnionArray(hotelPageBlockRefsItem.options).nullable(),
faq: hotelFaqRefsSchema.nullable(),
system: systemSchema,
}),
trackingProps: z.object({
url: z.string(),
}),
})
export const hotelPageUrlsSchema = z
.object({
all_hotel_page: z.object({
items: z.array(
z
.object({
url: z.string(),
hotel_page_id: z.string(),
system: systemSchema,
})
.transform((data) => {
return {
url: removeMultipleSlashes(`/${data.system.locale}/${data.url}`),
hotelId: data.hotel_page_id,
}
})
),
}),
})
.transform(({ all_hotel_page }) => all_hotel_page.items)
export const batchedHotelPageUrlsSchema = z
.array(
z.object({
data: hotelPageUrlsSchema,
})
)
.transform((allItems) => {
return allItems.flatMap((item) => item.data)
})
export const hotelPageCountSchema = z
.object({
all_hotel_page: z.object({
total: z.number(),
}),
})
.transform(({ all_hotel_page }) => all_hotel_page.total)

View File

@@ -1,55 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentstackExtendedProcedureUID } from "@scandic-hotels/trpc/procedures"
import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
import { request } from "@/lib/graphql/request"
import { generateTag } from "@/utils/generateTag"
import { hotelPageSchema } from "./output"
import type { GetHotelPageData } from "@/types/trpc/routers/contentstack/hotelPage"
export const hotelPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const { lang, uid } = ctx
const getHotelPageCounter = createCounter(
"trpc.contentstack",
"hotelPage.get"
)
const metricsGetHotelPage = getHotelPageCounter.init({ lang, uid })
metricsGetHotelPage.start()
const response = await request<GetHotelPageData>(
GetHotelPage,
{
locale: lang,
uid,
},
{
key: generateTag(lang, uid),
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetHotelPage.noDataError()
throw notFoundError
}
const validatedHotelPage = hotelPageSchema.safeParse(response.data)
if (!validatedHotelPage.success) {
metricsGetHotelPage.validationError(validatedHotelPage.error)
return null
}
metricsGetHotelPage.success()
return validatedHotelPage.data.hotel_page
}),
})

View File

@@ -1,100 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { GetHotelPageCount } from "@/lib/graphql/Query/HotelPage/HotelPageCount.graphql"
import { GetHotelPageUrls } from "@/lib/graphql/Query/HotelPage/HotelPageUrl.graphql"
import { request } from "@/lib/graphql/request"
import { batchedHotelPageUrlsSchema, hotelPageCountSchema } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type {
GetHotelPageCountData,
GetHotelPageUrlsData,
} from "@/types/trpc/routers/contentstack/hotelPage"
export async function getHotelPageCount(lang: Lang) {
const getHotelPageCountCounter = createCounter(
"trpc.contentstack",
"hotelPageCount.get"
)
const metricsGetHotelPageCount = getHotelPageCountCounter.init({ lang })
metricsGetHotelPageCount.start()
const response = await request<GetHotelPageCountData>(
GetHotelPageCount,
{
locale: lang,
},
{
key: `${lang}:hotel_page_count`,
ttl: "max",
}
)
if (!response.data) {
metricsGetHotelPageCount.noDataError()
return 0
}
const validatedResponse = hotelPageCountSchema.safeParse(response.data)
if (!validatedResponse.success) {
metricsGetHotelPageCount.validationError(validatedResponse.error)
return 0
}
metricsGetHotelPageCount.success()
return validatedResponse.data
}
export async function getHotelPageUrls(lang: Lang) {
const getHotelPageUrlsCounter = createCounter(
"trpc.contentstack",
"hotelPageUrls.get"
)
const metricsGetHotelPageUrls = getHotelPageUrlsCounter.init({ lang })
metricsGetHotelPageUrls.start()
const count = await getHotelPageCount(lang)
if (count === 0) {
return []
}
// Calculating the amount of requests needed to fetch all hotel pages.
// Contentstack has a limit of 100 items per request.
// So we need to make multiple requests to fetch urls to all hotel pages.
// The `batchRequest` function is not working here, because the arrayMerge is
// used for other purposes.
const amountOfRequests = Math.ceil(count / 100)
const requests = Array.from({ length: amountOfRequests }).map((_, i) => ({
document: GetHotelPageUrls,
variables: { locale: lang, skip: i * 100 },
cacheKey: `${lang}:hotel_page_urls_batch_${i}`,
}))
const batchedResponse = await Promise.all(
requests.map((req) =>
request<GetHotelPageUrlsData>(req.document, req.variables, {
key: req.cacheKey,
ttl: "max",
})
)
)
const validatedResponse =
batchedHotelPageUrlsSchema.safeParse(batchedResponse)
if (!validatedResponse.success) {
metricsGetHotelPageUrls.validationError(validatedResponse.error)
return []
}
metricsGetHotelPageUrls.success()
return validatedResponse.data
}

View File

@@ -1,43 +0,0 @@
import { router } from "@scandic-hotels/trpc"
import { accountPageRouter } from "./accountPage"
import { baseRouter } from "./base"
import { breadcrumbsRouter } from "./breadcrumbs"
import { campaignOverviewPageRouter } from "./campaignOverviewPage"
import { campaignPageRouter } from "./campaignPage"
import { collectionPageRouter } from "./collectionPage"
import { contentPageRouter } from "./contentPage"
import { destinationCityPageRouter } from "./destinationCityPage"
import { destinationCountryPageRouter } from "./destinationCountryPage"
import { destinationOverviewPageRouter } from "./destinationOverviewPage"
import { hotelPageRouter } from "./hotelPage"
import { languageSwitcherRouter } from "./languageSwitcher"
import { loyaltyLevelRouter } from "./loyaltyLevel"
import { loyaltyPageRouter } from "./loyaltyPage"
import { metadataRouter } from "./metadata"
import { pageSettingsRouter } from "./pageSettings"
import { partnerRouter } from "./partner"
import { rewardRouter } from "./reward"
import { startPageRouter } from "./startPage"
export const contentstackRouter = router({
accountPage: accountPageRouter,
base: baseRouter,
breadcrumbs: breadcrumbsRouter,
hotelPage: hotelPageRouter,
languageSwitcher: languageSwitcherRouter,
loyaltyPage: loyaltyPageRouter,
campaignOverviewPage: campaignOverviewPageRouter,
campaignPage: campaignPageRouter,
collectionPage: collectionPageRouter,
contentPage: contentPageRouter,
destinationOverviewPage: destinationOverviewPageRouter,
destinationCountryPage: destinationCountryPageRouter,
destinationCityPage: destinationCityPageRouter,
metadata: metadataRouter,
pageSettings: pageSettingsRouter,
rewards: rewardRouter,
loyaltyLevels: loyaltyLevelRouter,
startPage: startPageRouter,
partner: partnerRouter,
})

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { languageSwitcherQueryRouter } from "./query"
export const languageSwitcherRouter = mergeRouters(languageSwitcherQueryRouter)

View File

@@ -1,8 +0,0 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
export const getLanguageSwitcherInput = z.object({
lang: z.nativeEnum(Lang),
pathName: z.string(),
})

View File

@@ -1,15 +0,0 @@
import { z } from "zod"
const link = z
.object({ url: z.string().optional(), isExternal: z.boolean() })
.optional()
.nullable()
export const validateLanguageSwitcherData = z.object({
da: link,
de: link,
en: link,
fi: link,
no: link,
sv: link,
})

View File

@@ -1,31 +0,0 @@
import { router } from "@scandic-hotels/trpc"
import { publicProcedure } from "@scandic-hotels/trpc/procedures"
import { getUidAndContentTypeByPath } from "@/services/cms/getUidAndContentTypeByPath"
import { getNonContentstackUrls } from "../metadata/output"
import { getLanguageSwitcherInput } from "./input"
import { getUrlsOfAllLanguages } from "./utils"
import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher"
export const languageSwitcherQueryRouter = router({
get: publicProcedure
.input(getLanguageSwitcherInput)
.query(async ({ input }) => {
const { pathName, lang } = input
const { uid, contentType } = await getUidAndContentTypeByPath(pathName)
let urls: LanguageSwitcherData | null = null
if (!uid || !contentType) {
urls = getNonContentstackUrls(lang, pathName)
} else {
urls = await getUrlsOfAllLanguages(lang, uid, contentType)
}
return {
lang,
urls,
}
}),
})

View File

@@ -1,205 +0,0 @@
import { Lang } from "@scandic-hotels/common/constants/language"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { internalServerError } from "@scandic-hotels/trpc/errors"
import { batchRequest } from "@/lib/graphql/batchRequest"
import {
GetDaDeEnUrlsAccountPage,
GetFiNoSvUrlsAccountPage,
} from "@/lib/graphql/Query/AccountPage/AccountPage.graphql"
import {
GetDaDeEnUrlsCampaignOverviewPage,
GetFiNoSvUrlsCampaignOverviewPage,
} from "@/lib/graphql/Query/CampaignOverviewPage/CampaignOverviewPage.graphql"
import {
GetDaDeEnUrlsCampaignPage,
GetFiNoSvUrlsCampaignPage,
} from "@/lib/graphql/Query/CampaignPage/CampaignPage.graphql"
import {
GetDaDeEnUrlsCollectionPage,
GetFiNoSvUrlsCollectionPage,
} from "@/lib/graphql/Query/CollectionPage/CollectionPage.graphql"
import {
GetDaDeEnUrlsContentPage,
GetFiNoSvUrlsContentPage,
} from "@/lib/graphql/Query/ContentPage/ContentPage.graphql"
import {
GetDaDeEnUrlsCurrentBlocksPage,
GetFiNoSvUrlsCurrentBlocksPage,
} from "@/lib/graphql/Query/Current/LanguageSwitcher.graphql"
import {
GetDaDeEnUrlsDestinationCityPage,
GetFiNoSvUrlsDestinationCityPage,
} from "@/lib/graphql/Query/DestinationCityPage/DestinationCityPage.graphql"
import {
GetDaDeEnUrlsDestinationCountryPage,
GetFiNoSvUrlsDestinationCountryPage,
} from "@/lib/graphql/Query/DestinationCountryPage/DestinationCountryPage.graphql"
import {
GetDaDeEnUrlsDestinationOverviewPage,
GetFiNoSvUrlsDestinationOverviewPage,
} from "@/lib/graphql/Query/DestinationOverviewPage/DestinationOverviewPage.graphql"
import {
GetDaDeEnUrlsHotelPage,
GetFiNoSvUrlsHotelPage,
} from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
import {
GetDaDeEnUrlsLoyaltyPage,
GetFiNoSvUrlsLoyaltyPage,
} from "@/lib/graphql/Query/LoyaltyPage/LoyaltyPage.graphql"
import {
GetDaDeEnUrlsStartPage,
GetFiNoSvUrlsStartPage,
} from "@/lib/graphql/Query/StartPage/StartPage.graphql"
import { generateTag } from "@/utils/generateTag"
import { removeTrailingSlash } from "@/utils/url"
import { validateLanguageSwitcherData } from "./output"
import { PageContentTypeEnum } from "@/types/requests/contentType"
import type {
LanguageSwitcherData,
LanguageSwitcherQueryDataRaw,
} from "@/types/requests/languageSwitcher"
export const languageSwitcherAffix = "languageSwitcher"
export async function getUrlsOfAllLanguages(
lang: Lang,
uid: string,
contentType: string
) {
const getLanguageSwitcherCounter = createCounter(
"trpc.contentstack",
"languageSwitcher.get"
)
const metricsGetLanguageSwitcher = getLanguageSwitcherCounter.init({
lang,
uid,
contentType,
})
metricsGetLanguageSwitcher.start()
const variables = { uid }
const tagsDaDeEn = [
generateTag(Lang.da, uid, languageSwitcherAffix),
generateTag(Lang.de, uid, languageSwitcherAffix),
generateTag(Lang.en, uid, languageSwitcherAffix),
]
const tagsFiNoSv = [
generateTag(Lang.fi, uid, languageSwitcherAffix),
generateTag(Lang.no, uid, languageSwitcherAffix),
generateTag(Lang.sv, uid, languageSwitcherAffix),
]
let daDeEnDocument = null
let fiNoSvDocument = null
switch (contentType) {
case PageContentTypeEnum.accountPage:
daDeEnDocument = GetDaDeEnUrlsAccountPage
fiNoSvDocument = GetFiNoSvUrlsAccountPage
break
case PageContentTypeEnum.currentBlocksPage:
daDeEnDocument = GetDaDeEnUrlsCurrentBlocksPage
fiNoSvDocument = GetFiNoSvUrlsCurrentBlocksPage
break
case PageContentTypeEnum.campaignOverviewPage:
daDeEnDocument = GetDaDeEnUrlsCampaignOverviewPage
fiNoSvDocument = GetFiNoSvUrlsCampaignOverviewPage
break
case PageContentTypeEnum.campaignPage:
daDeEnDocument = GetDaDeEnUrlsCampaignPage
fiNoSvDocument = GetFiNoSvUrlsCampaignPage
break
case PageContentTypeEnum.loyaltyPage:
daDeEnDocument = GetDaDeEnUrlsLoyaltyPage
fiNoSvDocument = GetFiNoSvUrlsLoyaltyPage
break
case PageContentTypeEnum.hotelPage:
daDeEnDocument = GetDaDeEnUrlsHotelPage
fiNoSvDocument = GetFiNoSvUrlsHotelPage
break
case PageContentTypeEnum.contentPage:
daDeEnDocument = GetDaDeEnUrlsContentPage
fiNoSvDocument = GetFiNoSvUrlsContentPage
break
case PageContentTypeEnum.collectionPage:
daDeEnDocument = GetDaDeEnUrlsCollectionPage
fiNoSvDocument = GetFiNoSvUrlsCollectionPage
break
case PageContentTypeEnum.destinationOverviewPage:
daDeEnDocument = GetDaDeEnUrlsDestinationOverviewPage
fiNoSvDocument = GetFiNoSvUrlsDestinationOverviewPage
break
case PageContentTypeEnum.destinationCountryPage:
daDeEnDocument = GetDaDeEnUrlsDestinationCountryPage
fiNoSvDocument = GetFiNoSvUrlsDestinationCountryPage
break
case PageContentTypeEnum.destinationCityPage:
daDeEnDocument = GetDaDeEnUrlsDestinationCityPage
fiNoSvDocument = GetFiNoSvUrlsDestinationCityPage
break
case PageContentTypeEnum.startPage:
daDeEnDocument = GetDaDeEnUrlsStartPage
fiNoSvDocument = GetFiNoSvUrlsStartPage
break
default:
console.error(`type: [${contentType}]`)
console.error(`Trying to get a content type that is not supported`)
throw internalServerError()
}
if (!daDeEnDocument || !fiNoSvDocument) {
throw internalServerError()
}
const response = await batchRequest<LanguageSwitcherQueryDataRaw>([
{
document: daDeEnDocument,
variables,
cacheOptions: {
ttl: "max",
key: tagsDaDeEn,
},
},
{
document: fiNoSvDocument,
variables,
cacheOptions: {
ttl: "max",
key: tagsFiNoSv,
},
},
])
const urls = Object.keys(response.data).reduce<LanguageSwitcherData>(
(acc, key) => {
const item = response.data[key as Lang]
const url = item
? item.web?.original_url || removeTrailingSlash(`/${key}${item.url}`)
: undefined
return {
...acc,
[key]: { url, isExternal: !!item?.web?.original_url },
}
},
{}
)
const validatedLanguageSwitcherData =
validateLanguageSwitcherData.safeParse(urls)
if (!validatedLanguageSwitcherData.success) {
metricsGetLanguageSwitcher.validationError(
validatedLanguageSwitcherData.error
)
return null
}
metricsGetLanguageSwitcher.success()
return urls
}

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { loyaltyLevelQueryRouter } from "./query"
export const loyaltyLevelRouter = mergeRouters(loyaltyLevelQueryRouter)

View File

@@ -1,10 +0,0 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const loyaltyLevelInput = z.object({
level: z.nativeEnum(MembershipLevelEnum),
lang: z.nativeEnum(Lang).optional(),
})

View File

@@ -1,24 +0,0 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const validateLoyaltyLevelsSchema = z
.object({
all_loyalty_level: z.object({
items: z.array(
z.object({
level_id: z.nativeEnum(MembershipLevelEnum),
name: z.string(),
user_facing_tag: z.string().optional(),
description: z.string().optional(),
required_nights: z.number().optional().nullable(),
required_points: z.number(),
})
),
}),
})
.transform((data) => data.all_loyalty_level.items)
export type LoyaltyLevelsResponse = z.input<typeof validateLoyaltyLevelsSchema>
export type LoyaltyLevel = z.output<typeof validateLoyaltyLevelsSchema>[number]

View File

@@ -1,126 +0,0 @@
import { cache } from "react"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentstackBaseProcedure } from "@scandic-hotels/trpc/procedures"
import {
type MembershipLevel,
MembershipLevelEnum,
} from "@/constants/membershipLevels"
import {
GetAllLoyaltyLevels,
GetLoyaltyLevel,
} from "@/lib/graphql/Query/LoyaltyLevels.graphql"
import { request } from "@/lib/graphql/request"
import { generateLoyaltyConfigTag } from "@/utils/generateTag"
import { loyaltyLevelInput } from "./input"
import {
type LoyaltyLevel,
type LoyaltyLevelsResponse,
validateLoyaltyLevelsSchema,
} from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
export const getAllLoyaltyLevels = cache(async (lang: Lang) => {
const getLoyaltyLevelAllCounter = createCounter(
"trpc.contentstack",
"loyaltyLevel.all"
)
const metricsGetLoyaltyLevelAll = getLoyaltyLevelAllCounter.init()
metricsGetLoyaltyLevelAll.start()
// Ideally we should fetch all available tiers from API, but since they
// are static, we can just use the enum values. We want to know which
// levels we are fetching so that we can use tags to cache them
const allLevelIds = Object.values(MembershipLevelEnum)
const tags = allLevelIds.map((levelId) =>
generateLoyaltyConfigTag(lang, "loyalty_level", levelId)
)
const loyaltyLevelsConfigResponse = await request<LoyaltyLevelsResponse>(
GetAllLoyaltyLevels,
{ lang, level_ids: allLevelIds },
{ key: tags, ttl: "max" }
)
if (!loyaltyLevelsConfigResponse.data) {
const notFoundError = notFound(loyaltyLevelsConfigResponse)
metricsGetLoyaltyLevelAll.noDataError()
throw notFoundError
}
const validatedLoyaltyLevels = validateLoyaltyLevelsSchema.safeParse(
loyaltyLevelsConfigResponse.data
)
if (!validatedLoyaltyLevels.success) {
metricsGetLoyaltyLevelAll.validationError(validatedLoyaltyLevels.error)
return []
}
metricsGetLoyaltyLevelAll.success()
return validatedLoyaltyLevels.data
})
export const getLoyaltyLevel = cache(
async (lang: Lang, level_id: MembershipLevel) => {
const getLoyaltyLevelCounter = createCounter(
"trpc.contentstack",
"loyaltyLevel.get"
)
const metricsGetLoyaltyLevel = getLoyaltyLevelCounter.init({
lang,
level_id,
})
metricsGetLoyaltyLevel.start()
const loyaltyLevelsConfigResponse = await request<LoyaltyLevelsResponse>(
GetLoyaltyLevel,
{ lang, level_id },
{
key: generateLoyaltyConfigTag(lang, "loyalty_level", level_id),
ttl: "max",
}
)
if (
!loyaltyLevelsConfigResponse.data ||
!loyaltyLevelsConfigResponse.data.all_loyalty_level.items.length
) {
const notFoundError = notFound(loyaltyLevelsConfigResponse)
metricsGetLoyaltyLevel.noDataError()
throw notFoundError
}
const validatedLoyaltyLevels = validateLoyaltyLevelsSchema.safeParse(
loyaltyLevelsConfigResponse.data
)
if (!validatedLoyaltyLevels.success) {
metricsGetLoyaltyLevel.validationError(validatedLoyaltyLevels.error)
return null
}
metricsGetLoyaltyLevel.success()
const result: LoyaltyLevel = validatedLoyaltyLevels.data[0]
return result
}
)
export const loyaltyLevelQueryRouter = router({
byLevel: contentstackBaseProcedure
.input(loyaltyLevelInput)
.query(async function ({ ctx, input }) {
return getLoyaltyLevel(ctx.lang, input.level)
}),
all: contentstackBaseProcedure.query(async function ({ ctx }) {
return getAllLoyaltyLevels(ctx.lang)
}),
})

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { loyaltyPageQueryRouter } from "./query"
export const loyaltyPageRouter = mergeRouters(loyaltyPageQueryRouter)

View File

@@ -1,195 +0,0 @@
import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import {
cardGridRefsSchema,
cardsGridSchema,
} from "../schemas/blocks/cardsGrid"
import {
contentRefsSchema as blockContentRefsSchema,
contentSchema as blockContentSchema,
} from "../schemas/blocks/content"
import {
dynamicContentRefsSchema,
dynamicContentSchema as blockDynamicContentSchema,
} from "../schemas/blocks/dynamicContent"
import {
shortcutsRefsSchema,
shortcutsSchema,
} from "../schemas/blocks/shortcuts"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import {
contentRefsSchema as sidebarContentRefsSchema,
contentSchema as sidebarContentSchema,
} from "../schemas/sidebar/content"
import { dynamicContentSchema as sidebarDynamicContentSchema } from "../schemas/sidebar/dynamicContent"
import {
joinLoyaltyContactRefsSchema,
joinLoyaltyContactSchema,
} from "../schemas/sidebar/joinLoyaltyContact"
import { systemSchema } from "../schemas/system"
import { LoyaltyPageEnum } from "@/types/enums/loyaltyPage"
// LoyaltyPage Refs
const extendedCardGridRefsSchema = z
.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.CardsGrid),
})
.merge(cardGridRefsSchema)
const extendedContentRefsSchema = z
.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.Content),
})
.merge(blockContentRefsSchema)
const extendedDynamicContentRefsSchema = z
.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.DynamicContent),
})
.merge(dynamicContentRefsSchema)
const extendedShortcutsRefsSchema = z
.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.Shortcuts),
})
.merge(shortcutsRefsSchema)
const blocksRefsSchema = z.discriminatedUnion("__typename", [
extendedCardGridRefsSchema,
extendedContentRefsSchema,
extendedDynamicContentRefsSchema,
extendedShortcutsRefsSchema,
])
const contentSidebarRefsSchema = z
.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.sidebar.Content),
})
.merge(sidebarContentRefsSchema)
const extendedJoinLoyaltyContactRefsSchema = z
.object({
__typename: z.literal(
LoyaltyPageEnum.ContentStack.sidebar.JoinLoyaltyContact
),
})
.merge(joinLoyaltyContactRefsSchema)
const sidebarRefsSchema = z.discriminatedUnion("__typename", [
contentSidebarRefsSchema,
extendedJoinLoyaltyContactRefsSchema,
z.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.sidebar.DynamicContent),
}),
])
export const loyaltyPageRefsSchema = z.object({
loyalty_page: z.object({
blocks: discriminatedUnionArray(blocksRefsSchema.options).optional(),
sidebar: discriminatedUnionArray(sidebarRefsSchema.options)
.optional()
.transform((data) => {
if (data) {
return data.filter(
(block) =>
block.__typename !==
LoyaltyPageEnum.ContentStack.sidebar.DynamicContent
)
}
return data
}),
system: systemSchema,
}),
})
// LoyaltyPage
export const extendedCardsGridSchema = z
.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.CardsGrid),
})
.merge(cardsGridSchema)
export const extendedContentSchema = z
.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.Content),
})
.merge(blockContentSchema)
export const extendedDynamicContentSchema = z
.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.DynamicContent),
})
.merge(blockDynamicContentSchema)
export const extendedShortcutsSchema = z
.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.Shortcuts),
})
.merge(shortcutsSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [
extendedCardsGridSchema,
extendedContentSchema,
extendedDynamicContentSchema,
extendedShortcutsSchema,
])
const contentSidebarSchema = z
.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.sidebar.Content),
})
.merge(sidebarContentSchema)
const dynamicContentSidebarSchema = z
.object({
__typename: z.literal(LoyaltyPageEnum.ContentStack.sidebar.DynamicContent),
})
.merge(sidebarDynamicContentSchema)
export const joinLoyaltyContactSidebarSchema = z
.object({
__typename: z.literal(
LoyaltyPageEnum.ContentStack.sidebar.JoinLoyaltyContact
),
})
.merge(joinLoyaltyContactSchema)
export const sidebarSchema = z.discriminatedUnion("__typename", [
contentSidebarSchema,
dynamicContentSidebarSchema,
joinLoyaltyContactSidebarSchema,
])
export const loyaltyPageSchema = z.object({
loyalty_page: z
.object({
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
heading: z.string().optional(),
hero_image: tempImageVaultAssetSchema,
preamble: z.string().optional(),
sidebar: discriminatedUnionArray(sidebarSchema.options).nullable(),
title: z.string().optional(),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
})
.transform((data) => {
return {
blocks: data.blocks ? data.blocks : [],
heading: data.heading,
heroImage: data.hero_image,
preamble: data.preamble,
sidebar: data.sidebar ? data.sidebar : [],
system: data.system,
}
}),
trackingProps: z.object({
url: z.string(),
}),
})

View File

@@ -1,129 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentstackExtendedProcedureUID } from "@scandic-hotels/trpc/procedures"
import {
GetLoyaltyPage,
GetLoyaltyPageRefs,
} from "@/lib/graphql/Query/LoyaltyPage/LoyaltyPage.graphql"
import { request } from "@/lib/graphql/request"
import {
generateRefsResponseTag,
generateTag,
generateTagsFromSystem,
} from "@/utils/generateTag"
import { loyaltyPageRefsSchema, loyaltyPageSchema } from "./output"
import { getConnections } from "./utils"
import {
TrackingChannelEnum,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type {
GetLoyaltyPageRefsSchema,
GetLoyaltyPageSchema,
} from "@/types/trpc/routers/contentstack/loyaltyPage"
export const loyaltyPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const { lang, uid } = ctx
const getLoyaltyPageRefsCounter = createCounter(
"trpc.contentstack",
"loyaltyPage.get.refs"
)
const metricsGetLoyaltyPageRefs = getLoyaltyPageRefsCounter.init({
lang,
uid,
})
metricsGetLoyaltyPageRefs.start()
const variables = { locale: lang, uid }
const refsResponse = await request<GetLoyaltyPageRefsSchema>(
GetLoyaltyPageRefs,
variables,
{
key: generateRefsResponseTag(lang, uid),
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
metricsGetLoyaltyPageRefs.noDataError()
throw notFoundError
}
const validatedLoyaltyPageRefs = loyaltyPageRefsSchema.safeParse(
refsResponse.data
)
if (!validatedLoyaltyPageRefs.success) {
metricsGetLoyaltyPageRefs.validationError(validatedLoyaltyPageRefs.error)
return null
}
metricsGetLoyaltyPageRefs.success()
const connections = getConnections(validatedLoyaltyPageRefs.data)
const tags = [
generateTagsFromSystem(lang, connections),
generateTag(lang, validatedLoyaltyPageRefs.data.loyalty_page.system.uid),
].flat()
const getLoyaltyPageCounter = createCounter(
"trpc.contentstack",
"loyaltyPage.get"
)
const metricsGetLoyaltyPage = getLoyaltyPageCounter.init({ lang, uid })
metricsGetLoyaltyPage.start()
const response = await request<GetLoyaltyPageSchema>(
GetLoyaltyPage,
variables,
{
key: tags,
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetLoyaltyPage.noDataError()
throw notFoundError
}
const validatedLoyaltyPage = loyaltyPageSchema.safeParse(response.data)
if (!validatedLoyaltyPage.success) {
metricsGetLoyaltyPage.validationError(validatedLoyaltyPage.error)
return null
}
const loyaltyPage = validatedLoyaltyPage.data.loyalty_page
const loyaltyTrackingData: TrackingSDKPageData = {
pageId: loyaltyPage.system.uid,
domainLanguage: lang,
publishDate: loyaltyPage.system.updated_at,
createDate: loyaltyPage.system.created_at,
channel: TrackingChannelEnum["scandic-friends"],
pageType: "loyaltycontentpage",
pageName: validatedLoyaltyPage.data.trackingProps.url,
siteSections: validatedLoyaltyPage.data.trackingProps.url,
siteVersion: "new-web",
}
metricsGetLoyaltyPage.success()
// Assert LoyaltyPage type to get correct typings for RTE fields
return {
loyaltyPage,
tracking: loyaltyTrackingData,
}
}),
})

View File

@@ -1,62 +0,0 @@
import { LoyaltyPageEnum } from "@/types/enums/loyaltyPage"
import type { System } from "@/types/requests/system"
import type { LoyaltyPageRefs } from "@/types/trpc/routers/contentstack/loyaltyPage"
export function getConnections({ loyalty_page }: LoyaltyPageRefs) {
const connections: System["system"][] = [loyalty_page.system]
if (loyalty_page.blocks) {
loyalty_page.blocks.forEach((block) => {
switch (block.__typename) {
case LoyaltyPageEnum.ContentStack.blocks.CardsGrid:
if (block.cards_grid.length) {
connections.push(...block.cards_grid)
}
break
case LoyaltyPageEnum.ContentStack.blocks.Content:
if (block.content.length) {
// TS has trouble infering the filtered types
// @ts-ignore
connections.push(...block.content)
}
break
case LoyaltyPageEnum.ContentStack.blocks.DynamicContent:
if (block.dynamic_content.link) {
connections.push(block.dynamic_content.link)
}
break
case LoyaltyPageEnum.ContentStack.blocks.Shortcuts:
if (block.shortcuts.shortcuts.length) {
connections.push(...block.shortcuts.shortcuts)
}
break
default:
break
}
})
}
if (loyalty_page.sidebar) {
loyalty_page.sidebar.forEach((block) => {
switch (block?.__typename) {
case LoyaltyPageEnum.ContentStack.sidebar.Content:
if (block.content.length) {
// TS has trouble infering the filtered types
// @ts-ignore
connections.push(...block.content)
}
break
case LoyaltyPageEnum.ContentStack.sidebar.JoinLoyaltyContact:
if (block.join_loyalty_contact?.button) {
connections.push(block.join_loyalty_contact.button)
}
break
default:
break
}
})
}
return connections
}

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { metadataQueryRouter } from "./query"
export const metadataRouter = mergeRouters(metadataQueryRouter)

View File

@@ -1,7 +0,0 @@
import { z } from "zod"
export const getMetadataInput = z.object({
subpage: z.string().optional(),
filterFromUrl: z.string().optional(),
noIndex: z.boolean().default(false),
})

View File

@@ -1,214 +0,0 @@
import { z } from "zod"
import { baseUrls } from "@/constants/routes/baseUrls"
import { findMyBooking } from "@/constants/routes/findMyBooking"
import { hotelreservation } from "@/constants/routes/hotelReservation"
import { myStay } from "@/constants/routes/myStay"
import { env } from "@/env/server"
import { attributesSchema as hotelAttributesSchema } from "../../hotels/schemas/hotel"
import { additionalDataAttributesSchema } from "../../hotels/schemas/hotel/include/additionalData"
import { imageSchema } from "../../hotels/schemas/image"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import { systemSchema } from "../schemas/system"
import { getDescription } from "./utils/description"
import { getImage } from "./utils/image"
import { getTitle } from "./utils/title"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { Metadata } from "next"
import type { ImageVaultAsset } from "@/types/components/imageVault"
import { Country } from "@/types/enums/country"
import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher"
import { RTETypeEnum } from "@/types/rte/enums"
const metaDataJsonSchema = z.object({
children: z.array(
z.object({
type: z.nativeEnum(RTETypeEnum),
children: z.array(
z.object({
text: z.string().optional(),
})
),
})
),
})
const metaDataBlocksSchema = z
.array(
z.object({
content: z
.object({
content: z
.object({
json: metaDataJsonSchema,
})
.optional()
.nullable(),
})
.optional()
.nullable(),
})
)
.optional()
.nullable()
export const rawMetadataSchema = z.object({
web: z
.object({
seo_metadata: z
.object({
title: z.string().nullish(),
description: z.string().nullish(),
noindex: z.boolean().nullish(),
seo_image: tempImageVaultAssetSchema.nullable(),
})
.nullish(),
breadcrumbs: z
.object({
title: z.string().nullish(),
})
.nullish(),
})
.nullish(),
destination_settings: z
.object({
city_denmark: z.string().nullish(),
city_finland: z.string().nullish(),
city_germany: z.string().nullish(),
city_poland: z.string().nullish(),
city_norway: z.string().nullish(),
city_sweden: z.string().nullish(),
country: z.nativeEnum(Country).nullish(),
})
.nullish(),
heading: 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(),
preamble: z.string().nullish(),
hero_image: tempImageVaultAssetSchema.nullable(),
})
.nullish(),
hero_image: tempImageVaultAssetSchema.nullable(),
images: z
.array(z.object({ image: tempImageVaultAssetSchema }).nullish())
.transform((images) =>
images
.map((image) => image?.image)
.filter((image): image is ImageVaultAsset => !!image)
)
.nullish(),
blocks: metaDataBlocksSchema,
hotel_page_id: z.string().nullish(),
hotelData: hotelAttributesSchema
.pick({
name: true,
address: true,
detailedFacilities: true,
hotelContent: true,
healthFacilities: true,
})
.nullish(),
additionalHotelData: additionalDataAttributesSchema
.pick({
gallery: true,
hotelParking: true,
healthAndFitness: true,
hotelSpecialNeeds: true,
meetingRooms: true,
parkingImages: true,
accessibility: true,
conferencesAndMeetings: true,
})
.nullish(),
hotelRestaurants: z
.array(
z.object({
nameInUrl: z.string().nullish(),
elevatorPitch: z.string().nullish(),
name: z.string().nullish(),
content: z
.object({
images: z.array(imageSchema).nullish(),
})
.nullish(),
})
)
.nullish(),
subpageUrl: z.string().nullish(),
destinationData: z
.object({
location: z.string().nullish(),
filter: z.string().nullish(),
filterType: z.enum(["facility", "surroundings"]).nullish(),
hotelCount: z.number().nullish(),
cities: z.array(z.string()).nullish(),
})
.nullish(),
system: systemSchema,
})
export const metadataSchema = rawMetadataSchema.transform(async (data) => {
const noIndex = !!data.web?.seo_metadata?.noindex
const metadata: Metadata = {
metadataBase: env.PUBLIC_URL ? new URL(env.PUBLIC_URL) : undefined,
title: await getTitle(data),
description: await getDescription(data),
openGraph: {
images: getImage(data),
},
}
if (noIndex) {
metadata.robots = {
index: false,
follow: false,
}
}
return metadata
})
// Several pages are not currently routed within contentstack context.
// This function is used to generate the urls for these pages.
export function getNonContentstackUrls(lang: Lang, pathName: string) {
if (Object.values(findMyBooking).includes(pathName)) {
const urls: LanguageSwitcherData = {}
return Object.entries(findMyBooking).reduce((acc, [lang, url]) => {
acc[lang as Lang] = { url }
return urls
}, urls)
}
if (Object.values(myStay).includes(pathName)) {
const urls: LanguageSwitcherData = {}
return Object.entries(myStay).reduce((acc, [lang, url]) => {
acc[lang as Lang] = { url }
return urls
}, urls)
}
if (pathName.startsWith(hotelreservation(lang))) {
return baseUrls
}
return { [lang]: { url: pathName } }
}

View File

@@ -1,265 +0,0 @@
import { cache } from "react"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentStackUidWithServiceProcedure } from "@scandic-hotels/trpc/procedures"
import { env } from "@/env/server"
import { GetAccountPageMetadata } from "@/lib/graphql/Query/AccountPage/Metadata.graphql"
import { GetCampaignOverviewPageMetadata } from "@/lib/graphql/Query/CampaignOverviewPage/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"
import { GetDestinationCountryPageMetadata } from "@/lib/graphql/Query/DestinationCountryPage/Metadata.graphql"
import { GetDestinationOverviewPageMetadata } from "@/lib/graphql/Query/DestinationOverviewPage/Metadata.graphql"
import { GetHotelPageMetadata } from "@/lib/graphql/Query/HotelPage/Metadata.graphql"
import { GetLoyaltyPageMetadata } from "@/lib/graphql/Query/LoyaltyPage/Metadata.graphql"
import { GetStartPageMetadata } from "@/lib/graphql/Query/StartPage/Metadata.graphql"
import { request } from "@/lib/graphql/request"
import { generateTag } from "@/utils/generateTag"
import { getHotel } from "../../hotels/utils"
import { getUrlsOfAllLanguages } from "../languageSwitcher/utils"
import { getMetadataInput } from "./input"
import { getNonContentstackUrls, metadataSchema } from "./output"
import { affix, getCityData, getCountryData } from "./utils"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { Metadata } from "next"
import { PageContentTypeEnum } from "@/types/requests/contentType"
import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
const fetchMetadata = cache(async function fetchMemoizedMetadata<T>(
query: string,
{ uid, lang }: { uid: string; lang: Lang }
) {
const getMetadataCounter = createCounter("trpc.contentstack", "metadata.get")
const metricsGetMetadata = getMetadataCounter.init({ lang, uid })
metricsGetMetadata.start()
const response = await request<T>(
query,
{ locale: lang, uid },
{
key: generateTag(lang, uid, affix),
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetMetadata.noDataError()
throw notFoundError
}
metricsGetMetadata.success()
return response.data
})
async function getTransformedMetadata(data: unknown) {
const transformMetadataCounter = createCounter(
"trpc.contentstack",
"metadata.transform"
)
const metricsTransformMetadata = transformMetadataCounter.init()
metricsTransformMetadata.start()
const validatedMetadata = await metadataSchema.safeParseAsync(data)
if (!validatedMetadata.success) {
metricsTransformMetadata.validationError(validatedMetadata.error)
return null
}
metricsTransformMetadata.success()
return validatedMetadata.data
}
export const metadataQueryRouter = router({
get: contentStackUidWithServiceProcedure
.input(getMetadataInput)
.query(async ({ ctx, input }) => {
const variables = {
lang: ctx.lang,
uid: ctx.uid,
}
let urls: LanguageSwitcherData | null = null
if (
input.subpage ||
input.filterFromUrl ||
!ctx.uid ||
!ctx.contentType
) {
urls = getNonContentstackUrls(ctx.lang, `${ctx.lang}/${ctx.pathname}`)
} else {
urls = await getUrlsOfAllLanguages(ctx.lang, ctx.uid, ctx.contentType)
}
let alternates: Metadata["alternates"] = null
if (urls) {
const languages: Record<string, string> = {}
Object.entries(urls).forEach(([lang, { url }]) => {
languages[lang] = url
})
const canonical = urls[ctx.lang]?.url
alternates = {
canonical,
languages,
}
}
let metadata: Metadata | null = null
switch (ctx.contentType) {
case PageContentTypeEnum.accountPage:
const accountPageResponse = await fetchMetadata<{
account_page: RawMetadataSchema
}>(GetAccountPageMetadata, variables)
metadata = await getTransformedMetadata(
accountPageResponse.account_page
)
break
case PageContentTypeEnum.campaignOverviewPage:
const campaignOverviewPageResponse = await fetchMetadata<{
campaign_overview_page: RawMetadataSchema
}>(GetCampaignOverviewPageMetadata, variables)
metadata = await getTransformedMetadata(
campaignOverviewPageResponse.campaign_overview_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
}>(GetCollectionPageMetadata, variables)
metadata = await getTransformedMetadata(
collectionPageResponse.collection_page
)
break
case PageContentTypeEnum.contentPage:
const contentPageResponse = await fetchMetadata<{
content_page: RawMetadataSchema
}>(GetContentPageMetadata, variables)
metadata = await getTransformedMetadata(
contentPageResponse.content_page
)
break
case PageContentTypeEnum.destinationOverviewPage:
const destinationOverviewPageResponse = await fetchMetadata<{
destination_overview_page: RawMetadataSchema
}>(GetDestinationOverviewPageMetadata, variables)
metadata = await getTransformedMetadata(
destinationOverviewPageResponse.destination_overview_page
)
break
case PageContentTypeEnum.destinationCountryPage:
const destinationCountryPageResponse = await fetchMetadata<{
destination_country_page: RawMetadataSchema
}>(GetDestinationCountryPageMetadata, variables)
const countryData = await getCountryData(
destinationCountryPageResponse.destination_country_page,
input,
ctx.serviceToken,
ctx.lang
)
metadata = await getTransformedMetadata({
...destinationCountryPageResponse.destination_country_page,
destinationData: countryData,
})
break
case PageContentTypeEnum.destinationCityPage:
const destinationCityPageResponse = await fetchMetadata<{
destination_city_page: RawMetadataSchema
}>(GetDestinationCityPageMetadata, variables)
const cityData = await getCityData(
destinationCityPageResponse.destination_city_page,
input,
ctx.serviceToken,
ctx.lang
)
metadata = await getTransformedMetadata({
...destinationCityPageResponse.destination_city_page,
destinationData: cityData,
})
break
case PageContentTypeEnum.loyaltyPage:
const loyaltyPageResponse = await fetchMetadata<{
loyalty_page: RawMetadataSchema
}>(GetLoyaltyPageMetadata, variables)
metadata = await getTransformedMetadata(
loyaltyPageResponse.loyalty_page
)
break
case PageContentTypeEnum.hotelPage:
const hotelPageResponse = await fetchMetadata<{
hotel_page: RawMetadataSchema
}>(GetHotelPageMetadata, variables)
const hotelPageData = hotelPageResponse.hotel_page
const hotelData = hotelPageData.hotel_page_id
? await getHotel(
{
hotelId: hotelPageData.hotel_page_id,
isCardOnlyPayment: false,
language: ctx.lang,
},
ctx.serviceToken
)
: null
metadata = await getTransformedMetadata({
...hotelPageData,
...(hotelData
? {
hotelData: hotelData.hotel,
additionalHotelData: hotelData.additionalData,
hotelRestaurants: hotelData.restaurants,
}
: {}),
subpageUrl: input.subpage,
})
break
case PageContentTypeEnum.startPage:
const startPageResponse = await fetchMetadata<{
start_page: RawMetadataSchema
}>(GetStartPageMetadata, variables)
metadata = await getTransformedMetadata(startPageResponse.start_page)
default:
break
}
if (metadata) {
if (alternates) {
// Hiding alternates until all languages are released in production
if (env.NEW_SITE_LIVE_STATUS === "ALL_LANGUAGES_LIVE") {
metadata.alternates = alternates
}
}
if (input.noIndex) {
metadata.robots = {
index: false,
follow: false,
}
}
}
return { metadata, alternates }
}),
})

View File

@@ -1,43 +0,0 @@
import { getIntl } from "@/i18n"
import { truncateTextAfterLastPeriod } from "../truncate"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
export async function getDestinationCityPageDescription(
data: RawMetadataSchema
) {
const intl = await getIntl()
if (!data.destinationData || !data.destinationData.hotelCount) {
return null
}
const { hotelCount, location } = data.destinationData
if (hotelCount === 1) {
const destinationCitySingleHotelDescription = intl.formatMessage(
{
defaultMessage:
"Discover our Scandic hotel in {location}. Start your day with a delicious breakfast before exploring {location}. Book your stay at a Scandic hotel now!",
},
{
location: location,
}
)
return truncateTextAfterLastPeriod(destinationCitySingleHotelDescription)
}
const destinationCityMultipleHotelDescription = intl.formatMessage(
{
defaultMessage:
"Discover all our {hotelCount} Scandic hotels in {location}. Start your day with a delicious breakfast before exploring {location}. Book your stay at a Scandic hotel now!",
},
{
hotelCount: hotelCount,
location: location,
}
)
return truncateTextAfterLastPeriod(destinationCityMultipleHotelDescription)
}

View File

@@ -1,52 +0,0 @@
import { getIntl } from "@/i18n"
import { truncateTextAfterLastPeriod } from "../truncate"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
export async function getDestinationCountryPageDescription(
data: RawMetadataSchema
) {
const intl = await getIntl()
if (!data.destinationData?.location) {
return null
}
const { hotelCount, location, cities } = data.destinationData
let destinationCountryDescription: string | null = null
if (!hotelCount) {
destinationCountryDescription = intl.formatMessage(
{
defaultMessage:
"Discover {location}. Enjoy your stay at a Scandic hotel. Book now!",
},
{ location }
)
} else if (!cities || cities.length < 2) {
destinationCountryDescription = intl.formatMessage(
{
defaultMessage:
"Discover all our {hotelCount} Scandic hotels in {location}. Enjoy your stay at a Scandic hotel. Book now!",
},
{ hotelCount, location }
)
} else {
destinationCountryDescription = intl.formatMessage(
{
defaultMessage:
"Discover all our {hotelCount} Scandic hotels in {location}. Explore {city1}, {city2}, and more! All while enjoying your stay at a Scandic hotel. Book now!",
},
{
hotelCount: hotelCount,
location: location,
city1: cities[0],
city2: cities[1],
}
)
}
return truncateTextAfterLastPeriod(destinationCountryDescription)
}

View File

@@ -1,104 +0,0 @@
import { getIntl } from "@/i18n"
import { truncateTextAfterLastPeriod } from "../truncate"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
function getSubpageDescription(
subpageUrl: string,
additionalHotelData: RawMetadataSchema["additionalHotelData"],
hotelRestaurants: RawMetadataSchema["hotelRestaurants"]
) {
const restaurantSubPage = hotelRestaurants?.find(
(restaurant) => restaurant.nameInUrl === subpageUrl
)
if (restaurantSubPage?.elevatorPitch) {
return restaurantSubPage.elevatorPitch
}
if (!additionalHotelData) {
return null
}
switch (subpageUrl) {
case additionalHotelData.hotelParking.nameInUrl:
return additionalHotelData.hotelParking.elevatorPitch
case additionalHotelData.healthAndFitness.nameInUrl:
return additionalHotelData.healthAndFitness.elevatorPitch
case additionalHotelData.hotelSpecialNeeds.nameInUrl:
return additionalHotelData.hotelSpecialNeeds.elevatorPitch
case additionalHotelData.meetingRooms.nameInUrl:
return additionalHotelData.meetingRooms.elevatorPitch
default:
return null
}
}
export async function getHotelPageDescription(data: RawMetadataSchema) {
const intl = await getIntl()
const { subpageUrl, hotelData, additionalHotelData, hotelRestaurants } = data
if (!hotelData) {
return null
}
if (subpageUrl) {
const subpageDescription = getSubpageDescription(
subpageUrl,
additionalHotelData,
hotelRestaurants
)
if (subpageDescription) {
return truncateTextAfterLastPeriod(subpageDescription)
}
}
const hotelName = hotelData.name
const location = hotelData.address.city
const amenities = hotelData.detailedFacilities
if (amenities.length < 4) {
return intl.formatMessage(
{ defaultMessage: "{hotelName} in {location}. Book your stay now!" },
{ hotelName, location }
)
}
const hotelDescription = intl.formatMessage(
{
defaultMessage:
"{hotelName} in {location} offers {amenity1} and {amenity2}. Guests can also enjoy {amenity3} and {amenity4}. Book your stay at {hotelName} today!",
},
{
hotelName,
location,
amenity1: amenities[0].name,
amenity2: amenities[1].name,
amenity3: amenities[2].name,
amenity4: amenities[3].name,
}
)
const shortHotelDescription = intl.formatMessage(
{
defaultMessage:
"{hotelName} in {location} offers {amenity1} and {amenity2}. Guests can also enjoy {amenity3} and {amenity4}.",
},
{
hotelName,
location,
amenity1: amenities[0].name,
amenity2: amenities[1].name,
amenity3: amenities[2].name,
amenity4: amenities[3].name,
}
)
if (hotelDescription.length > 160) {
if (shortHotelDescription.length > 160) {
return truncateTextAfterLastPeriod(shortHotelDescription)
}
return shortHotelDescription
} else {
return hotelDescription
}
}

View File

@@ -1,58 +0,0 @@
import { truncateTextAfterLastPeriod } from "../truncate"
import { getDestinationCityPageDescription } from "./destinationCityPage"
import { getDestinationCountryPageDescription } from "./destinationCountryPage"
import { getHotelPageDescription } from "./hotelPage"
import { PageContentTypeEnum } from "@/types/requests/contentType"
import { RTETypeEnum } from "@/types/rte/enums"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
export async function getDescription(data: RawMetadataSchema) {
const metadata = data.web?.seo_metadata
if (metadata?.description) {
return metadata.description
}
let description: string | null = null
switch (data.system.content_type_uid) {
case PageContentTypeEnum.hotelPage:
description = await getHotelPageDescription(data)
break
case PageContentTypeEnum.destinationCityPage:
description = await getDestinationCityPageDescription(data)
break
case PageContentTypeEnum.destinationCountryPage:
description = await getDestinationCountryPageDescription(data)
break
default:
break
}
if (description) {
return description
}
// Fallback descriptions from contentstack content
if (data.preamble) {
return truncateTextAfterLastPeriod(data.preamble)
}
if (data.header?.preamble) {
return truncateTextAfterLastPeriod(data.header.preamble)
}
if (data.blocks?.length) {
const jsonData = data.blocks[0].content?.content?.json
// Finding the first paragraph with text
const firstParagraph = jsonData?.children?.find(
(child) => child.type === RTETypeEnum.p && child.children[0].text
)
if (firstParagraph?.children?.length) {
return firstParagraph.children[0].text
? truncateTextAfterLastPeriod(firstParagraph.children[0].text)
: ""
}
}
return ""
}

View File

@@ -1,117 +0,0 @@
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
export function getImage(data: RawMetadataSchema) {
const metadataImage = data.web?.seo_metadata?.seo_image
const heroImage =
data.hero_image || data.header?.hero_image || data.images?.[0]
// Currently we don't have the possibility to get smaller images from ImageVault (2024-11-15)
if (metadataImage) {
return {
url: metadataImage.url,
alt: metadataImage.meta.alt || undefined,
width: metadataImage.dimensions.width,
height: metadataImage.dimensions.height,
}
}
if (data.system.content_type_uid === "hotel_page" && data.hotelData) {
if (data.subpageUrl) {
let subpageImage: { url: string; alt: string } | undefined
const restaurantSubPage = data.hotelRestaurants?.find(
(restaurant) => restaurant.nameInUrl === data.subpageUrl
)
const restaurantImage = restaurantSubPage?.content?.images?.[0]
if (restaurantImage) {
subpageImage = {
url: restaurantImage.imageSizes.small,
alt:
restaurantImage.metaData.altText ||
restaurantImage.metaData.altText_En ||
"",
}
}
switch (data.subpageUrl) {
case data.additionalHotelData?.hotelParking.nameInUrl:
const parkingImage =
data.additionalHotelData?.parkingImages?.heroImages[0]
if (parkingImage) {
subpageImage = {
url: parkingImage.imageSizes.small,
alt:
parkingImage.metaData.altText ||
parkingImage.metaData.altText_En ||
"",
}
}
break
case data.additionalHotelData?.healthAndFitness.nameInUrl:
const wellnessImage = data.hotelData.healthFacilities.find(
(fac) => fac.content.images.length
)?.content.images[0]
if (wellnessImage) {
subpageImage = {
url: wellnessImage.imageSizes.small,
alt:
wellnessImage.metaData.altText ||
wellnessImage.metaData.altText_En ||
"",
}
}
break
case data.additionalHotelData?.hotelSpecialNeeds.nameInUrl:
const accessibilityImage =
data.additionalHotelData?.accessibility?.heroImages[0]
if (accessibilityImage) {
subpageImage = {
url: accessibilityImage.imageSizes.small,
alt:
accessibilityImage.metaData.altText ||
accessibilityImage.metaData.altText_En ||
"",
}
}
break
case data.additionalHotelData?.meetingRooms.nameInUrl:
const meetingImage =
data.additionalHotelData?.conferencesAndMeetings?.heroImages[0]
if (meetingImage) {
subpageImage = {
url: meetingImage.imageSizes.small,
alt:
meetingImage.metaData.altText ||
meetingImage.metaData.altText_En ||
"",
}
}
break
default:
break
}
if (subpageImage) {
return subpageImage
}
}
const hotelImage =
data.additionalHotelData?.gallery?.heroImages?.[0] ||
data.additionalHotelData?.gallery?.smallerImages?.[0]
if (hotelImage) {
return {
url: hotelImage.imageSizes.small,
alt: hotelImage.metaData.altText || undefined,
}
}
}
if (heroImage) {
return {
url: heroImage.url,
alt: heroImage.meta.alt || undefined,
width: heroImage.dimensions.width,
height: heroImage.dimensions.height,
}
}
return undefined
}

View File

@@ -1,141 +0,0 @@
import {
getFiltersFromHotels,
getSortedCities,
} from "@/stores/destination-data/helper"
import {
getCityByCityIdentifier,
getHotelIdsByCityIdentifier,
getHotelIdsByCountry,
getHotelsByHotelIds,
} from "../../../hotels/utils"
import { getCityPages } from "../../destinationCountryPage/utils"
import type { Lang } from "@scandic-hotels/common/constants/language"
import { ApiCountry } from "@/types/enums/country"
import { SortOption } from "@/types/enums/destinationFilterAndSort"
import type {
MetadataInputSchema,
RawMetadataSchema,
} from "@/types/trpc/routers/contentstack/metadata"
export const affix = "metadata"
export async function getCityData(
data: RawMetadataSchema,
input: MetadataInputSchema,
serviceToken: string,
lang: Lang
) {
const destinationSettings = data.destination_settings
const filter = input.filterFromUrl
if (destinationSettings) {
const {
city_sweden,
city_norway,
city_denmark,
city_finland,
city_germany,
city_poland,
} = destinationSettings
const cities = [
city_denmark,
city_finland,
city_germany,
city_poland,
city_norway,
city_sweden,
].filter((city): city is string => Boolean(city))
const cityIdentifier = cities[0]
if (cityIdentifier) {
const cityData = await getCityByCityIdentifier({
cityIdentifier,
serviceToken,
lang,
})
const hotelIds = await getHotelIdsByCityIdentifier(
cityIdentifier,
serviceToken
)
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
let filterType
if (filter) {
const allFilters = getFiltersFromHotels(hotels)
const facilityFilter = allFilters.facilityFilters.find(
(f) => f.slug === filter
)
const surroudingsFilter = allFilters.surroundingsFilters.find(
(f) => f.slug === filter
)
if (facilityFilter) {
filterType = "facility"
} else if (surroudingsFilter) {
filterType = "surroundings"
}
}
return {
location: cityData?.name,
filter,
filterType,
hotelCount: hotelIds.length,
}
}
}
return null
}
export async function getCountryData(
data: RawMetadataSchema,
input: MetadataInputSchema,
serviceToken: string,
lang: Lang
) {
const country = data.destination_settings?.country
const filter = input.filterFromUrl
if (country) {
const translatedCountry = ApiCountry[lang][country]
let filterType
const cities = await getCityPages(lang, serviceToken, country)
const sortedCities = getSortedCities(cities, SortOption.Recommended)
const hotelIds = await getHotelIdsByCountry({
country,
serviceToken,
})
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
if (filter) {
const allFilters = getFiltersFromHotels(hotels)
const facilityFilter = allFilters.facilityFilters.find(
(f) => f.slug === filter
)
const surroudingsFilter = allFilters.surroundingsFilters.find(
(f) => f.slug === filter
)
if (facilityFilter) {
filterType = "facility"
} else if (surroudingsFilter) {
filterType = "surroundings"
}
}
return {
location: translatedCountry,
filter,
filterType,
cities: sortedCities.slice(0, 2).map(({ cityName }) => cityName),
hotelCount: hotelIds.length,
}
}
return null
}

View File

@@ -1,214 +0,0 @@
import { getIntl } from "@/i18n"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
export async function getTitle(data: RawMetadataSchema) {
const intl = await getIntl()
const metadata = data.web?.seo_metadata
if (metadata?.title) {
return metadata.title
}
if (data.system.content_type_uid === "hotel_page" && data.hotelData) {
if (data.subpageUrl) {
const restaurantSubPage = data.hotelRestaurants?.find(
(restaurant) => restaurant.nameInUrl === data.subpageUrl
)
if (restaurantSubPage) {
const restaurantTitleLong = intl.formatMessage(
{
defaultMessage:
"Explore {restaurantName} at {hotelName} in {destination}",
},
{
restaurantName: restaurantSubPage.name,
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const restaurantTitleShort = intl.formatMessage(
{
defaultMessage: "Explore {restaurantName} at {hotelName}",
},
{
restaurantName: restaurantSubPage.name,
hotelName: data.hotelData.name,
}
)
if (restaurantTitleLong.length > 60) {
return restaurantTitleShort
}
return restaurantTitleLong
}
switch (data.subpageUrl) {
case "reviews":
const reviewsTitleLong = intl.formatMessage(
{
defaultMessage:
"Ratings & reviews for {hotelName} in {destination}",
},
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const reviewsTitleShort = intl.formatMessage(
{ defaultMessage: "Ratings & reviews for {hotelName}" },
{ hotelName: data.hotelData.name }
)
if (reviewsTitleLong.length > 60) {
return reviewsTitleShort
}
return reviewsTitleLong
case data.additionalHotelData?.hotelParking.nameInUrl:
const parkingTitleLong = intl.formatMessage(
{
defaultMessage:
"Parking information for {hotelName} in {destination}",
},
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const parkingTitleShort = intl.formatMessage(
{ defaultMessage: "Parking information for {hotelName}" },
{ hotelName: data.hotelData.name }
)
if (parkingTitleLong.length > 60) {
return parkingTitleShort
}
return parkingTitleLong
case data.additionalHotelData?.healthAndFitness.nameInUrl:
const wellnessTitleLong = intl.formatMessage(
{
defaultMessage:
"Gym & health facilities at {hotelName} in {destination}",
},
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const wellnessTitleShort = intl.formatMessage(
{
defaultMessage: "Gym & health facilities at {hotelName}",
},
{
hotelName: data.hotelData.name,
}
)
if (wellnessTitleLong.length > 60) {
return wellnessTitleShort
}
return wellnessTitleLong
case data.additionalHotelData?.hotelSpecialNeeds.nameInUrl:
const accessibilityTitleLong = intl.formatMessage(
{
defaultMessage:
"Accessibility information for {hotelName} in {destination}",
},
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const accessibilityTitleShort = intl.formatMessage(
{
defaultMessage: "Accessibility information for {hotelName}",
},
{
hotelName: data.hotelData.name,
}
)
if (accessibilityTitleLong.length > 60) {
return accessibilityTitleShort
}
return accessibilityTitleLong
case data.additionalHotelData?.meetingRooms.nameInUrl:
const meetingsTitleLong = intl.formatMessage(
{
defaultMessage:
"Meetings & conferences at {hotelName} in {destination}",
},
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const meetingsTitleShort = intl.formatMessage(
{
defaultMessage: "Meetings & conferences at {hotelName}",
},
{
hotelName: data.hotelData.name,
}
)
if (meetingsTitleLong.length > 60) {
return meetingsTitleShort
}
return meetingsTitleLong
default:
break
}
}
return intl.formatMessage(
{
defaultMessage: "Stay at {hotelName} | Hotel in {destination}",
},
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
}
if (
data.system.content_type_uid === "destination_city_page" ||
data.system.content_type_uid === "destination_country_page"
) {
if (data.destinationData) {
const { location, filter, filterType } = data.destinationData
if (location) {
if (filter) {
if (filterType === "facility") {
return intl.formatMessage(
{
defaultMessage: "Hotels with {filter} in {location}",
},
{ location, filter }
)
} else if (filterType === "surroundings") {
return intl.formatMessage(
{
defaultMessage: "Hotels near {filter} in {location}",
},
{ location, filter }
)
}
}
return intl.formatMessage(
{
defaultMessage: "Hotels in {location}",
},
{ location }
)
}
}
}
if (data.web?.breadcrumbs?.title) {
return data.web.breadcrumbs.title
}
if (data.heading) {
return data.heading
}
if (data.header?.heading) {
return data.header.heading
}
return ""
}

View File

@@ -1,60 +0,0 @@
/**
* Truncates the given text "intelligently" based on the last period found near the max length.
*
* - If a period exists within the extended range (`maxLength` to `maxLength + maxExtension`),
* the function truncates after the closest period to `maxLength`.
* - If no period is found in the range, it truncates the text after the last period found in the max length of the text.
* - If no periods exist at all, it truncates at `maxLength` and appends ellipsis (`...`).
*
* @param {string} text - The input text to be truncated.
* @param {number} [maxLength=150] - The desired maximum length of the truncated text.
* @param {number} [minLength=120] - The minimum allowable length for the truncated text.
* @param {number} [maxExtension=10] - The maximum number of characters to extend beyond `maxLength` to find a period.
* @returns {string} - The truncated text.
*/
export function truncateTextAfterLastPeriod(
text: string,
maxLength: number = 160,
minLength: number = 120,
maxExtension: number = 10
): string {
if (text.length <= maxLength) {
return text
}
// Define the extended range
const extendedEnd = Math.min(text.length, maxLength + maxExtension)
const extendedText = text.slice(0, extendedEnd)
// Find all periods within the extended range and filter after minLength to get valid periods
const periodsInRange = [...extendedText.matchAll(/\./g)].map(
({ index }) => index
)
const validPeriods = periodsInRange.filter((index) => index + 1 >= minLength)
if (validPeriods.length > 0) {
// Find the period closest to maxLength
const closestPeriod = validPeriods.reduce((closest, currentIndex) => {
const distanceFromCurrentToMaxLength = Math.abs(
currentIndex + 1 - maxLength
)
const distanceFromClosestToMaxLength = Math.abs(closest + 1 - maxLength)
return distanceFromCurrentToMaxLength < distanceFromClosestToMaxLength
? currentIndex
: closest
}, validPeriods[0])
return extendedText.slice(0, closestPeriod + 1)
}
// Fallback: If no period is found within the valid range, look for the last period in the truncated text
const maxLengthText = text.slice(0, maxLength)
const lastPeriodIndex = maxLengthText.lastIndexOf(".")
if (lastPeriodIndex !== -1) {
return text.slice(0, lastPeriodIndex + 1)
}
// Final fallback: Return maxLength text including ellipsis
return `${maxLengthText}...`
}

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { pageSettingsQueryRouter } from "./query"
export const pageSettingsRouter = mergeRouters(pageSettingsQueryRouter)

View File

@@ -1,30 +0,0 @@
import { z } from "zod"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
export const pageSettingsSchema = z.object({
hide_booking_widget: z.boolean(),
booking_code: nullableStringValidator,
})
export type PageSettingsSchema = z.output<typeof pageSettingsSchema>
const DEFAULT_PAGE_SETTINGS: PageSettingsSchema = {
hide_booking_widget: false,
booking_code: "",
} as const
export const getPageSettingsSchema = z.object({
page: z.object({
settings: pageSettingsSchema
.nullable()
.optional()
.transform((val) => val ?? DEFAULT_PAGE_SETTINGS),
}),
})
export type GetPageSettingsSchema = z.output<typeof getPageSettingsSchema>
export const DEFAULT_GET_PAGE_SETTINGS: GetPageSettingsSchema = {
page: {
settings: DEFAULT_PAGE_SETTINGS,
},
}

View File

@@ -1,94 +0,0 @@
import * as Sentry from "@sentry/nextjs"
import { router } from "@scandic-hotels/trpc"
import { contentstackBaseProcedure } from "@scandic-hotels/trpc/procedures"
import {
GetAccountPageSettings,
GetCampaignOverviewPageSettings,
GetCampaignPageSettings,
GetCollectionPageSettings,
GetContentPageSettings,
GetCurrentBlocksPageSettings,
GetDestinationCityPageSettings,
GetDestinationCountryPageSettings,
GetDestinationOverviewPageSettings,
GetHotelPageSettings,
GetLoyaltyPageSettings,
GetStartPageSettings,
} from "@/lib/graphql/Query/PageSettings.graphql"
import { request } from "@/lib/graphql/request"
import { langInput } from "@/server/utils"
import { generateTag } from "@/utils/generateTag"
import {
DEFAULT_GET_PAGE_SETTINGS,
type GetPageSettingsSchema,
getPageSettingsSchema,
} from "./output"
import { affix } from "./utils"
import { PageContentTypeEnum } from "@/types/requests/contentType"
export const pageSettingsQueryRouter = router({
get: contentstackBaseProcedure
.input(langInput)
.query(async ({ input, ctx }): Promise<GetPageSettingsSchema> => {
const { contentType, uid } = ctx
const lang = input.lang ?? ctx.lang
// This condition is to handle 404 page case and booking flow
if (!contentType || !uid) {
return DEFAULT_GET_PAGE_SETTINGS
}
const getPageSettingsQuery =
graphqlQueriesForContentType[contentType as PageContentTypeEnum]
if (!getPageSettingsQuery) {
Sentry.captureMessage(
`GetPageSettings: No proper Content type defined for '${contentType}'`
)
return DEFAULT_GET_PAGE_SETTINGS
}
const response = await request<GetPageSettingsSchema>(
getPageSettingsQuery,
{
uid: uid,
locale: lang,
},
{
key: generateTag(lang, uid, affix),
ttl: "max",
}
)
try {
const result = getPageSettingsSchema.parse(response.data)
return result
} catch (error) {
Sentry.captureException(error, { extra: { uid, contentType } })
return DEFAULT_GET_PAGE_SETTINGS
}
}),
})
const graphqlQueriesForContentType: Record<PageContentTypeEnum, any> = {
[PageContentTypeEnum.accountPage]: GetAccountPageSettings,
[PageContentTypeEnum.campaignOverviewPage]: GetCampaignOverviewPageSettings,
[PageContentTypeEnum.campaignPage]: GetCampaignPageSettings,
[PageContentTypeEnum.collectionPage]: GetCollectionPageSettings,
[PageContentTypeEnum.contentPage]: GetContentPageSettings,
[PageContentTypeEnum.currentBlocksPage]: GetCurrentBlocksPageSettings,
[PageContentTypeEnum.destinationCityPage]: GetDestinationCityPageSettings,
[PageContentTypeEnum.destinationCountryPage]:
GetDestinationCountryPageSettings,
[PageContentTypeEnum.destinationOverviewPage]:
GetDestinationOverviewPageSettings,
[PageContentTypeEnum.hotelPage]: GetHotelPageSettings,
[PageContentTypeEnum.loyaltyPage]: GetLoyaltyPageSettings,
[PageContentTypeEnum.startPage]: GetStartPageSettings,
}

View File

@@ -1 +0,0 @@
export const affix = "pageSettings"

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { partnerQueryRouter } from "./query"
export const partnerRouter = mergeRouters(partnerQueryRouter)

View File

@@ -1,49 +0,0 @@
import { z } from "zod"
import { linkUnionSchema, transformPageLink } from "../schemas/pageLinks"
const link = z.object({
href: z.string(),
title: z.string(),
})
export const validateSasTierComparisonSchema = z
.object({
all_sas_tier_comparison: z.object({
items: z.array(
z.object({
scandic_column_title: z.string(),
sas_column_title: z.string(),
tier_matches: z.array(
z.object({
scandic_friends_tier_name: z.string(),
sas_eb_tier_name: z.string(),
title: z.string(),
content: z.object({
json: z.any(), // json
}),
link: link.optional(),
})
),
call_to_action: z.object({
title: z.string().optional(),
linkConnection: z.object({
edges: z.array(
z.object({
node: linkUnionSchema,
})
),
}),
}),
})
),
}),
})
.transform((data) => {
const { call_to_action, ...item } = data.all_sas_tier_comparison.items[0]
const linkNode = call_to_action.linkConnection.edges[0]?.node
const link = transformPageLink(linkNode)
return { ...item, cta: { title: call_to_action.title, link } }
})

View File

@@ -1,67 +0,0 @@
import { cache } from "react"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentstackBaseProcedure } from "@scandic-hotels/trpc/procedures"
import { GetAllSasTierComparison } from "@/lib/graphql/Query/SASTierComparison.graphql"
import { request } from "@/lib/graphql/request"
import { validateSasTierComparisonSchema } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { SasTierComparisonResponse } from "@/types/trpc/routers/contentstack/partner"
export const getSasTierComparison = cache(async (lang: Lang) => {
const getSasTierComparisonCounter = createCounter(
"trpc.contentstack",
"partner.getSasTierComparison"
)
const metricsGetSasTierComparison = getSasTierComparisonCounter.init({
lang,
})
metricsGetSasTierComparison.start()
const tag = `${lang}:sas_tier_comparison`
const sasTierComparisonConfigResponse =
await request<SasTierComparisonResponse>(
GetAllSasTierComparison,
{ lang },
{
key: tag,
ttl: "max",
}
)
if (!sasTierComparisonConfigResponse.data) {
const notFoundError = notFound(sasTierComparisonConfigResponse)
metricsGetSasTierComparison.noDataError()
throw notFoundError
}
const validatedSasTierComparison = validateSasTierComparisonSchema.safeParse(
sasTierComparisonConfigResponse.data
)
if (!validatedSasTierComparison.success) {
metricsGetSasTierComparison.validationError(
validatedSasTierComparison.error
)
return null
}
metricsGetSasTierComparison.success()
return validatedSasTierComparison.data
})
export const partnerQueryRouter = router({
getSasTierComparison: contentstackBaseProcedure.query(async function ({
ctx,
}) {
return getSasTierComparison(ctx.lang)
}),
})

View File

@@ -1,5 +0,0 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { rewardQueryRouter } from "./query"
export const rewardRouter = mergeRouters(rewardQueryRouter)

View File

@@ -1,24 +0,0 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const rewardsByLevelInput = z.object({
level_id: z.nativeEnum(MembershipLevelEnum),
unique: z.boolean().default(false),
})
export const rewardsAllInput = z
.object({ unique: z.boolean() })
.default({ unique: false })
export const rewardsUpdateInput = z.array(
z.object({
rewardId: z.string(),
couponCode: z.string(),
})
)
export const rewardsRedeemInput = z.object({
rewardId: z.string(),
couponCode: z.string().optional(),
})

View File

@@ -1,175 +0,0 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
import {
linkRefsUnionSchema,
linkUnionSchema,
transformPageLink,
} from "../schemas/pageLinks"
import { systemSchema } from "../schemas/system"
export {
BenefitReward,
CouponData,
CouponReward,
REDEEM_LOCATIONS,
REWARD_TYPES,
rewardRefsSchema,
validateApiAllTiersSchema,
validateCategorizedRewardsSchema,
validateCmsRewardsSchema,
}
const validateCmsRewardsSchema = z
.object({
data: z.object({
all_reward: z.object({
items: z.array(
z.object({
taxonomies: z.array(
z.object({
term_uid: z.string().optional().default(""),
})
),
label: z.string().optional(),
reward_id: z.string(),
grouped_label: z.string().optional(),
description: z.string().optional(),
redeem_description: z
.object({
json: z.any(), // JSON
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkUnionSchema.transform((data) => {
const link = transformPageLink(data)
if (link) {
return link
}
return data
}),
})
),
}),
})
// This is primarily added in order to handle a transition
// switching from string to RTE
.nullable(),
grouped_description: z.string().optional(),
value: z.string().optional(),
})
),
}),
}),
})
.transform((data) => data.data.all_reward.items)
const rewardRefsSchema = z.object({
data: z.object({
all_reward: z.object({
items: z.array(
z.object({
redeem_description: z
.object({
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
})
// This is primarily added in order to handle a transition
// switching from string to RTE
.nullable(),
system: systemSchema,
})
),
}),
}),
})
const REDEEM_LOCATIONS = ["Non-redeemable", "On-site", "Online"] as const
const REWARD_CATEGORIES = [
"Restaurants",
"Bar",
"Voucher",
"Services and rooms",
"Spa and gym",
] as const
const BaseReward = z.object({
title: z.string().optional(),
id: z.string(),
categories: z
.array(z.enum(REWARD_CATEGORIES).or(z.literal("")))
.optional()
// we sometimes receive empty categories, this filters them out
.transform((categories = []) =>
categories.filter(
(c): c is (typeof REWARD_CATEGORIES)[number] => c !== ""
)
),
rewardId: z.string(),
redeemLocation: z.enum(REDEEM_LOCATIONS),
status: z.enum(["active", "expired"]),
})
const REWARD_TYPES = {
Surprise: "Surprise",
Campaign: "Campaign",
MemberVoucher: "Member-voucher",
Tier: "Tier",
} as const
const BenefitReward = BaseReward.merge(
z.object({
rewardType: z.enum([REWARD_TYPES.Tier]),
rewardTierLevel: z.string().optional(),
})
)
const CouponData = z.object({
couponCode: z.string(),
unwrapped: z.boolean().default(false),
state: z.enum(["claimed", "redeemed", "viewed"]),
expiresAt: z.string().datetime({ offset: true }).optional(),
})
const CouponReward = BaseReward.merge(
z.object({
rewardType: z.enum([
REWARD_TYPES.Surprise,
REWARD_TYPES.Campaign,
REWARD_TYPES.MemberVoucher,
]),
operaRewardId: z.string().default(""),
coupon: z
.array(CouponData)
.optional()
.transform((val) => val || []),
})
)
const validateCategorizedRewardsSchema = z.object({
benefits: z.array(BenefitReward),
coupons: z.array(CouponReward),
})
const TierKeyMapping = {
tier1: MembershipLevelEnum.L1,
tier2: MembershipLevelEnum.L2,
tier3: MembershipLevelEnum.L3,
tier4: MembershipLevelEnum.L4,
tier5: MembershipLevelEnum.L5,
tier6: MembershipLevelEnum.L6,
tier7: MembershipLevelEnum.L7,
} as const
const TierKeys = Object.keys(TierKeyMapping) as [keyof typeof TierKeyMapping]
const validateApiAllTiersSchema = z.record(
z.enum(TierKeys).transform((data) => TierKeyMapping[data]),
z.array(BenefitReward)
)

View File

@@ -1,396 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import { notFound } from "@scandic-hotels/trpc/errors"
import {
contentStackBaseWithProtectedProcedure,
contentStackBaseWithServiceProcedure,
protectedProcedure,
} from "@scandic-hotels/trpc/procedures"
import * as api from "@/lib/api"
import { langInput } from "@/server/utils"
import {
getRedeemableRewards,
getUnwrappedSurpriseRewards,
} from "@/utils/rewards"
import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
import {
rewardsAllInput,
rewardsByLevelInput,
rewardsRedeemInput,
rewardsUpdateInput,
} from "./input"
import { validateCategorizedRewardsSchema } from "./output"
import {
getCachedAllTierRewards,
getCmsRewards,
getUniqueRewardIds,
} from "./utils"
import type { BaseReward, Surprise } from "@/types/components/myPages/rewards"
import type { LevelWithRewards } from "@/types/components/overviewTable"
import type { CMSReward } from "@/types/trpc/routers/contentstack/reward"
export const rewardQueryRouter = router({
all: contentStackBaseWithServiceProcedure
.input(rewardsAllInput)
.query(async function ({ input, ctx }) {
const getContentstackRewardAllCounter = createCounter(
"trpc.contentstack",
"reward.all"
)
const metricsGetContentstackRewardAll =
getContentstackRewardAllCounter.init()
metricsGetContentstackRewardAll.start()
const allApiRewards = await getCachedAllTierRewards(ctx.serviceToken)
if (!allApiRewards) {
return []
}
const rewardIds = Object.values(allApiRewards)
.flatMap((level) => level.map((reward) => reward?.rewardId))
.filter((id): id is string => Boolean(id))
const contentStackRewards = await getCmsRewards(
ctx.lang,
getUniqueRewardIds(rewardIds)
)
if (!contentStackRewards) {
return []
}
const loyaltyLevelsConfig = await getAllLoyaltyLevels(ctx.lang)
const levelsWithRewards = Object.entries(allApiRewards).map(
([level, rewards]) => {
const combinedRewards = rewards
.filter((reward) =>
input.unique ? reward.rewardTierLevel === level : true
)
.map((reward) => {
const contentStackReward = contentStackRewards.find((r) => {
return r.reward_id === reward.rewardId
})
if (contentStackReward) {
return contentStackReward
} else {
metricsGetContentstackRewardAll.dataError(
`Failed to find reward in CMS for reward ${reward.rewardId} `,
{
rewardId: reward.rewardId,
}
)
}
})
.filter((reward): reward is CMSReward => Boolean(reward))
const levelConfig = loyaltyLevelsConfig.find(
(l) => l.level_id === level
)
if (!levelConfig) {
metricsGetContentstackRewardAll.dataError(
`Failed to matched loyalty level between API and CMS for level ${level}`
)
throw notFound()
}
const result: LevelWithRewards = {
...levelConfig,
rewards: combinedRewards,
}
return result
}
)
metricsGetContentstackRewardAll.success()
return levelsWithRewards
}),
byLevel: contentStackBaseWithServiceProcedure
.input(rewardsByLevelInput)
.query(async function ({ input, ctx }) {
const { level_id } = input
const getRewardByLevelCounter = createCounter(
"trpc.contentstack",
"reward.byLevel"
)
const metricsGetRewardByLevel = getRewardByLevelCounter.init({
level_id,
})
metricsGetRewardByLevel.start()
const allUpcomingApiRewards = await getCachedAllTierRewards(
ctx.serviceToken
)
if (!allUpcomingApiRewards || !allUpcomingApiRewards[level_id]) {
metricsGetRewardByLevel.noDataError()
return null
}
let apiRewards = allUpcomingApiRewards[level_id]!
if (input.unique) {
apiRewards = allUpcomingApiRewards[level_id]!.filter(
(reward) => reward?.rewardTierLevel === level_id
)
}
const rewardIds = apiRewards
.map((reward) => reward?.rewardId)
.filter((id): id is string => Boolean(id))
const [contentStackRewards, loyaltyLevelsConfig] = await Promise.all([
getCmsRewards(ctx.lang, rewardIds),
getLoyaltyLevel(ctx.lang, input.level_id),
])
if (!contentStackRewards) {
return null
}
const levelsWithRewards = apiRewards
.map((reward) => {
const contentStackReward = contentStackRewards.find((r) => {
return r.reward_id === reward.rewardId
})
if (contentStackReward) {
return contentStackReward
} else {
metricsGetRewardByLevel.dataError(
`Failed to find reward in Contentstack with rewardId: ${reward.rewardId}`,
{
rewardId: reward.rewardId,
}
)
}
})
.filter((reward): reward is CMSReward => Boolean(reward))
metricsGetRewardByLevel.success()
return { level: loyaltyLevelsConfig, rewards: levelsWithRewards }
}),
current: contentStackBaseWithProtectedProcedure
.input(langInput.optional()) // lang is required for client, but not for server
.query(async function ({ ctx }) {
const getCurrentRewardCounter = createCounter(
"trpc.contentstack",
"reward.current"
)
const metricsGetCurrentReward = getCurrentRewardCounter.init()
metricsGetCurrentReward.start()
const apiResponse = await api.get(
api.endpoints.v1.Profile.Reward.reward,
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
}
)
if (!apiResponse.ok) {
await metricsGetCurrentReward.httpError(apiResponse)
return null
}
const data = await apiResponse.json()
const validatedApiRewards =
validateCategorizedRewardsSchema.safeParse(data)
if (!validatedApiRewards.success) {
metricsGetCurrentReward.validationError(validatedApiRewards.error)
return null
}
const { benefits, coupons } = validatedApiRewards.data
const redeemableRewards = getRedeemableRewards([...benefits, ...coupons])
const rewardIds = redeemableRewards.map(({ rewardId }) => rewardId).sort()
const cmsRewards = await getCmsRewards(ctx.lang, rewardIds)
if (!cmsRewards) {
return null
}
const rewards: BaseReward[] = cmsRewards.map((cmsReward) => {
// Non-null assertion is used here because we know our reward exist
const apiReward = redeemableRewards.find(
({ rewardId }) => rewardId === cmsReward.reward_id
)!
return {
...apiReward,
...cmsReward,
}
})
metricsGetCurrentReward.success()
return { rewards }
}),
surprises: contentStackBaseWithProtectedProcedure
.input(langInput.optional()) // lang is required for client, but not for server
.query(async ({ ctx }) => {
const getSurprisesCounter = createCounter(
"trpc.contentstack",
"surprises"
)
const metricsGetSurprises = getSurprisesCounter.init()
metricsGetSurprises.start()
const endpoint = api.endpoints.v1.Profile.Reward.reward
const apiResponse = await api.get(endpoint, {
cache: undefined,
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
})
if (!apiResponse.ok) {
await metricsGetSurprises.httpError(apiResponse)
return null
}
const data = await apiResponse.json()
const validatedApiRewards =
validateCategorizedRewardsSchema.safeParse(data)
if (!validatedApiRewards.success) {
metricsGetSurprises.validationError(validatedApiRewards.error)
return null
}
const unwrappedSurpriseRewards = getUnwrappedSurpriseRewards(
validatedApiRewards.data.coupons
)
const rewardIds = unwrappedSurpriseRewards
.map(({ rewardId }) => rewardId)
.sort()
const cmsRewards = await getCmsRewards(ctx.lang, rewardIds)
if (!cmsRewards) {
return null
}
const surprises: Surprise[] = cmsRewards.map((cmsReward) => {
// Non-null assertion is used here because we know our reward exist
const apiReward = unwrappedSurpriseRewards.find(
({ rewardId }) => rewardId === cmsReward.reward_id
)!
return {
...apiReward,
...cmsReward,
}
})
metricsGetSurprises.success()
return surprises
}),
unwrap: protectedProcedure
.input(rewardsUpdateInput)
.mutation(async ({ input, ctx }) => {
const results = await Promise.allSettled(
// Execute each unwrap individually
input.map(({ rewardId, couponCode }) => {
async function handleUnwrap() {
const getUnwrapSurpriseCounter = createCounter(
"trpc.contentstack",
"reward.unwrap"
)
const metricsGetUnwrapSurprise = getUnwrapSurpriseCounter.init({
rewardId,
couponCode,
})
metricsGetUnwrapSurprise.start()
const apiResponse = await api.post(
api.endpoints.v1.Profile.Reward.unwrap,
{
body: {
rewardId,
couponCode,
},
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
}
)
if (!apiResponse.ok) {
metricsGetUnwrapSurprise.httpError(apiResponse)
return false
}
metricsGetUnwrapSurprise.success()
return true
}
return handleUnwrap()
})
)
if (
results.some(
(result) => result.status === "rejected" || result.value === false
)
) {
return null
}
return true
}),
redeem: protectedProcedure
.input(rewardsRedeemInput)
.mutation(async ({ input, ctx }) => {
const { rewardId, couponCode } = input
const getRedeemCounter = createCounter(
"trpc.contentstack",
"reward.redeem"
)
const metricGetRedeem = getRedeemCounter.init({ rewardId, couponCode })
metricGetRedeem.start()
const apiResponse = await api.post(
api.endpoints.v1.Profile.Reward.redeem,
{
body: {
rewardId,
couponCode,
},
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
}
)
if (!apiResponse.ok) {
metricGetRedeem.httpError(apiResponse)
return null
}
metricGetRedeem.success()
return true
}),
})

View File

@@ -1,168 +0,0 @@
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { notFound } from "@scandic-hotels/trpc/errors"
import * as api from "@/lib/api"
import {
GetRewards as GetRewards,
GetRewardsRef as GetRewardsRef,
} from "@/lib/graphql/Query/RewardsWithRedeem.graphql"
import { request } from "@/lib/graphql/request"
import {
generateLoyaltyConfigTag,
generateRefsResponseTag,
} from "@/utils/generateTag"
import {
rewardRefsSchema,
validateApiAllTiersSchema,
validateCmsRewardsSchema,
} from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type {
CMSRewardsResponse,
GetRewardRefsSchema,
} from "@/types/trpc/routers/contentstack/reward"
export function getUniqueRewardIds(rewardIds: string[]) {
const uniqueRewardIds = new Set(rewardIds)
return Array.from(uniqueRewardIds)
}
/**
* Cached for 1 hour.
*/
export async function getCachedAllTierRewards(token: string) {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
"getAllTierRewards",
async () => {
const getApiRewardAllTiersCounter = createCounter(
"trpc.api",
"reward.allTiers"
)
const metricsGetApiRewardAllTiers = getApiRewardAllTiersCounter.init()
metricsGetApiRewardAllTiers.start()
const apiResponse = await api.get(
api.endpoints.v1.Profile.Reward.allTiers,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
if (!apiResponse.ok) {
metricsGetApiRewardAllTiers.httpError(apiResponse)
throw apiResponse
}
const data = await apiResponse.json()
const validatedApiAllTierRewards =
validateApiAllTiersSchema.safeParse(data)
if (!validatedApiAllTierRewards.success) {
metricsGetApiRewardAllTiers.validationError(
validatedApiAllTierRewards.error
)
throw validatedApiAllTierRewards.error
}
metricsGetApiRewardAllTiers.success()
return validatedApiAllTierRewards.data
},
"1h"
)
}
export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
if (!rewardIds.length) {
return null
}
const tags = rewardIds.map((id) =>
generateLoyaltyConfigTag(lang, "reward", id)
)
const getContentstackRewardAllRefsCounter = createCounter(
"trpc.contentstack",
"reward.all.refs"
)
const metricsGetContentstackRewardAllRefs =
getContentstackRewardAllRefsCounter.init({ lang, rewardIds })
metricsGetContentstackRewardAllRefs.start()
const refsResponse = await request<GetRewardRefsSchema>(
GetRewardsRef,
{
locale: lang,
rewardIds,
},
{
key: rewardIds.map((rewardId) => generateRefsResponseTag(lang, rewardId)),
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
metricsGetContentstackRewardAllRefs.noDataError()
throw notFoundError
}
const validatedRefsData = rewardRefsSchema.safeParse(refsResponse)
if (!validatedRefsData.success) {
metricsGetContentstackRewardAllRefs.validationError(validatedRefsData.error)
return null
}
metricsGetContentstackRewardAllRefs.success()
const getContentstackRewardAllCounter = createCounter(
"trpc.contentstack",
"reward.all"
)
const metricsGetContentstackRewardAll = getContentstackRewardAllCounter.init({
lang,
rewardIds,
})
const cmsRewardsResponse = await request<CMSRewardsResponse>(
GetRewards,
{
locale: lang,
rewardIds,
},
{
key: tags,
ttl: "max",
}
)
if (!cmsRewardsResponse.data) {
const notFoundError = notFound(cmsRewardsResponse)
metricsGetContentstackRewardAll.noDataError()
throw notFoundError
}
const validatedCmsRewards =
validateCmsRewardsSchema.safeParse(cmsRewardsResponse)
if (!validatedCmsRewards.success) {
metricsGetContentstackRewardAll.validationError(validatedCmsRewards.error)
return null
}
metricsGetContentstackRewardAll.success()
return validatedCmsRewards.data
}

View File

@@ -1,194 +0,0 @@
import { z } from "zod"
import {
linkRefsUnionSchema,
linkUnionSchema,
transformPageLink,
} from "../pageLinks"
import { sysAssetSchema } from "./sysAsset"
import { BlocksEnums } from "@/types/enums/blocks"
export const embeddedContentSchema = z.union([linkUnionSchema, sysAssetSchema])
export const accordionItemsSchema = z.array(
z.object({
question: z.string(),
answer: z.object({
json: z.any(), // JSON
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: embeddedContentSchema.transform((data) => {
const link = transformPageLink(data)
if (link) {
return link
}
return data
}),
})
),
}),
}),
})
)
export type Accordion = z.infer<typeof accordionSchema>
enum AccordionEnum {
CampaignPageBlocksAccordionBlockAccordionsGlobalAccordion = "CampaignPageBlocksAccordionBlockAccordionsGlobalAccordion",
CampaignPageBlocksAccordionBlockAccordionsSpecificAccordion = "CampaignPageBlocksAccordionBlockAccordionsSpecificAccordion",
ContentPageBlocksAccordionBlockAccordionsGlobalAccordion = "ContentPageBlocksAccordionBlockAccordionsGlobalAccordion",
ContentPageBlocksAccordionBlockAccordionsSpecificAccordion = "ContentPageBlocksAccordionBlockAccordionsSpecificAccordion",
DestinationCityPageBlocksAccordionBlockAccordionsGlobalAccordion = "DestinationCityPageBlocksAccordionBlockAccordionsGlobalAccordion",
DestinationCityPageBlocksAccordionBlockAccordionsSpecificAccordion = "DestinationCityPageBlocksAccordionBlockAccordionsSpecificAccordion",
DestinationCountryPageBlocksAccordionBlockAccordionsGlobalAccordion = "DestinationCountryPageBlocksAccordionBlockAccordionsGlobalAccordion",
DestinationCountryPageBlocksAccordionBlockAccordionsSpecificAccordion = "DestinationCountryPageBlocksAccordionBlockAccordionsSpecificAccordion",
}
export const accordionSchema = z.object({
typename: z
.literal(BlocksEnums.block.Accordion)
.optional()
.default(BlocksEnums.block.Accordion),
accordion: z
.object({
title: z.string().optional().default(""),
accordions: z.array(
z.object({
__typename: z.nativeEnum(AccordionEnum),
global_accordion: z
.object({
global_accordionConnection: z.object({
edges: z.array(
z.object({
node: z.object({
questions: accordionItemsSchema,
}),
})
),
}),
})
.optional(),
specific_accordion: z
.object({
questions: accordionItemsSchema,
})
.optional(),
})
),
})
.transform((data) => {
return {
...data,
accordions: data.accordions.flatMap((acc) => {
switch (acc.__typename) {
case AccordionEnum.CampaignPageBlocksAccordionBlockAccordionsGlobalAccordion:
case AccordionEnum.ContentPageBlocksAccordionBlockAccordionsGlobalAccordion:
case AccordionEnum.DestinationCityPageBlocksAccordionBlockAccordionsGlobalAccordion:
case AccordionEnum.DestinationCountryPageBlocksAccordionBlockAccordionsGlobalAccordion:
return (
acc.global_accordion?.global_accordionConnection.edges.flatMap(
({ node: accordionConnection }) => {
return accordionConnection.questions
}
) || []
)
case AccordionEnum.CampaignPageBlocksAccordionBlockAccordionsSpecificAccordion:
case AccordionEnum.ContentPageBlocksAccordionBlockAccordionsSpecificAccordion:
case AccordionEnum.DestinationCityPageBlocksAccordionBlockAccordionsSpecificAccordion:
case AccordionEnum.DestinationCountryPageBlocksAccordionBlockAccordionsSpecificAccordion:
return acc.specific_accordion?.questions || []
}
}),
}
}),
})
export const globalAccordionConnectionRefs = z.object({
edges: z.array(
z.object({
node: z.object({
questions: z.array(
z.object({
answer: z.object({
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
}),
})
),
}),
})
),
})
export const specificAccordionConnectionRefs = z.object({
questions: z.array(
z.object({
answer: z.object({
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
}),
})
),
})
export const accordionRefsSchema = z.object({
accordion: z
.object({
accordions: z.array(
z.object({
__typename: z.nativeEnum(AccordionEnum),
global_accordion: z
.object({
global_accordionConnection: globalAccordionConnectionRefs,
})
.optional(),
specific_accordion: specificAccordionConnectionRefs.optional(),
})
),
})
.transform((data) => {
return data.accordions.flatMap((accordion) => {
switch (accordion.__typename) {
case AccordionEnum.CampaignPageBlocksAccordionBlockAccordionsGlobalAccordion:
case AccordionEnum.ContentPageBlocksAccordionBlockAccordionsGlobalAccordion:
case AccordionEnum.DestinationCityPageBlocksAccordionBlockAccordionsGlobalAccordion:
case AccordionEnum.DestinationCountryPageBlocksAccordionBlockAccordionsGlobalAccordion:
return (
accordion.global_accordion?.global_accordionConnection.edges.flatMap(
({ node: accordionConnection }) => {
return accordionConnection.questions.flatMap((question) =>
question.answer.embedded_itemsConnection.edges.flatMap(
({ node }) => node.system
)
)
}
) || []
)
case AccordionEnum.CampaignPageBlocksAccordionBlockAccordionsSpecificAccordion:
case AccordionEnum.DestinationCityPageBlocksAccordionBlockAccordionsSpecificAccordion:
case AccordionEnum.DestinationCountryPageBlocksAccordionBlockAccordionsSpecificAccordion:
return (
accordion.specific_accordion?.questions.flatMap((question) =>
question.answer.embedded_itemsConnection.edges.flatMap(
({ node }) => node.system
)
) || []
)
default:
return []
}
})
}),
})

Some files were not shown because too many files have changed in this diff Show More