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:
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { accountPageQueryRouter } from "./query"
|
||||
|
||||
export const accountPageRouter = mergeRouters(accountPageQueryRouter)
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { baseQueryRouter } from "./query"
|
||||
|
||||
export const baseRouter = mergeRouters(baseQueryRouter)
|
||||
@@ -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,
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { breadcrumbsQueryRouter } from "./query"
|
||||
|
||||
export const breadcrumbsRouter = mergeRouters(breadcrumbsQueryRouter)
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
@@ -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 []
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { campaignOverviewPageQueryRouter } from "./query"
|
||||
|
||||
export const campaignOverviewPageRouter = mergeRouters(
|
||||
campaignOverviewPageQueryRouter
|
||||
)
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { campaignPageQueryRouter } from "./query"
|
||||
|
||||
export const campaignPageRouter = mergeRouters(campaignPageQueryRouter)
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { collectionPageQueryRouter } from "./query"
|
||||
|
||||
export const collectionPageRouter = mergeRouters(collectionPageQueryRouter)
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { contentPageQueryRouter } from "./query"
|
||||
|
||||
export const contentPageRouter = mergeRouters(contentPageQueryRouter)
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { destinationCityPageQueryRouter } from "./query"
|
||||
|
||||
export const destinationCityPageRouter = mergeRouters(
|
||||
destinationCityPageQueryRouter
|
||||
)
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { destinationCountryPageQueryRouter } from "./query"
|
||||
|
||||
export const destinationCountryPageRouter = mergeRouters(
|
||||
destinationCountryPageQueryRouter
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { Country } from "@/types/enums/country"
|
||||
|
||||
export const getCityPagesInput = z.object({
|
||||
country: z.nativeEnum(Country),
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,7 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { destinationOverviewPageQueryRouter } from "./query"
|
||||
|
||||
export const destinationOverviewPageRouter = mergeRouters(
|
||||
destinationOverviewPageQueryRouter
|
||||
)
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { hotelPageQueryRouter } from "./query"
|
||||
|
||||
export const hotelPageRouter = mergeRouters(hotelPageQueryRouter)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { languageSwitcherQueryRouter } from "./query"
|
||||
|
||||
export const languageSwitcherRouter = mergeRouters(languageSwitcherQueryRouter)
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { loyaltyLevelQueryRouter } from "./query"
|
||||
|
||||
export const loyaltyLevelRouter = mergeRouters(loyaltyLevelQueryRouter)
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
}),
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { loyaltyPageQueryRouter } from "./query"
|
||||
|
||||
export const loyaltyPageRouter = mergeRouters(loyaltyPageQueryRouter)
|
||||
@@ -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(),
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { metadataQueryRouter } from "./query"
|
||||
|
||||
export const metadataRouter = mergeRouters(metadataQueryRouter)
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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}...`
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { pageSettingsQueryRouter } from "./query"
|
||||
|
||||
export const pageSettingsRouter = mergeRouters(pageSettingsQueryRouter)
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const affix = "pageSettings"
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { partnerQueryRouter } from "./query"
|
||||
|
||||
export const partnerRouter = mergeRouters(partnerQueryRouter)
|
||||
@@ -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 } }
|
||||
})
|
||||
@@ -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)
|
||||
}),
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mergeRouters } from "@scandic-hotels/trpc"
|
||||
|
||||
import { rewardQueryRouter } from "./query"
|
||||
|
||||
export const rewardRouter = mergeRouters(rewardQueryRouter)
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user