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:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

View 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
}

View 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)
}
}

View 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
}

View 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
}

View 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
}

View 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)
}
}

View 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,
}
}

View 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[],
}
}

View 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
}

View 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}`
}

View 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)
}

View 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
}

View 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
}
}

View 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,
}
}

View 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)
)

View 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 ?? "",
}
}

View 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}`
}

View 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)
}

View 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("*****************")
})
})

View 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)
}

View 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
}

View 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}`
}

View 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
)
}

View 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
}

View 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]
}
}

View 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
}

View 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)
}

View 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)
}

View File

@@ -0,0 +1,3 @@
export function timeout(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View 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
}

View 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
}

View 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]
}

View 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()}`
}
}

View 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) : []))

View 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))

View 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,
})

View 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",
})
}
}
}
})
}

View 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 : ""))