Merged in monorepo-step-1 (pull request #1080)
Migrate to a monorepo setup - step 1 * Move web to subfolder /apps/scandic-web * Yarn + transitive deps - Move to yarn - design-system package removed for now since yarn doesn't support the parameter for token (ie project currently broken) - Add missing transitive dependencies as Yarn otherwise prevents these imports - VS Code doesn't pick up TS path aliases unless you open /apps/scandic-web instead of root (will be fixed with monorepo) * Pin framer-motion to temporarily fix typing issue https://github.com/adobe/react-spectrum/issues/7494 * Pin zod to avoid typ error There seems to have been a breaking change in the types returned by zod where error is now returned as undefined instead of missing in the type. We should just handle this but to avoid merge conflicts just pin the dependency for now. * Pin react-intl version Pin version of react-intl to avoid tiny type issue where formatMessage does not accept a generic any more. This will be fixed in a future commit, but to avoid merge conflicts just pin for now. * Pin typescript version Temporarily pin version as newer versions as stricter and results in a type error. Will be fixed in future commit after merge. * Setup workspaces * Add design-system as a monorepo package * Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN * Fix husky for monorepo setup * Update netlify.toml * Add lint script to root package.json * Add stub readme * Fix react-intl formatMessage types * Test netlify.toml in root * Remove root toml * Update netlify.toml publish path * Remove package-lock.json * Update build for branch/preview builds Approved-by: Linus Flood
This commit is contained in:
committed by
Linus Flood
parent
667cab6fb6
commit
80100e7631
41
apps/scandic-web/utils/aes.ts
Normal file
41
apps/scandic-web/utils/aes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
function base64ToUint8Array(base64String: string) {
|
||||
const binaryString = atob(base64String)
|
||||
const byteArray = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
byteArray[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
return byteArray
|
||||
}
|
||||
|
||||
function utf8ToUint8Array(utf8String: string) {
|
||||
return new TextEncoder().encode(utf8String)
|
||||
}
|
||||
|
||||
function uint8ArrayToUtf8(uint8Array: Uint8Array) {
|
||||
return new TextDecoder().decode(uint8Array)
|
||||
}
|
||||
|
||||
export async function decryptData(
|
||||
keyBase64: string,
|
||||
ivBase64: string,
|
||||
encryptedDataBase64: string
|
||||
): Promise<string> {
|
||||
const keyBuffer = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
base64ToUint8Array(keyBase64),
|
||||
"AES-CBC",
|
||||
false,
|
||||
["decrypt"]
|
||||
)
|
||||
|
||||
const encryptedDataBuffer = base64ToUint8Array(encryptedDataBase64)
|
||||
const ivBuffer = base64ToUint8Array(ivBase64)
|
||||
const decryptedDataBuffer = await crypto.subtle.decrypt(
|
||||
{ name: "AES-CBC", iv: ivBuffer },
|
||||
keyBuffer,
|
||||
encryptedDataBuffer
|
||||
)
|
||||
|
||||
const decryptedData = uint8ArrayToUtf8(new Uint8Array(decryptedDataBuffer))
|
||||
return decryptedData
|
||||
}
|
||||
22
apps/scandic-web/utils/cache.ts
Normal file
22
apps/scandic-web/utils/cache.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import stringify from "json-stable-stringify-without-jsonify"
|
||||
import { cache as reactCache } from "react"
|
||||
|
||||
/**
|
||||
* Wrapper function to handle caching of memoized requests that recieve objects as args.
|
||||
* React Cache will use shallow equality of the arguments to determine if there is a cache hit,
|
||||
* therefore we need to stringify the arguments to ensure that the cache works as expected.
|
||||
* This function will handle the stingification of the arguments, the caching of the function,
|
||||
* and the parsing of the arguments back to their original form.
|
||||
*
|
||||
* @param fn - The function to memoize
|
||||
*/
|
||||
export function cache<T extends (...args: any[]) => any>(fn: T) {
|
||||
const cachedFunction = reactCache((stringifiedParams: string) => {
|
||||
return fn(...JSON.parse(stringifiedParams))
|
||||
})
|
||||
|
||||
return (...args: Parameters<T>): ReturnType<T> => {
|
||||
const stringifiedParams = stringify(args)
|
||||
return cachedFunction(stringifiedParams)
|
||||
}
|
||||
}
|
||||
14
apps/scandic-web/utils/clientSession.ts
Normal file
14
apps/scandic-web/utils/clientSession.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Session } from "next-auth"
|
||||
|
||||
export function isValidClientSession(session: Session | null) {
|
||||
if (!session) {
|
||||
console.log("No session available (user not authenticated).")
|
||||
return false
|
||||
}
|
||||
if (session.error) {
|
||||
console.log(`Session error: ${session.error}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
20
apps/scandic-web/utils/contactConfig.ts
Normal file
20
apps/scandic-web/utils/contactConfig.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
ContactConfig,
|
||||
ContactFieldGroups,
|
||||
} from "@/server/routers/contentstack/base/output"
|
||||
|
||||
export function getValueFromContactConfig(
|
||||
keyString: string,
|
||||
data: ContactConfig
|
||||
): string | undefined {
|
||||
const [groupName, key] = keyString.split(".") as [
|
||||
ContactFieldGroups,
|
||||
keyof ContactConfig[ContactFieldGroups],
|
||||
]
|
||||
if (data[groupName]) {
|
||||
const fieldGroup = data[groupName]
|
||||
|
||||
return fieldGroup[key]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
27
apps/scandic-web/utils/dateFormatting.ts
Normal file
27
apps/scandic-web/utils/dateFormatting.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import d from "dayjs"
|
||||
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
/**
|
||||
* Get the localized month name for a given month index and language
|
||||
* @param monthIndex - The month index (1-12)
|
||||
* @param lang - the language to use, Lang enum
|
||||
* @returns The localized month name
|
||||
*/
|
||||
export function getLocalizedMonthName(monthIndex: number, lang: Lang) {
|
||||
const monthName = new Date(2024, monthIndex - 1).toLocaleString(lang, {
|
||||
month: "long",
|
||||
})
|
||||
|
||||
return monthName.charAt(0).toUpperCase() + monthName.slice(1)
|
||||
}
|
||||
|
||||
export function getNights(start: string, end: string) {
|
||||
const range = []
|
||||
let current = d(start)
|
||||
while (current.isBefore(end)) {
|
||||
range.push(current)
|
||||
current = current.add(1, "days")
|
||||
}
|
||||
return range
|
||||
}
|
||||
10
apps/scandic-web/utils/debounce.ts
Normal file
10
apps/scandic-web/utils/debounce.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function debounce(func: Function, delay = 300) {
|
||||
let debounceTimer: ReturnType<typeof setTimeout>
|
||||
return function () {
|
||||
// @ts-expect-error this in TypeScript
|
||||
const context = this
|
||||
const args = arguments
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => func.apply(context, args), delay)
|
||||
}
|
||||
}
|
||||
47
apps/scandic-web/utils/entry.ts
Normal file
47
apps/scandic-web/utils/entry.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { batchEdgeRequest } from "@/lib/graphql/batchEdgeRequest"
|
||||
import {
|
||||
EntryByUrlBatch1,
|
||||
EntryByUrlBatch2,
|
||||
} from "@/lib/graphql/Query/ResolveEntry.graphql"
|
||||
import { internalServerError } from "@/server/errors/next"
|
||||
|
||||
import { validateEntryResolveSchema } from "@/types/requests/entry"
|
||||
|
||||
export async function resolve(url: string, lang = Lang.en) {
|
||||
const variables = { locale: lang, url: url || "/" }
|
||||
|
||||
// The maximum amount of content types you can query is 6, therefor more
|
||||
// than that is being batched
|
||||
const response = await batchEdgeRequest([
|
||||
{
|
||||
document: EntryByUrlBatch1,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
document: EntryByUrlBatch2,
|
||||
variables,
|
||||
},
|
||||
])
|
||||
|
||||
const validatedData = validateEntryResolveSchema.safeParse(response.data)
|
||||
|
||||
if (!validatedData.success) {
|
||||
throw internalServerError(validatedData.error)
|
||||
}
|
||||
|
||||
for (const value of Object.values(validatedData.data)) {
|
||||
if (value.total) {
|
||||
const { content_type_uid, uid } = value.items[0].system
|
||||
return {
|
||||
contentType: content_type_uid,
|
||||
uid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contentType: null,
|
||||
uid: null,
|
||||
}
|
||||
}
|
||||
229
apps/scandic-web/utils/facilityCards.ts
Normal file
229
apps/scandic-web/utils/facilityCards.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
type Facilities,
|
||||
type FacilityCard,
|
||||
FacilityCardButtonText,
|
||||
type FacilityCardType,
|
||||
FacilityCardTypeEnum,
|
||||
type FacilityGrid,
|
||||
type FacilityImage,
|
||||
HealthFacilitiesEnum,
|
||||
RestaurantHeadings,
|
||||
WellnessHeadings,
|
||||
} from "@/types/components/hotelPage/facilities"
|
||||
import { SidepeekSlugs } from "@/types/components/hotelPage/hotelPage"
|
||||
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||
import { FacilityEnum } from "@/types/enums/facilities"
|
||||
import type {
|
||||
Amenities,
|
||||
Facility,
|
||||
FacilityData,
|
||||
HealthFacilities,
|
||||
} from "@/types/hotel"
|
||||
import type { CardProps } from "@/components/TempDesignSystem/Card/card"
|
||||
|
||||
export function setFacilityCards(
|
||||
restaurantImages: FacilityData | undefined,
|
||||
conferencesAndMeetings: FacilityData | undefined,
|
||||
healthAndWellness: FacilityData | undefined,
|
||||
hasRestaurants: boolean,
|
||||
hasMeetingRooms: boolean,
|
||||
hasWellness: boolean
|
||||
): Facility[] {
|
||||
const facilities = []
|
||||
if (hasRestaurants) {
|
||||
facilities.push(
|
||||
setFacilityCard(restaurantImages, FacilityCardTypeEnum.restaurant)
|
||||
)
|
||||
}
|
||||
if (hasMeetingRooms) {
|
||||
facilities.push(
|
||||
setFacilityCard(conferencesAndMeetings, FacilityCardTypeEnum.conference)
|
||||
)
|
||||
}
|
||||
if (hasWellness) {
|
||||
facilities.push(
|
||||
setFacilityCard(healthAndWellness, FacilityCardTypeEnum.wellness)
|
||||
)
|
||||
}
|
||||
return facilities
|
||||
}
|
||||
|
||||
function setFacilityCard(
|
||||
facility: FacilityData | undefined,
|
||||
type: FacilityCardTypeEnum
|
||||
): Facility {
|
||||
return {
|
||||
...facility,
|
||||
id: type,
|
||||
headingText: facility?.headingText ?? "",
|
||||
heroImages: facility?.heroImages ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function isFacilityCard(card: FacilityCardType): card is FacilityCard {
|
||||
return "heading" in card
|
||||
}
|
||||
|
||||
export function isFacilityImage(card: FacilityCardType): card is FacilityImage {
|
||||
return "backgroundImage" in card
|
||||
}
|
||||
|
||||
function setCardProps(
|
||||
theme: CardProps["theme"],
|
||||
buttonText: (typeof FacilityCardButtonText)[keyof typeof FacilityCardButtonText],
|
||||
href: HotelHashValues,
|
||||
heading: string,
|
||||
slug: SidepeekSlugs,
|
||||
scriptedTopTitle?: string
|
||||
): FacilityCard {
|
||||
return {
|
||||
theme,
|
||||
id: href,
|
||||
heading,
|
||||
scriptedTopTitle,
|
||||
secondaryButton: {
|
||||
href: `#s-${slug}`,
|
||||
title: buttonText,
|
||||
isExternal: false,
|
||||
scrollOnClick: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function setFacilityCardGrids(
|
||||
facilities: Facility[],
|
||||
amenities: Amenities,
|
||||
healthFacilities: HealthFacilities
|
||||
): Facilities {
|
||||
const cards: Facilities = facilities
|
||||
.filter((fac) => !!fac.headingText)
|
||||
.map((facility) => {
|
||||
let card: FacilityCard
|
||||
|
||||
const grid: FacilityGrid = facility.heroImages
|
||||
.slice(0, 2)
|
||||
.map((image) => {
|
||||
// Can be a maximum 2 images per grid
|
||||
const img: FacilityImage = {
|
||||
backgroundImage: {
|
||||
url: image.imageSizes.large,
|
||||
title: image.metaData.title,
|
||||
meta: {
|
||||
alt: image.metaData.altText,
|
||||
caption: image.metaData.altText_En,
|
||||
},
|
||||
id: image.imageSizes.large,
|
||||
},
|
||||
theme: "image",
|
||||
id: image.imageSizes.large,
|
||||
}
|
||||
return img
|
||||
})
|
||||
|
||||
switch (facility.id) {
|
||||
case FacilityCardTypeEnum.wellness:
|
||||
const wellnessTitle = getWellnessHeading(healthFacilities)
|
||||
card = setCardProps(
|
||||
"one",
|
||||
FacilityCardButtonText.WELLNESS,
|
||||
HotelHashValues.wellness,
|
||||
facility.headingText,
|
||||
SidepeekSlugs.wellness,
|
||||
wellnessTitle
|
||||
)
|
||||
grid.unshift(card)
|
||||
break
|
||||
|
||||
case FacilityCardTypeEnum.conference:
|
||||
card = setCardProps(
|
||||
"primaryDim",
|
||||
FacilityCardButtonText.MEETINGS,
|
||||
HotelHashValues.meetings,
|
||||
facility.headingText,
|
||||
SidepeekSlugs.meetings,
|
||||
"Events that make an impression"
|
||||
)
|
||||
grid.push(card)
|
||||
break
|
||||
|
||||
case FacilityCardTypeEnum.restaurant:
|
||||
const restaurantTitle = getRestaurantHeading(amenities)
|
||||
card = setCardProps(
|
||||
"primaryDark",
|
||||
FacilityCardButtonText.RESTAURANT,
|
||||
HotelHashValues.restaurant,
|
||||
facility.headingText,
|
||||
SidepeekSlugs.restaurant,
|
||||
restaurantTitle
|
||||
)
|
||||
grid.unshift(card)
|
||||
break
|
||||
}
|
||||
return grid
|
||||
})
|
||||
return cards
|
||||
}
|
||||
|
||||
function getRestaurantHeading(amenities: Amenities): RestaurantHeadings {
|
||||
const hasBar = amenities.some(
|
||||
(facility) =>
|
||||
facility.id === FacilityEnum.Bar ||
|
||||
facility.id === FacilityEnum.RooftopBar ||
|
||||
facility.id === FacilityEnum.Skybar
|
||||
)
|
||||
const hasRestaurant = amenities.some(
|
||||
(facility) => facility.id === FacilityEnum.Restaurant
|
||||
)
|
||||
|
||||
if (hasBar && hasRestaurant) {
|
||||
return RestaurantHeadings.restaurantAndBar
|
||||
} else if (hasBar) {
|
||||
return RestaurantHeadings.bar
|
||||
} else if (hasRestaurant) {
|
||||
return RestaurantHeadings.restaurant
|
||||
}
|
||||
return RestaurantHeadings.breakfastRestaurant
|
||||
}
|
||||
|
||||
export function getWellnessHeading(
|
||||
healthFacilities: HealthFacilities
|
||||
): WellnessHeadings | undefined {
|
||||
const hasGym = healthFacilities.some(
|
||||
(facility) => facility.type === HealthFacilitiesEnum.Gym
|
||||
)
|
||||
const hasSauna = healthFacilities.some(
|
||||
(faility) => faility.type === HealthFacilitiesEnum.Sauna
|
||||
)
|
||||
const hasRelax = healthFacilities.some(
|
||||
(facility) => facility.type === HealthFacilitiesEnum.Relax
|
||||
)
|
||||
const hasJacuzzi = healthFacilities.some(
|
||||
(facility) => facility.type === HealthFacilitiesEnum.Jacuzzi
|
||||
)
|
||||
const hasPool = healthFacilities.some(
|
||||
(facility) =>
|
||||
facility.type === HealthFacilitiesEnum.IndoorPool ||
|
||||
facility.type === HealthFacilitiesEnum.OutdoorPool
|
||||
)
|
||||
|
||||
if (hasGym && hasJacuzzi && hasSauna && hasRelax) {
|
||||
return WellnessHeadings.GymJacuzziSaunaRelax
|
||||
} else if (hasGym && hasPool && hasSauna && hasRelax) {
|
||||
return WellnessHeadings.GymPoolSaunaRelax
|
||||
} else if (hasGym && hasSauna) {
|
||||
return WellnessHeadings.GymSauna
|
||||
} else if (hasGym && hasPool) {
|
||||
return WellnessHeadings.GymPool
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function filterFacilityCards(cards: FacilityGrid) {
|
||||
const card = cards.filter((card) => isFacilityCard(card))
|
||||
const images = cards.filter((card) => isFacilityImage(card))
|
||||
|
||||
return {
|
||||
card: card[0] as FacilityCard,
|
||||
images: images as FacilityImage[],
|
||||
}
|
||||
}
|
||||
18
apps/scandic-web/utils/generateMetadata.ts
Normal file
18
apps/scandic-web/utils/generateMetadata.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import type {
|
||||
ContentTypeParams,
|
||||
LangParams,
|
||||
PageArgs,
|
||||
UIDParams,
|
||||
} from "@/types/params"
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: PageArgs<LangParams & ContentTypeParams & UIDParams, { subpage?: string }>) {
|
||||
const { subpage } = searchParams
|
||||
const metadata = await serverClient().contentstack.metadata.get({
|
||||
subpage,
|
||||
})
|
||||
return metadata
|
||||
}
|
||||
122
apps/scandic-web/utils/generateTag.ts
Normal file
122
apps/scandic-web/utils/generateTag.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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 { Lang } from "@/constants/languages"
|
||||
|
||||
/**
|
||||
* Function to generate tag for initial refs request
|
||||
*
|
||||
* @param lang Lang
|
||||
* @param identifier Should be uid for all pages and content_type_uid for
|
||||
* everything else
|
||||
* @param affix possible extra value to add to string, e.g lang:identifier:breadcrumbs:refs
|
||||
* as it is the same entity as the actual page tag otherwise
|
||||
* @returns string
|
||||
*/
|
||||
export function generateRefsResponseTag(
|
||||
lang: Lang,
|
||||
identifier: string,
|
||||
affix?: string
|
||||
) {
|
||||
if (affix) {
|
||||
return `${lang}:${identifier}:${affix}:refs`
|
||||
}
|
||||
return `${lang}:${identifier}:refs`
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate all tags to references on entity
|
||||
*
|
||||
* @param lang Lang
|
||||
* @param contentTypeUid content_type_uid of reference
|
||||
* @param uid system.uid of reference
|
||||
* @returns string
|
||||
*/
|
||||
export function generateRefTag(
|
||||
lang: Lang,
|
||||
contentTypeUid: string,
|
||||
uid: string
|
||||
) {
|
||||
return `${lang}:ref:${contentTypeUid}:${uid}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate tag for entity being requested
|
||||
*
|
||||
* @param lang Lang
|
||||
* @param uid system.uid of entity
|
||||
* @param affix possible extra value to add to string, e.g lang:uid:breadcrumbs
|
||||
* as it is the same entity as the actual page tag otherwise
|
||||
* @returns string
|
||||
*/
|
||||
export function generateTag(lang: Lang, uid: string, affix?: string) {
|
||||
if (affix) {
|
||||
return `${lang}:${uid}:${affix}`
|
||||
}
|
||||
|
||||
return `${lang}:${uid}`
|
||||
}
|
||||
|
||||
export function generateTags(lang: Lang, connections: Edges<NodeRefs>[]) {
|
||||
return connections
|
||||
.map((connection) => {
|
||||
return connection.edges.map(({ node }) => {
|
||||
return generateRefTag(
|
||||
lang,
|
||||
node.system.content_type_uid,
|
||||
node.system.uid
|
||||
)
|
||||
})
|
||||
})
|
||||
.flat()
|
||||
}
|
||||
|
||||
export function generateTagsFromSystem(
|
||||
lang: Lang,
|
||||
connections: System["system"][]
|
||||
) {
|
||||
return connections.map((system) => {
|
||||
return generateRefTag(
|
||||
system.locale ?? lang,
|
||||
system.content_type_uid,
|
||||
system.uid
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate tags for loyalty configuration models
|
||||
*
|
||||
* @param lang Lang
|
||||
* @param contentTypeUid content_type_uid of reference
|
||||
* @param id system shared identifier, e.g reward_id, level_id
|
||||
* @returns string
|
||||
*/
|
||||
export function generateLoyaltyConfigTag(
|
||||
lang: Lang,
|
||||
contentTypeUid: string,
|
||||
id: string
|
||||
) {
|
||||
return `${lang}:loyalty_config:${contentTypeUid}:${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate tags for service tokens
|
||||
*
|
||||
* @param serviceTokenScope scope of service token
|
||||
* @returns string
|
||||
*/
|
||||
export function generateServiceTokenTag(scopes: string[]) {
|
||||
return `service_token:${scopes.join("-")}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate tags for hotel page urls
|
||||
*
|
||||
* @param lang Lang
|
||||
* @param hotelId hotelId of reference
|
||||
* @returns string
|
||||
*/
|
||||
export function generateHotelUrlTag(lang: Lang, hotelId: string) {
|
||||
return `${lang}:hotel_page_url:${hotelId}`
|
||||
}
|
||||
34
apps/scandic-web/utils/imageGallery.ts
Normal file
34
apps/scandic-web/utils/imageGallery.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { GalleryImage } from "@/types/components/imageGallery"
|
||||
import type { ImageVaultAsset } from "@/types/components/imageVault"
|
||||
import type { ApiImage } from "@/types/hotel"
|
||||
|
||||
function mapApiImageToGalleryImage(apiImage: ApiImage): GalleryImage {
|
||||
return {
|
||||
src: apiImage.imageSizes.medium,
|
||||
alt: apiImage.metaData.altText || apiImage.metaData.title,
|
||||
caption: apiImage.metaData.title,
|
||||
smallSrc: apiImage.imageSizes.small,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapApiImagesToGalleryImages(
|
||||
apiImages: ApiImage[]
|
||||
): GalleryImage[] {
|
||||
return apiImages.map(mapApiImageToGalleryImage)
|
||||
}
|
||||
|
||||
function mapImageVaultImageToGalleryImage(
|
||||
imageVaultImage: ImageVaultAsset
|
||||
): GalleryImage {
|
||||
return {
|
||||
src: imageVaultImage.url,
|
||||
alt: imageVaultImage.meta.alt || imageVaultImage.meta.caption || "",
|
||||
caption: imageVaultImage.meta.caption,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapImageVaultImagesToGalleryImages(
|
||||
imageVaultImages: ImageVaultAsset[]
|
||||
): GalleryImage[] {
|
||||
return imageVaultImages.map(mapImageVaultImageToGalleryImage)
|
||||
}
|
||||
44
apps/scandic-web/utils/imageVault.ts
Normal file
44
apps/scandic-web/utils/imageVault.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
ImageVaultAsset,
|
||||
ImageVaultAssetResponse,
|
||||
} from "@/types/components/imageVault"
|
||||
|
||||
export function insertResponseToImageVaultAsset(
|
||||
response: ImageVaultAssetResponse
|
||||
): ImageVaultAsset {
|
||||
const alt = response.Metadata?.find((meta) =>
|
||||
meta.Name.includes("AltText_")
|
||||
)?.Value
|
||||
|
||||
const caption = response.Metadata?.find((meta) =>
|
||||
meta.Name.includes("Title_")
|
||||
)?.Value
|
||||
|
||||
const mediaConversion = response.MediaConversions[0]
|
||||
const aspectRatio =
|
||||
mediaConversion.FormatAspectRatio ||
|
||||
mediaConversion.AspectRatio ||
|
||||
mediaConversion.Width / mediaConversion.Height
|
||||
|
||||
return {
|
||||
url: mediaConversion.Url,
|
||||
id: response.Id,
|
||||
meta: {
|
||||
alt,
|
||||
caption,
|
||||
},
|
||||
title: response.Name,
|
||||
dimensions: {
|
||||
width: mediaConversion.Width,
|
||||
height: mediaConversion.Height,
|
||||
aspectRatio,
|
||||
},
|
||||
focalPoint: response.FocalPoint || { x: 50, y: 50 },
|
||||
}
|
||||
}
|
||||
|
||||
export function makeImageVaultImage(image: any) {
|
||||
return image && !!Object.keys(image).length
|
||||
? insertResponseToImageVaultAsset(image as ImageVaultAssetResponse)
|
||||
: undefined
|
||||
}
|
||||
9
apps/scandic-web/utils/isValidJson.ts
Normal file
9
apps/scandic-web/utils/isValidJson.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default function isValidJson(value: string | null | undefined): boolean {
|
||||
if (!value || value === "undefined") return false
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
79
apps/scandic-web/utils/jsonSchemas.ts
Normal file
79
apps/scandic-web/utils/jsonSchemas.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import type {
|
||||
BreadcrumbList,
|
||||
Hotel as HotelSchema,
|
||||
ListItem,
|
||||
WithContext,
|
||||
} from "schema-dts"
|
||||
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
import type { Breadcrumbs } from "@/types/trpc/routers/contentstack/breadcrumbs"
|
||||
|
||||
export function generateBreadcrumbsSchema(breadcrumbs: Breadcrumbs) {
|
||||
const itemListElement: ListItem[] = breadcrumbs.map((item, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
name: item.title,
|
||||
// Only include "item" if "href" exists; otherwise, omit it
|
||||
...(item.href ? { item: `${env.PUBLIC_URL}${item.href}` } : {}),
|
||||
}))
|
||||
|
||||
const jsonLd: WithContext<BreadcrumbList> = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement,
|
||||
}
|
||||
|
||||
return {
|
||||
key: "breadcrumbs",
|
||||
type: "application/ld+json",
|
||||
jsonLd,
|
||||
}
|
||||
}
|
||||
|
||||
export function generateHotelSchema(hotel: Hotel) {
|
||||
const ratings = hotel.ratings?.tripAdvisor
|
||||
const checkinData = hotel.hotelFacts.checkin
|
||||
const image = hotel.gallery?.heroImages[0] || hotel.gallery?.smallerImages[0]
|
||||
const facilities = hotel.detailedFacilities
|
||||
const jsonLd: WithContext<HotelSchema> = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Hotel",
|
||||
name: hotel.name,
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: hotel.address.streetAddress,
|
||||
addressLocality: hotel.address.city,
|
||||
postalCode: hotel.address.zipCode,
|
||||
addressCountry: hotel.address.country,
|
||||
},
|
||||
checkinTime: checkinData.checkInTime,
|
||||
checkoutTime: checkinData.checkOutTime,
|
||||
amenityFeature: facilities.map((facility) => ({
|
||||
"@type": "LocationFeatureSpecification",
|
||||
name: facility.name,
|
||||
})),
|
||||
}
|
||||
|
||||
if (image) {
|
||||
jsonLd.image = {
|
||||
"@type": "ImageObject",
|
||||
url: image.imageSizes.small,
|
||||
caption: image.metaData.title,
|
||||
}
|
||||
}
|
||||
|
||||
if (ratings && ratings.rating && ratings.numberOfReviews) {
|
||||
jsonLd.aggregateRating = {
|
||||
"@type": "AggregateRating",
|
||||
ratingValue: ratings.rating,
|
||||
reviewCount: ratings.numberOfReviews,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "application/ld+json",
|
||||
jsonLd,
|
||||
}
|
||||
}
|
||||
21
apps/scandic-web/utils/languages.ts
Normal file
21
apps/scandic-web/utils/languages.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
|
||||
export function findLang(pathname: string) {
|
||||
const langFromPath = Object.values(Lang).find(
|
||||
(l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
|
||||
)
|
||||
|
||||
const parsedLang = languageSchema.safeParse(langFromPath)
|
||||
if (!parsedLang.success) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsedLang.data
|
||||
}
|
||||
|
||||
export const languageSchema = z.preprocess(
|
||||
(arg) => (typeof arg === "string" ? arg.toLowerCase() : arg),
|
||||
z.nativeEnum(Lang)
|
||||
)
|
||||
50
apps/scandic-web/utils/loyaltyTable.ts
Normal file
50
apps/scandic-web/utils/loyaltyTable.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Reward } from "@/server/routers/contentstack/reward/output"
|
||||
|
||||
import type { ComparisonLevel } from "@/types/components/overviewTable"
|
||||
|
||||
export function getGroupedRewards(levels: ComparisonLevel[]) {
|
||||
const allRewards = levels
|
||||
.map((level) => {
|
||||
return level.rewards
|
||||
})
|
||||
.flat()
|
||||
|
||||
const mappedRewards = allRewards.reduce<Record<string, Reward[]>>(
|
||||
(acc, curr) => {
|
||||
const taxonomiTerm = curr.taxonomies.find((tax) => tax.term_uid)?.term_uid
|
||||
|
||||
if (taxonomiTerm) {
|
||||
if (!acc[taxonomiTerm]) {
|
||||
acc[taxonomiTerm] = []
|
||||
}
|
||||
acc[taxonomiTerm].push(curr)
|
||||
} else {
|
||||
if (!acc[curr.reward_id]) {
|
||||
acc[curr.reward_id] = []
|
||||
}
|
||||
acc[curr.reward_id].push(curr)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
return mappedRewards
|
||||
}
|
||||
|
||||
export function findAvailableRewards(
|
||||
allRewardIds: string[],
|
||||
level: ComparisonLevel
|
||||
) {
|
||||
return level.rewards.find((r) => allRewardIds.includes(r.reward_id))
|
||||
}
|
||||
|
||||
export function getGroupedLabelAndDescription(rewards: Reward[]) {
|
||||
const reward = rewards.find(
|
||||
(reward) => !!(reward.grouped_label && reward.grouped_label)
|
||||
)
|
||||
return {
|
||||
label: reward?.grouped_label ?? "",
|
||||
description: reward?.grouped_description ?? "",
|
||||
}
|
||||
}
|
||||
88
apps/scandic-web/utils/map.ts
Normal file
88
apps/scandic-web/utils/map.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import crypto from "node:crypto"
|
||||
|
||||
// Helper function to calculate the latitude offset
|
||||
export function calculateLatWithOffset(
|
||||
latitude: number,
|
||||
offsetPx: number,
|
||||
zoomLevel: number
|
||||
): number {
|
||||
const earthCircumference = 40075017 // Earth's circumference in meters
|
||||
const tileSize = 256 // Height of a tile in pixels (standard in Google Maps)
|
||||
|
||||
// Calculate ground resolution (meters per pixel) at the given latitude and zoom level
|
||||
const groundResolution =
|
||||
(earthCircumference * Math.cos((latitude * Math.PI) / 180)) /
|
||||
(tileSize * Math.pow(2, zoomLevel))
|
||||
|
||||
// Calculate the number of meters for the given offset in pixels
|
||||
const metersOffset = groundResolution * offsetPx
|
||||
|
||||
// Convert the meters offset into a latitude offset (1 degree latitude is ~111,320 meters)
|
||||
const latOffset = metersOffset / 111320
|
||||
|
||||
// Return the new latitude by subtracting the offset
|
||||
return latitude - latOffset
|
||||
}
|
||||
|
||||
/**
|
||||
* Util functions taken from https://developers.google.com/maps/documentation/maps-static/digital-signature#sample-code-for-url-signing
|
||||
* Used to sign the URL for the Google Static Maps API.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert from 'web safe' base64 to true base64.
|
||||
*
|
||||
* @param {string} safeEncodedString The code you want to translate
|
||||
* from a web safe form.
|
||||
* @return {string}
|
||||
*/
|
||||
function removeWebSafe(safeEncodedString: string) {
|
||||
return safeEncodedString.replace(/-/g, "+").replace(/_/g, "/")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert from true base64 to 'web safe' base64
|
||||
*
|
||||
* @param {string} encodedString The code you want to translate to a
|
||||
* web safe form.
|
||||
* @return {string}
|
||||
*/
|
||||
function makeWebSafe(encodedString: string) {
|
||||
return encodedString.replace(/\+/g, "-").replace(/\//g, "_")
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a base64 code and decodes it.
|
||||
*
|
||||
* @param {string} code The encoded data.
|
||||
* @return {string}
|
||||
*/
|
||||
function decodeBase64Hash(code: string) {
|
||||
return Buffer.from(code, "base64")
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a key and signs the data with it.
|
||||
*
|
||||
* @param {string} key Your unique secret key.
|
||||
* @param {string} data The url to sign.
|
||||
* @return {string}
|
||||
*/
|
||||
function encodeBase64Hash(key: Buffer, data: string) {
|
||||
return crypto.createHmac("sha1", key).update(data).digest("base64")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a URL using a secret key.
|
||||
*
|
||||
* @param {URL} url The url you want to sign.
|
||||
* @param {string} secret Your unique secret key.
|
||||
* @return {string}
|
||||
*/
|
||||
export function getUrlWithSignature(url: URL, secret = "") {
|
||||
const path = url.pathname + url.search
|
||||
const safeSecret = decodeBase64Hash(removeWebSafe(secret))
|
||||
const hashedSignature = makeWebSafe(encodeBase64Hash(safeSecret, path))
|
||||
|
||||
return `${url.toString()}&signature=${hashedSignature}`
|
||||
}
|
||||
52
apps/scandic-web/utils/maskValue.ts
Normal file
52
apps/scandic-web/utils/maskValue.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
function maskAll(str: string) {
|
||||
return "*".repeat(str.length)
|
||||
}
|
||||
|
||||
function maskAllButFirstChar(str: string) {
|
||||
const first = str[0]
|
||||
const rest = str.substring(1)
|
||||
const restMasked = maskAll(rest)
|
||||
|
||||
return `${first}${restMasked}`
|
||||
}
|
||||
|
||||
function maskAllButLastTwoChar(str: string) {
|
||||
const lastTwo = str.slice(-2)
|
||||
const rest = str.substring(0, str.length - 2)
|
||||
const restMasked = maskAll(rest)
|
||||
|
||||
return `${restMasked}${lastTwo}`
|
||||
}
|
||||
|
||||
export function email(str: string) {
|
||||
const parts = str.split("@")
|
||||
|
||||
const aliasMasked = maskAllButFirstChar(parts[0])
|
||||
|
||||
if (parts[1]) {
|
||||
const domainParts = parts[1].split(".")
|
||||
if (domainParts.length > 1) {
|
||||
const domainTLD = domainParts.pop()
|
||||
const domainPartsMasked = domainParts
|
||||
.map((domainPart, i) => {
|
||||
return maskAllButFirstChar(domainPart)
|
||||
})
|
||||
.join(".")
|
||||
return `${aliasMasked}@${domainPartsMasked}.${domainTLD}`
|
||||
}
|
||||
}
|
||||
|
||||
return maskAllButFirstChar(str)
|
||||
}
|
||||
|
||||
export function phone(str: string) {
|
||||
return maskAllButLastTwoChar(str)
|
||||
}
|
||||
|
||||
export function text(str: string) {
|
||||
return maskAllButFirstChar(str)
|
||||
}
|
||||
|
||||
export function all(str: string) {
|
||||
return maskAll(str)
|
||||
}
|
||||
27
apps/scandic-web/utils/maskvalue.test.ts
Normal file
27
apps/scandic-web/utils/maskvalue.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "@jest/globals"
|
||||
|
||||
import { all, email, phone, text } from "./maskValue"
|
||||
|
||||
describe("Mask value", () => {
|
||||
test("masks e-mails properly", () => {
|
||||
expect(email("test@example.com")).toBe("t***@e******.com")
|
||||
expect(email("test@sub.example.com")).toBe("t***@s**.e******.com")
|
||||
expect(email("test_no_atexample.com")).toBe("t********************")
|
||||
expect(email("test_no_dot@examplecom")).toBe("t*********************")
|
||||
expect(email("test_no_at_no_dot_com")).toBe("t********************")
|
||||
})
|
||||
|
||||
test("masks phone number properly", () => {
|
||||
expect(phone("0000000000")).toBe("********00")
|
||||
})
|
||||
|
||||
test("masks text strings properly", () => {
|
||||
expect(text("test")).toBe("t***")
|
||||
expect(text("test.with.dot")).toBe("t************")
|
||||
})
|
||||
|
||||
test("masks whole string properly", () => {
|
||||
expect(all("test")).toBe("****")
|
||||
expect(all("123jknasd@iajsd.c")).toBe("*****************")
|
||||
})
|
||||
})
|
||||
5
apps/scandic-web/utils/membershipLevels.ts
Normal file
5
apps/scandic-web/utils/membershipLevels.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
|
||||
export function isMembershipLevel(value: string): value is MembershipLevelEnum {
|
||||
return Object.values(MembershipLevelEnum).some((level) => level === value)
|
||||
}
|
||||
19
apps/scandic-web/utils/merge.ts
Normal file
19
apps/scandic-web/utils/merge.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import merge from "deepmerge"
|
||||
|
||||
export function arrayMerge(
|
||||
target: any[],
|
||||
source: any[],
|
||||
options: merge.ArrayMergeOptions
|
||||
) {
|
||||
const destination = target.slice()
|
||||
source.forEach((item, index) => {
|
||||
if (typeof destination[index] === "undefined") {
|
||||
destination[index] = options.cloneUnlessOtherwiseSpecified(item, options)
|
||||
} else if (options?.isMergeableObject(item)) {
|
||||
destination[index] = merge(target[index], item, options)
|
||||
} else if (target.indexOf(item) === -1) {
|
||||
destination.push(item)
|
||||
}
|
||||
})
|
||||
return destination
|
||||
}
|
||||
24
apps/scandic-web/utils/numberFormatting.ts
Normal file
24
apps/scandic-web/utils/numberFormatting.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { IntlShape } from "react-intl"
|
||||
|
||||
/**
|
||||
* Function to parse number with single decimal if any
|
||||
* @param n
|
||||
* @returns number in float type with single digit decimal if any
|
||||
*/
|
||||
export function getSingleDecimal(n: Number | string) {
|
||||
return parseFloat(Number(n).toFixed(1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to parse number for i18n format for prices with currency
|
||||
* @param intl - react-intl object
|
||||
* @param price - number to be formatted
|
||||
* @param currency - currency code
|
||||
* @returns localized and formatted number in string type with currency
|
||||
*/
|
||||
export function formatPrice(intl: IntlShape, price: number, currency: string) {
|
||||
const localizedPrice = intl.formatNumber(price, {
|
||||
minimumFractionDigits: 0,
|
||||
})
|
||||
return `${localizedPrice} ${currency}`
|
||||
}
|
||||
6
apps/scandic-web/utils/rangeArray.ts
Normal file
6
apps/scandic-web/utils/rangeArray.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function rangeArray(start: number, stop: number, step: number = 1) {
|
||||
return Array.from(
|
||||
{ length: (stop - start) / step + 1 },
|
||||
(value, index) => start + index * step
|
||||
)
|
||||
}
|
||||
51
apps/scandic-web/utils/rewards.ts
Normal file
51
apps/scandic-web/utils/rewards.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
RESTAURANT_REWARD_IDS,
|
||||
REWARD_IDS,
|
||||
REWARD_TYPES,
|
||||
} from "@/constants/rewards"
|
||||
|
||||
import type {
|
||||
RestaurantRewardId,
|
||||
RewardId,
|
||||
RewardType,
|
||||
} from "@/types/components/myPages/rewards"
|
||||
import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output"
|
||||
|
||||
export function isValidRewardId(id: string): id is RewardId {
|
||||
return Object.values<string>(REWARD_IDS).includes(id)
|
||||
}
|
||||
|
||||
export function isRestaurantReward(
|
||||
rewardId: string
|
||||
): rewardId is RestaurantRewardId {
|
||||
return RESTAURANT_REWARD_IDS.some((id) => id === rewardId)
|
||||
}
|
||||
|
||||
export function redeemLocationIsOnSite(
|
||||
location: RewardWithRedeem["redeemLocation"]
|
||||
): location is "On-site" {
|
||||
return location === "On-site"
|
||||
}
|
||||
|
||||
export function isTierType(
|
||||
type: RewardWithRedeem["rewardType"]
|
||||
): type is "Tier" {
|
||||
return type === "Tier"
|
||||
}
|
||||
|
||||
export function isOnSiteTierReward(reward: RewardWithRedeem): boolean {
|
||||
return (
|
||||
redeemLocationIsOnSite(reward.redeemLocation) &&
|
||||
isTierType(reward.rewardType)
|
||||
)
|
||||
}
|
||||
|
||||
export function isRestaurantOnSiteTierReward(
|
||||
reward: RewardWithRedeem
|
||||
): boolean {
|
||||
return isOnSiteTierReward(reward) && isRestaurantReward(reward.reward_id)
|
||||
}
|
||||
|
||||
export function getRewardType(type?: string): RewardType | null {
|
||||
return REWARD_TYPES.find((t) => t === type) ?? null
|
||||
}
|
||||
11
apps/scandic-web/utils/safeTry.ts
Normal file
11
apps/scandic-web/utils/safeTry.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type SafeTryResult<T> = Promise<
|
||||
[T, undefined] | [undefined, Error | unknown]
|
||||
>
|
||||
|
||||
export async function safeTry<T>(func: Promise<T>): SafeTryResult<T> {
|
||||
try {
|
||||
return [await func, undefined]
|
||||
} catch (err) {
|
||||
return [undefined, err]
|
||||
}
|
||||
}
|
||||
27
apps/scandic-web/utils/session.ts
Normal file
27
apps/scandic-web/utils/session.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import "server-only"
|
||||
|
||||
import type { Session } from "next-auth"
|
||||
|
||||
export function isValidSession(session: Session | null) {
|
||||
if (!session) {
|
||||
console.log("No session available (user not authenticated).")
|
||||
return false
|
||||
}
|
||||
if (session.error) {
|
||||
console.log(`Session error: ${session.error}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const token = session.token
|
||||
|
||||
if (token?.error) {
|
||||
console.log(`Session token error: ${token.error}`)
|
||||
return false
|
||||
}
|
||||
if (token?.expires_at && token.expires_at < Date.now()) {
|
||||
console.log(`Session expired: ${session.token.expires_at}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
75
apps/scandic-web/utils/sitemap.ts
Normal file
75
apps/scandic-web/utils/sitemap.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { getStore } from "@netlify/blobs"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import type { SitemapData, SyncItem } from "@/types/sitemap"
|
||||
|
||||
const branch = env.CMS_BRANCH
|
||||
const environment = env.CMS_ENVIRONMENT
|
||||
const entriesKey = `${environment}/${branch}/entries`
|
||||
const syncTokenKey = `${environment}/${branch}/syncToken`
|
||||
const sitemapDataKey = `${environment}/${branch}/sitemapData`
|
||||
const lastUpdatedKey = `${environment}/${branch}/lastUpdated`
|
||||
const MAX_ENTRIES_PER_SITEMAP = 50000
|
||||
|
||||
// We need to wrap `getStore` because calling it in the root of the file causes
|
||||
// it to be executed during build time. This is not supported by Netlify.
|
||||
function store() {
|
||||
return getStore("sitemap")
|
||||
}
|
||||
|
||||
export async function saveEntries(entries: SyncItem[]) {
|
||||
await store().setJSON(entriesKey, entries)
|
||||
}
|
||||
|
||||
export async function saveSitemapData(sitemapData: SitemapData) {
|
||||
await store().setJSON(sitemapDataKey, sitemapData)
|
||||
}
|
||||
|
||||
export async function saveLastUpdatedDate(lastUpdated: string) {
|
||||
await store().set(lastUpdatedKey, lastUpdated)
|
||||
}
|
||||
|
||||
export async function saveSyncToken(syncToken: string) {
|
||||
await store().set(syncTokenKey, syncToken)
|
||||
}
|
||||
|
||||
export async function getSyncToken() {
|
||||
return await store().get(syncTokenKey)
|
||||
}
|
||||
|
||||
export async function getEntries() {
|
||||
const entries: SyncItem[] = await store().get(entriesKey, {
|
||||
type: "json",
|
||||
})
|
||||
|
||||
return entries || []
|
||||
}
|
||||
|
||||
export async function getLastUpdated() {
|
||||
return await store().get(lastUpdatedKey)
|
||||
}
|
||||
|
||||
export async function getSitemapData() {
|
||||
const sitemapData: SitemapData | null = await store().get(sitemapDataKey, {
|
||||
type: "json",
|
||||
})
|
||||
|
||||
return sitemapData || []
|
||||
}
|
||||
|
||||
export async function getSitemapDataById(id: number) {
|
||||
const sitemapData = await getSitemapData()
|
||||
const index = id - 1
|
||||
const start = index * MAX_ENTRIES_PER_SITEMAP
|
||||
const end = start + MAX_ENTRIES_PER_SITEMAP
|
||||
return sitemapData.slice(start, end)
|
||||
}
|
||||
|
||||
export async function getSitemapIds() {
|
||||
const sitemapData = await getSitemapData()
|
||||
const numberOfSitemaps = Math.ceil(
|
||||
sitemapData.length / MAX_ENTRIES_PER_SITEMAP
|
||||
)
|
||||
return Array.from({ length: numberOfSitemaps }, (_, index) => index + 1)
|
||||
}
|
||||
67
apps/scandic-web/utils/tabbable.ts
Normal file
67
apps/scandic-web/utils/tabbable.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/*!
|
||||
* Adapted from jQuery UI core
|
||||
*
|
||||
* http://jqueryui.com
|
||||
*
|
||||
* Copyright 2014 jQuery Foundation and other contributors
|
||||
* Released under the MIT license.
|
||||
* http://jquery.org/license
|
||||
*
|
||||
* http://api.jqueryui.com/category/ui-core/
|
||||
*/
|
||||
|
||||
const tabbableNode = /input|select|textarea|button|object/
|
||||
|
||||
function hidesContents(element: HTMLElement) {
|
||||
const zeroSize = element.offsetWidth <= 0 && element.offsetHeight <= 0
|
||||
|
||||
// If the node is empty, this is good enough
|
||||
if (zeroSize && !element.innerHTML) return true
|
||||
|
||||
// Otherwise we need to check some styles
|
||||
const style = window.getComputedStyle(element)
|
||||
return (
|
||||
style.getPropertyValue("display") === "none" ||
|
||||
(zeroSize && style.getPropertyValue("overflow") !== "visible")
|
||||
)
|
||||
}
|
||||
|
||||
function visible(element: any) {
|
||||
let parentElement = element
|
||||
while (parentElement) {
|
||||
if (parentElement === document.body) break
|
||||
if (hidesContents(parentElement)) return false
|
||||
parentElement = parentElement.parentNode
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function focusable(element: HTMLElement, isTabIndexNotNaN: boolean) {
|
||||
const nodeName = element.nodeName.toLowerCase()
|
||||
const res =
|
||||
//@ts-ignore
|
||||
(tabbableNode.test(nodeName) && !element.disabled) ||
|
||||
//@ts-ignore
|
||||
(nodeName === "a" ? element.href || isTabIndexNotNaN : isTabIndexNotNaN)
|
||||
return res && visible(element)
|
||||
}
|
||||
|
||||
export function tabbable(element: HTMLElement) {
|
||||
const tabIndexAttr = element.getAttribute("tabindex")
|
||||
const tabIndex = tabIndexAttr !== null ? Number(tabIndexAttr) : undefined
|
||||
const isTabIndexNaN = tabIndex === undefined || isNaN(tabIndex)
|
||||
|
||||
return (isTabIndexNaN || tabIndex >= 0) && focusable(element, !isTabIndexNaN)
|
||||
}
|
||||
|
||||
export default function findTabbableDescendants(
|
||||
element: HTMLElement | null | undefined
|
||||
): HTMLElement[] {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.from(element.querySelectorAll("*"))
|
||||
.filter((el): el is HTMLElement => el instanceof HTMLElement)
|
||||
.filter(tabbable)
|
||||
}
|
||||
3
apps/scandic-web/utils/timeout.ts
Normal file
3
apps/scandic-web/utils/timeout.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function timeout(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
199
apps/scandic-web/utils/tracking.ts
Normal file
199
apps/scandic-web/utils/tracking.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import type {
|
||||
LowestRoomPriceEvent,
|
||||
PaymentEvent,
|
||||
PaymentFailEvent,
|
||||
TrackingPosition,
|
||||
TrackingSDKData,
|
||||
} from "@/types/components/tracking"
|
||||
|
||||
export function trackClick(
|
||||
name: string,
|
||||
additionalParams?: Record<string, string>
|
||||
) {
|
||||
trackEvent({
|
||||
event: "linkClick",
|
||||
cta: {
|
||||
...additionalParams,
|
||||
name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function trackPageViewStart() {
|
||||
trackEvent({
|
||||
event: "pageViewStart",
|
||||
})
|
||||
}
|
||||
|
||||
export function trackLoginClick(position: TrackingPosition) {
|
||||
const event = {
|
||||
event: "loginStart",
|
||||
login: {
|
||||
position,
|
||||
action: "login start",
|
||||
ctaName: "login",
|
||||
},
|
||||
}
|
||||
trackEvent(event)
|
||||
}
|
||||
|
||||
export function trackSocialMediaClick(socialMediaName: string) {
|
||||
const event = {
|
||||
event: "social media",
|
||||
social: {
|
||||
socialIconClicked: socialMediaName,
|
||||
},
|
||||
}
|
||||
trackEvent(event)
|
||||
}
|
||||
|
||||
export function trackFooterClick(group: string, name: string) {
|
||||
const event = {
|
||||
event: "footer link",
|
||||
footer: {
|
||||
footerLinkClicked: `${group}:${name}`,
|
||||
},
|
||||
}
|
||||
trackEvent(event)
|
||||
}
|
||||
|
||||
export function trackHotelMapClick() {
|
||||
const event = {
|
||||
event: "map click",
|
||||
map: {
|
||||
action: "map click - open/explore mearby",
|
||||
},
|
||||
}
|
||||
trackEvent(event)
|
||||
}
|
||||
|
||||
export function trackAccordionClick(option: string) {
|
||||
const event = {
|
||||
event: "accordionClick",
|
||||
accordion: {
|
||||
action: "accordion open click",
|
||||
option,
|
||||
},
|
||||
}
|
||||
trackEvent(event)
|
||||
}
|
||||
|
||||
export function trackHotelTabClick(name: string) {
|
||||
trackEvent({
|
||||
event: "linkClick",
|
||||
link: {
|
||||
action: "hotel menu click",
|
||||
option: `hotel menu:${name}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function trackUpdatePaymentMethod(hotelId: string, method: string) {
|
||||
const paymentSelectionEvent = {
|
||||
event: "paymentSelection",
|
||||
hotelInfo: {
|
||||
hotelId: hotelId,
|
||||
},
|
||||
cta: {
|
||||
name: method,
|
||||
},
|
||||
}
|
||||
trackEvent(paymentSelectionEvent)
|
||||
}
|
||||
|
||||
export function trackOpenSidePeekEvent(
|
||||
sidePeek: SidePeekEnum | null,
|
||||
hotelId: string,
|
||||
pathName: string,
|
||||
roomTypeCode?: string | null
|
||||
) {
|
||||
const openSidePeekEvent = {
|
||||
event: "openSidePeek",
|
||||
hotelInfo: {
|
||||
hotelId: hotelId,
|
||||
},
|
||||
cta: {
|
||||
name: sidePeek,
|
||||
roomTypeCode,
|
||||
pathName,
|
||||
},
|
||||
}
|
||||
trackEvent(openSidePeekEvent)
|
||||
}
|
||||
|
||||
export function trackPaymentEvent(paymentEvent: PaymentEvent) {
|
||||
const paymentAttempt = {
|
||||
event: paymentEvent.event,
|
||||
hotelInfo: {
|
||||
hotelId: paymentEvent.hotelId,
|
||||
},
|
||||
paymentInfo: {
|
||||
isSavedCard: paymentEvent.isSavedCreditCard,
|
||||
status: paymentEvent.status,
|
||||
type: paymentEvent.method,
|
||||
smsEnable: paymentEvent.smsEnable,
|
||||
errorMessage: isPaymentFailEvent(paymentEvent)
|
||||
? paymentEvent.errorMessage
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
trackEvent(paymentAttempt)
|
||||
}
|
||||
|
||||
export function trackLowestRoomPrice(event: LowestRoomPriceEvent) {
|
||||
const lowestRoomPrice = {
|
||||
event: "lowestRoomPrice",
|
||||
hotelInfo: {
|
||||
hotelId: event.hotelId,
|
||||
arrivalDate: event.arrivalDate,
|
||||
departureDate: event.departureDate,
|
||||
},
|
||||
viewItemInfo: {
|
||||
lowestPrice: event.lowestPrice,
|
||||
currency: event.currency,
|
||||
},
|
||||
}
|
||||
trackEvent(lowestRoomPrice)
|
||||
}
|
||||
|
||||
function trackEvent(data: any) {
|
||||
if (typeof window !== "undefined" && window.adobeDataLayer) {
|
||||
data = { ...data, siteVersion: "new-web" }
|
||||
window.adobeDataLayer.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
export function trackPageView(data: any) {
|
||||
if (typeof window !== "undefined" && window.adobeDataLayer) {
|
||||
window.adobeDataLayer.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
export function createSDKPageObject(
|
||||
trackingData: TrackingSDKData
|
||||
): TrackingSDKData {
|
||||
let pageName = convertSlashToPipe(trackingData.pageName)
|
||||
let siteSections = convertSlashToPipe(trackingData.siteSections)
|
||||
|
||||
if (trackingData.pathName.indexOf("/webview/") > -1) {
|
||||
pageName = "webview|" + pageName
|
||||
siteSections = "webview|" + siteSections
|
||||
}
|
||||
|
||||
return {
|
||||
...trackingData,
|
||||
domain: typeof window !== "undefined" ? window.location.host : "",
|
||||
pageName: pageName,
|
||||
siteSections: siteSections,
|
||||
}
|
||||
}
|
||||
|
||||
function convertSlashToPipe(url: string) {
|
||||
const formattedUrl = url.startsWith("/") ? url.slice(1) : url
|
||||
return formattedUrl.replaceAll("/", "|")
|
||||
}
|
||||
|
||||
function isPaymentFailEvent(event: PaymentEvent): event is PaymentFailEvent {
|
||||
return "errorMessage" in event
|
||||
}
|
||||
161
apps/scandic-web/utils/url.ts
Normal file
161
apps/scandic-web/utils/url.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type {
|
||||
Child,
|
||||
Room,
|
||||
} from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
|
||||
export function removeMultipleSlashes(pathname: string) {
|
||||
return pathname.replaceAll(/\/\/+/g, "/")
|
||||
}
|
||||
|
||||
export function removeTrailingSlash(pathname: string) {
|
||||
if (pathname.endsWith("/")) {
|
||||
// Remove the trailing slash
|
||||
return pathname.slice(0, -1)
|
||||
}
|
||||
return pathname
|
||||
}
|
||||
|
||||
type PartialRoom = { rooms?: Partial<Room>[] }
|
||||
|
||||
const keyedSearchParams = new Map([
|
||||
["room", "rooms"],
|
||||
["ratecode", "rateCode"],
|
||||
["counterratecode", "counterRateCode"],
|
||||
["roomtype", "roomTypeCode"],
|
||||
["fromdate", "fromDate"],
|
||||
["todate", "toDate"],
|
||||
["hotel", "hotelId"],
|
||||
["child", "childrenInRoom"],
|
||||
])
|
||||
|
||||
export type SelectHotelParams<T> = Omit<T, "hotel"> & {
|
||||
hotelId: string
|
||||
} & PartialRoom
|
||||
|
||||
export function getKeyFromSearchParam(key: string): string {
|
||||
return keyedSearchParams.get(key) || key
|
||||
}
|
||||
|
||||
export function getSearchParamFromKey(key: string): string {
|
||||
for (const [mapKey, mapValue] of keyedSearchParams.entries()) {
|
||||
if (mapValue === key) {
|
||||
return mapKey
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
export function convertSearchParamsToObj<T extends PartialRoom>(
|
||||
searchParams: Record<string, string>
|
||||
) {
|
||||
const searchParamsObject = Object.entries(searchParams).reduce<
|
||||
SelectHotelParams<T>
|
||||
>((acc, [key, value]) => {
|
||||
// The params are sometimes indexed with a number (for ex: `room[0].adults`),
|
||||
// so we need to split them by . or []
|
||||
const keys = key.replace(/\]/g, "").split(/\[|\./)
|
||||
const firstKey = getKeyFromSearchParam(keys[0])
|
||||
|
||||
// Room is a special case since it is an array, so we need to handle it separately
|
||||
if (firstKey === "rooms") {
|
||||
// Rooms are always indexed with a number, so we need to extract the index
|
||||
const index = Number(keys[1])
|
||||
const roomObject =
|
||||
acc.rooms && Array.isArray(acc.rooms) ? acc.rooms : (acc.rooms = [])
|
||||
|
||||
const roomObjectKey = getKeyFromSearchParam(keys[2]) as keyof Room
|
||||
|
||||
if (!roomObject[index]) {
|
||||
roomObject[index] = {}
|
||||
}
|
||||
|
||||
// Adults should be converted to a number
|
||||
if (roomObjectKey === "adults") {
|
||||
roomObject[index].adults = Number(value)
|
||||
|
||||
// Child is an array, so we need to handle it separately
|
||||
} else if (roomObjectKey === "childrenInRoom") {
|
||||
const childIndex = Number(keys[3])
|
||||
const childKey = keys[4] as keyof Child
|
||||
|
||||
if (
|
||||
!("childrenInRoom" in roomObject[index]) ||
|
||||
!Array.isArray(roomObject[index].childrenInRoom)
|
||||
) {
|
||||
roomObject[index].childrenInRoom = []
|
||||
}
|
||||
|
||||
roomObject[index].childrenInRoom![childIndex] = {
|
||||
...roomObject[index].childrenInRoom![childIndex],
|
||||
[childKey]: Number(value),
|
||||
}
|
||||
} else if (roomObjectKey === "packages") {
|
||||
roomObject[index].packages = value.split(",") as RoomPackageCodeEnum[]
|
||||
} else {
|
||||
roomObject[index][roomObjectKey] = value
|
||||
}
|
||||
} else {
|
||||
return { ...acc, [firstKey]: value }
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {} as SelectHotelParams<T>)
|
||||
|
||||
return searchParamsObject
|
||||
}
|
||||
|
||||
export function convertObjToSearchParams<T>(
|
||||
bookingData: T & PartialRoom,
|
||||
intitalSearchParams = {} as URLSearchParams
|
||||
) {
|
||||
const bookingSearchParams = new URLSearchParams(intitalSearchParams)
|
||||
Object.entries(bookingData).forEach(([key, value]) => {
|
||||
if (key === "rooms") {
|
||||
value.forEach((item, index) => {
|
||||
if (item?.adults) {
|
||||
bookingSearchParams.set(
|
||||
`room[${index}].adults`,
|
||||
item.adults.toString()
|
||||
)
|
||||
}
|
||||
if (item?.childrenInRoom) {
|
||||
item.childrenInRoom.forEach((child, childIndex) => {
|
||||
bookingSearchParams.set(
|
||||
`room[${index}].child[${childIndex}].age`,
|
||||
child.age.toString()
|
||||
)
|
||||
bookingSearchParams.set(
|
||||
`room[${index}].child[${childIndex}].bed`,
|
||||
child.bed.toString()
|
||||
)
|
||||
})
|
||||
}
|
||||
if (item?.roomTypeCode) {
|
||||
bookingSearchParams.set(`room[${index}].roomtype`, item.roomTypeCode)
|
||||
}
|
||||
if (item?.rateCode) {
|
||||
bookingSearchParams.set(`room[${index}].ratecode`, item.rateCode)
|
||||
}
|
||||
|
||||
if (item?.counterRateCode) {
|
||||
bookingSearchParams.set(
|
||||
`room[${index}].counterratecode`,
|
||||
item.counterRateCode
|
||||
)
|
||||
}
|
||||
|
||||
if (item.packages && item.packages.length > 0) {
|
||||
bookingSearchParams.set(
|
||||
`room[${index}].packages`,
|
||||
item.packages.join(",")
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
bookingSearchParams.set(getSearchParamFromKey(key), value.toString())
|
||||
}
|
||||
})
|
||||
|
||||
return bookingSearchParams
|
||||
}
|
||||
69
apps/scandic-web/utils/user.ts
Normal file
69
apps/scandic-web/utils/user.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
MembershipLevel,
|
||||
MembershipLevelEnum,
|
||||
} from "@/constants/membershipLevels"
|
||||
import { getMembershipCardsSchema } from "@/server/routers/user/output"
|
||||
|
||||
import type { Membership, Memberships, User } from "@/types/user"
|
||||
|
||||
enum scandicMemberships {
|
||||
guestpr = "guestpr",
|
||||
scandicfriends = "scandicfriend's",
|
||||
}
|
||||
|
||||
export function getMembership(memberships: Memberships) {
|
||||
return memberships?.find(
|
||||
(membership) =>
|
||||
membership.membershipType.toLowerCase() === scandicMemberships.guestpr
|
||||
) as FriendsMembership | undefined
|
||||
}
|
||||
|
||||
export type FriendsMembership = Omit<
|
||||
NonNullable<Membership>,
|
||||
"membershipLevel" | "nextLevel"
|
||||
> & {
|
||||
membershipLevel: MembershipLevel
|
||||
nextLevel: MembershipLevel
|
||||
}
|
||||
|
||||
export function getMembershipCards(
|
||||
memberships: z.infer<typeof getMembershipCardsSchema>
|
||||
) {
|
||||
return memberships.filter(function (membership) {
|
||||
return (
|
||||
membership.membershipType.toLowerCase() !== scandicMemberships.guestpr &&
|
||||
membership.membershipType.toLowerCase() !==
|
||||
scandicMemberships.scandicfriends
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function isHighestMembership(
|
||||
membershipLevel: MembershipLevel | undefined
|
||||
) {
|
||||
return membershipLevel == MembershipLevelEnum.L7
|
||||
}
|
||||
|
||||
export function getInitials(
|
||||
firstName: User["firstName"],
|
||||
lastName: User["lastName"]
|
||||
) {
|
||||
if (!firstName || !lastName) return null
|
||||
const firstInitial = firstName.charAt(0).toUpperCase()
|
||||
const lastInitial = lastName.charAt(0).toUpperCase()
|
||||
return `${firstInitial}${lastInitial}`
|
||||
}
|
||||
|
||||
export function getSteppedUpLevel(
|
||||
currentValue: MembershipLevel,
|
||||
stepsUp: number
|
||||
): MembershipLevel {
|
||||
const values = Object.values(MembershipLevelEnum)
|
||||
const currentIndex = values.indexOf(currentValue as MembershipLevelEnum)
|
||||
if (currentIndex === -1 || currentIndex === values.length - 1) {
|
||||
return currentValue
|
||||
}
|
||||
return values[currentIndex + stepsUp]
|
||||
}
|
||||
32
apps/scandic-web/utils/webviews.ts
Normal file
32
apps/scandic-web/utils/webviews.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import "server-only"
|
||||
|
||||
import { headers } from "next/headers"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { webviews } from "@/constants/routes/webviews"
|
||||
|
||||
export function webviewSearchParams() {
|
||||
const searchParams = new URLSearchParams()
|
||||
const loginType = headers().get("loginType")
|
||||
if (loginType) {
|
||||
searchParams.set("loginType", loginType)
|
||||
}
|
||||
|
||||
const adobeMc = headers().get("adobe_mc")
|
||||
if (adobeMc) {
|
||||
searchParams.set("adobe_mc", adobeMc)
|
||||
}
|
||||
return searchParams
|
||||
}
|
||||
|
||||
export function modWebviewLink(url: string, lang: Lang) {
|
||||
const searchParams = webviewSearchParams()
|
||||
const urlWithoutLang = url.replace(`/${lang}`, "")
|
||||
|
||||
const webviewUrl = `/${lang}/webview${urlWithoutLang}`
|
||||
if (webviews.includes(webviewUrl) || url.startsWith("/webview")) {
|
||||
return `${webviewUrl}?${searchParams.toString()}`
|
||||
} else {
|
||||
return `${url}?${searchParams.toString()}`
|
||||
}
|
||||
}
|
||||
17
apps/scandic-web/utils/zod/arrayValidator.ts
Normal file
17
apps/scandic-web/utils/zod/arrayValidator.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { nullableStringValidator } from "./stringValidator"
|
||||
|
||||
import type { ZodObject, ZodRawShape } from "zod"
|
||||
|
||||
export function nullableArrayObjectValidator<T extends ZodRawShape>(
|
||||
schema: ZodObject<T>
|
||||
) {
|
||||
return schema
|
||||
.array()
|
||||
.nullish()
|
||||
.transform((arr) => (arr ? arr.filter(Boolean) : []))
|
||||
}
|
||||
|
||||
export const nullableArrayStringValidator = nullableStringValidator
|
||||
.array()
|
||||
.nullish()
|
||||
.transform((arr) => (arr ? arr.filter(Boolean) : []))
|
||||
12
apps/scandic-web/utils/zod/numberValidator.ts
Normal file
12
apps/scandic-web/utils/zod/numberValidator.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const nullableNumberValidator = z
|
||||
.number()
|
||||
.nullish()
|
||||
.transform((num) => (typeof num === "number" ? num : 0))
|
||||
|
||||
export const nullableIntValidator = z
|
||||
.number()
|
||||
.int()
|
||||
.nullish()
|
||||
.transform((num) => (typeof num === "number" ? num : 0))
|
||||
46
apps/scandic-web/utils/zod/passwordValidator.ts
Normal file
46
apps/scandic-web/utils/zod/passwordValidator.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const passwordValidators = {
|
||||
length: {
|
||||
matcher: (password: string) =>
|
||||
password.length >= 10 && password.length <= 40,
|
||||
message: "10 to 40 characters",
|
||||
},
|
||||
hasUppercase: {
|
||||
matcher: (password: string) => /[A-Z]/.test(password),
|
||||
message: "1 uppercase letter",
|
||||
},
|
||||
hasLowercase: {
|
||||
matcher: (password: string) => /[a-z]/.test(password),
|
||||
message: "1 lowercase letter",
|
||||
},
|
||||
hasNumber: {
|
||||
matcher: (password: string) => /[0-9]/.test(password),
|
||||
message: "1 number",
|
||||
},
|
||||
hasSpecialChar: {
|
||||
matcher: (password: string) =>
|
||||
/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+/.test(password),
|
||||
message: "1 special character",
|
||||
},
|
||||
}
|
||||
|
||||
export const passwordValidator = (msg = "Required field") =>
|
||||
z
|
||||
.string()
|
||||
.min(1, msg)
|
||||
.refine(passwordValidators.length.matcher, {
|
||||
message: passwordValidators.length.message,
|
||||
})
|
||||
.refine(passwordValidators.hasUppercase.matcher, {
|
||||
message: passwordValidators.hasUppercase.message,
|
||||
})
|
||||
.refine(passwordValidators.hasLowercase.matcher, {
|
||||
message: passwordValidators.hasLowercase.message,
|
||||
})
|
||||
.refine(passwordValidators.hasNumber.matcher, {
|
||||
message: passwordValidators.hasNumber.message,
|
||||
})
|
||||
.refine(passwordValidators.hasSpecialChar.matcher, {
|
||||
message: passwordValidators.hasSpecialChar.message,
|
||||
})
|
||||
87
apps/scandic-web/utils/zod/phoneValidator.ts
Normal file
87
apps/scandic-web/utils/zod/phoneValidator.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
isPossiblePhoneNumber,
|
||||
ParseError,
|
||||
parsePhoneNumber,
|
||||
validatePhoneNumberLength,
|
||||
} from "libphonenumber-js"
|
||||
import { z } from "zod"
|
||||
|
||||
const enum ParseErrorMessage {
|
||||
INVALID_COUNTRY = "INVALID_COUNTRY",
|
||||
INVALID_LENGTH = "INVALID_LENGTH",
|
||||
NOT_A_NUMBER = "NOT_A_NUMBER",
|
||||
TOO_LONG = "TOO_LONG",
|
||||
TOO_SHORT = "TOO_SHORT",
|
||||
}
|
||||
|
||||
export function phoneValidator(
|
||||
msg = "Required field",
|
||||
invalidMsg = "Invalid type"
|
||||
) {
|
||||
return z
|
||||
.string({ invalid_type_error: invalidMsg, required_error: msg })
|
||||
.min(1, { message: msg })
|
||||
.superRefine((value, ctx) => {
|
||||
if (value) {
|
||||
try {
|
||||
const phoneNumber = parsePhoneNumber(value)
|
||||
if (phoneNumber) {
|
||||
if (isPossiblePhoneNumber(value, phoneNumber.country)) {
|
||||
return validatePhoneNumberLength(value, phoneNumber.country)
|
||||
} else {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Please enter a valid phone number",
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ParseError) {
|
||||
/**
|
||||
* Only setup for when we need proper validation,
|
||||
* should probably move to .superRefine to be able
|
||||
* to return different messages depending on error.
|
||||
*/
|
||||
switch (error.message) {
|
||||
case ParseErrorMessage.INVALID_COUNTRY:
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"The country selected and country code doesn't match",
|
||||
})
|
||||
break
|
||||
case ParseErrorMessage.INVALID_LENGTH:
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Please enter a valid phone number",
|
||||
})
|
||||
break
|
||||
case ParseErrorMessage.NOT_A_NUMBER:
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Please enter a number",
|
||||
})
|
||||
break
|
||||
case ParseErrorMessage.TOO_LONG:
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "The number you have entered is too long",
|
||||
})
|
||||
break
|
||||
case ParseErrorMessage.TOO_SHORT:
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "The number you have entered is too short",
|
||||
})
|
||||
break
|
||||
}
|
||||
} else {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "The number you have entered is not valid",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
18
apps/scandic-web/utils/zod/stringValidator.ts
Normal file
18
apps/scandic-web/utils/zod/stringValidator.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const nullableStringValidator = z
|
||||
.string()
|
||||
.nullish()
|
||||
.transform((str) => (str ? str : ""))
|
||||
|
||||
export const nullableStringEmailValidator = z
|
||||
.string()
|
||||
.email()
|
||||
.nullish()
|
||||
.transform((str) => (str ? str : ""))
|
||||
|
||||
export const nullableStringUrlValidator = z
|
||||
.string()
|
||||
.url()
|
||||
.nullish()
|
||||
.transform((str) => (str ? str : ""))
|
||||
Reference in New Issue
Block a user