Merge branch 'master' of bitbucket.org:scandic-swap/web into fix/loading-rooms-separately

This commit is contained in:
Joakim Jäderberg
2024-11-20 14:33:05 +01:00
108 changed files with 1115 additions and 683 deletions

View File

@@ -1,89 +0,0 @@
"use server"
import { parsePhoneNumber } from "libphonenumber-js"
import { redirect } from "next/navigation"
import { z } from "zod"
import { signupVerify } from "@/constants/routes/signup"
import * as api from "@/lib/api"
import { serviceServerActionProcedure } from "@/server/trpc"
import { signUpSchema } from "@/components/Forms/Signup/schema"
import { passwordValidator } from "@/utils/passwordValidator"
import { phoneValidator } from "@/utils/phoneValidator"
const registerUserPayload = z.object({
language: z.string(),
firstName: z.string(),
lastName: z.string(),
email: z.string(),
phoneNumber: phoneValidator("Phone is required"),
dateOfBirth: z.string(),
address: z.object({
city: z.string().default(""),
country: z.string().default(""),
countryCode: z.string().default(""),
zipCode: z.string().default(""),
streetAddress: z.string().default(""),
}),
password: passwordValidator("Password is required"),
})
export const registerUser = serviceServerActionProcedure
.input(signUpSchema)
.mutation(async function ({ ctx, input }) {
const payload = {
...input,
language: ctx.lang,
phoneNumber: input.phoneNumber.replace(/\s+/g, ""),
}
const parsedPayload = registerUserPayload.safeParse(payload)
if (!parsedPayload.success) {
console.error(
"registerUser payload validation error",
JSON.stringify({
query: input,
error: parsedPayload.error,
})
)
return { success: false, error: "Validation error" }
}
let apiResponse
try {
apiResponse = await api.post(api.endpoints.v1.Profile.profile, {
body: parsedPayload.data,
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
})
} catch (error) {
console.error("Unexpected error", error)
return { success: false, error: "Unexpected error" }
}
if (!apiResponse.ok) {
const text = await apiResponse.text()
console.error(
"registerUser api error",
JSON.stringify({
query: input,
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
error: text,
},
})
)
return { success: false, error: "API error" }
}
const json = await apiResponse.json()
console.log("registerUser: json", json)
// Note: The redirect needs to be called after the try/catch block.
// See: https://nextjs.org/docs/app/api-reference/functions/redirect
redirect(signupVerify[ctx.lang])
})

View File

@@ -0,0 +1,11 @@
import { env } from "@/env/server"
import CurrentLoadingSpinner from "@/components/Current/LoadingSpinner"
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
if (env.HIDE_FOR_NEXT_RELEASE) {
return <CurrentLoadingSpinner />
}
return <LoadingSpinner />
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function LoadingModal() {
return <LoadingSpinner />
}

View File

@@ -12,7 +12,7 @@ import {
import { MapModal } from "@/components/MapModal" import { MapModal } from "@/components/MapModal"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import { fetchAvailableHotels } from "../../utils" import { fetchAvailableHotels, getFiltersFromHotels } from "../../utils"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
@@ -57,6 +57,7 @@ export default async function SelectHotelMapPage({
}) })
const hotelPins = getHotelPins(hotels) const hotelPins = getHotelPins(hotels)
const filterList = getFiltersFromHotels(hotels)
return ( return (
<MapModal> <MapModal>
@@ -65,6 +66,7 @@ export default async function SelectHotelMapPage({
hotelPins={hotelPins} hotelPins={hotelPins}
mapId={googleMapId} mapId={googleMapId}
hotels={hotels} hotels={hotels}
filterList={filterList}
/> />
</MapModal> </MapModal>
) )

View File

@@ -8,6 +8,7 @@ import {
getFiltersFromHotels, getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils" } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import HotelCardListing from "@/components/HotelReservation/HotelCardListing" import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
import HotelCount from "@/components/HotelReservation/SelectHotel/HotelCount"
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter" import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
import HotelSorter from "@/components/HotelReservation/SelectHotel/HotelSorter" import HotelSorter from "@/components/HotelReservation/SelectHotel/HotelSorter"
import MobileMapButtonContainer from "@/components/HotelReservation/SelectHotel/MobileMapButtonContainer" import MobileMapButtonContainer from "@/components/HotelReservation/SelectHotel/MobileMapButtonContainer"
@@ -20,7 +21,6 @@ import StaticMap from "@/components/Maps/StaticMap"
import Alert from "@/components/TempDesignSystem/Alert" import Alert from "@/components/TempDesignSystem/Alert"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
@@ -66,13 +66,15 @@ export default async function SelectHotelPage({
const filterList = getFiltersFromHotels(hotels) const filterList = getFiltersFromHotels(hotels)
const isAllUnavailable = hotels.every((hotel) => hotel.price === undefined)
return ( return (
<> <>
<header className={styles.header}> <header className={styles.header}>
<div className={styles.title}> <div className={styles.title}>
<div className={styles.cityInformation}> <div className={styles.cityInformation}>
<Subtitle>{city.name}</Subtitle> <Subtitle>{city.name}</Subtitle>
<Preamble>{hotels.length} hotels</Preamble> <HotelCount />
</div> </div>
<div className={styles.sorter}> <div className={styles.sorter}>
<HotelSorter discreet /> <HotelSorter discreet />
@@ -123,7 +125,7 @@ export default async function SelectHotelPage({
<HotelFilter filters={filterList} className={styles.filter} /> <HotelFilter filters={filterList} className={styles.filter} />
</div> </div>
<div className={styles.hotelList}> <div className={styles.hotelList}>
{!hotels.length && ( {isAllUnavailable && (
<Alert <Alert
type={AlertTypeEnum.Info} type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })} heading={intl.formatMessage({ id: "No availability" })}

View File

@@ -19,6 +19,15 @@ const hotelSurroundingsFilterNames = [
"Omgivningar", "Omgivningar",
] ]
const hotelFacilitiesFilterNames = [
"Hotel facilities",
"Hotellfaciliteter",
"Hotelfaciliteter",
"Hotel faciliteter",
"Hotel-Infos",
"Hotellin palvelut",
]
export async function fetchAvailableHotels( export async function fetchAvailableHotels(
input: AvailabilityInput input: AvailabilityInput
): Promise<HotelData[]> { ): Promise<HotelData[]> {
@@ -52,6 +61,7 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
const filterList: Filter[] = uniqueFilterIds const filterList: Filter[] = uniqueFilterIds
.map((filterId) => filters.find((filter) => filter.id === filterId)) .map((filterId) => filters.find((filter) => filter.id === filterId))
.filter((filter): filter is Filter => filter !== undefined) .filter((filter): filter is Filter => filter !== undefined)
.sort((a, b) => b.sortOrder - a.sortOrder)
return filterList.reduce<CategorizedFilters>( return filterList.reduce<CategorizedFilters>(
(acc, filter) => { (acc, filter) => {
@@ -61,10 +71,13 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
surroundingsFilters: [...acc.surroundingsFilters, filter], surroundingsFilters: [...acc.surroundingsFilters, filter],
} }
return { if (filter.filter && hotelFacilitiesFilterNames.includes(filter.filter))
facilityFilters: [...acc.facilityFilters, filter], return {
surroundingsFilters: acc.surroundingsFilters, facilityFilters: [...acc.facilityFilters, filter],
} surroundingsFilters: acc.surroundingsFilters,
}
return acc
}, },
{ facilityFilters: [], surroundingsFilters: [] } { facilityFilters: [], surroundingsFilters: [] }
) )

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function LoadingSummaryHeader() {
return <LoadingSpinner />
}

View File

@@ -83,10 +83,10 @@ export default async function SummaryPage({
price: availability.publicRate.localPrice.pricePerStay, price: availability.publicRate.localPrice.pricePerStay,
currency: availability.publicRate.localPrice.currency, currency: availability.publicRate.localPrice.currency,
}, },
euro: availability.publicRate.requestedPrice euro: availability.publicRate?.requestedPrice
? { ? {
price: availability.publicRate.requestedPrice.pricePerStay, price: availability.publicRate?.requestedPrice.pricePerStay,
currency: availability.publicRate.requestedPrice.currency, currency: availability.publicRate?.requestedPrice.currency,
} }
: undefined, : undefined,
} }

View File

@@ -1,6 +1,6 @@
import "./enterDetailsLayout.css" import "./enterDetailsLayout.css"
import { notFound, redirect, RedirectType } from "next/navigation" import { notFound } from "next/navigation"
import { import {
getBreakfastPackages, getBreakfastPackages,
@@ -38,6 +38,8 @@ export default async function StepPage({
}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) { }: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) {
const intl = await getIntl() const intl = await getIntl()
const selectRoomParams = new URLSearchParams(searchParams) const selectRoomParams = new URLSearchParams(searchParams)
selectRoomParams.delete("step")
const searchParamsString = selectRoomParams.toString()
const { const {
hotel: hotelId, hotel: hotelId,
rooms, rooms,
@@ -111,11 +113,19 @@ export default async function StepPage({
publicPrice: roomAvailability.publicRate!.localPrice.pricePerStay, publicPrice: roomAvailability.publicRate!.localPrice.pricePerStay,
} }
const memberPrice = roomAvailability.memberRate
? {
price: roomAvailability.memberRate.localPrice.pricePerStay,
currency: roomAvailability.memberRate.localPrice.currency,
}
: undefined
return ( return (
<StepsProvider <StepsProvider
bedTypes={roomAvailability.bedTypes} bedTypes={roomAvailability.bedTypes}
breakfastPackages={breakfastPackages} breakfastPackages={breakfastPackages}
isMember={!!user} isMember={!!user}
searchParams={searchParamsString}
step={searchParams.step} step={searchParams.step}
> >
<section> <section>
@@ -152,7 +162,7 @@ export default async function StepPage({
step={StepEnum.details} step={StepEnum.details}
label={intl.formatMessage({ id: "Enter your details" })} label={intl.formatMessage({ id: "Enter your details" })}
> >
<Details user={user} /> <Details user={user} memberPrice={memberPrice} />
</SectionAccordion> </SectionAccordion>
<SectionAccordion <SectionAccordion

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "./page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "./page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "../page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "./page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "../page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "./page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -13,7 +13,7 @@ export default function SitewideAlertPage({ params }: PageArgs<LangParams>) {
} }
setLang(params.lang) setLang(params.lang)
preload() void preload()
return ( return (
<Suspense> <Suspense>

View File

@@ -177,7 +177,7 @@ export default function BookingWidgetClient({
> >
<CloseLargeIcon /> <CloseLargeIcon />
</button> </button>
<Form locations={locations} type={type} setIsOpen={setIsOpen} /> <Form locations={locations} type={type} onClose={closeMobileSearch} />
</div> </div>
</section> </section>
<div className={styles.backdrop} onClick={closeMobileSearch} /> <div className={styles.backdrop} onClick={closeMobileSearch} />

View File

@@ -1,4 +1,4 @@
import { getLocations } from "@/lib/trpc/memoizedRequests" import { getLocations, getSiteConfig } from "@/lib/trpc/memoizedRequests"
import BookingWidgetClient from "./Client" import BookingWidgetClient from "./Client"
@@ -13,8 +13,9 @@ export default async function BookingWidget({
searchParams, searchParams,
}: BookingWidgetProps) { }: BookingWidgetProps) {
const locations = await getLocations() const locations = await getLocations()
const siteConfig = await getSiteConfig()
if (!locations || "error" in locations) { if (!locations || "error" in locations || siteConfig?.bookingWidgetDisabled) {
return null return null
} }

View File

@@ -206,11 +206,12 @@ export default function Search({ locations }: SearchProps) {
} }
export function SearchSkeleton() { export function SearchSkeleton() {
const intl = useIntl()
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.label}> <div className={styles.label}>
<Caption type="bold" color="red" asChild> <Caption type="bold" color="red" asChild>
<span>Where to</span> <span>{intl.formatMessage({ id: "Where to" })}</span>
</Caption> </Caption>
</div> </div>
<div className={styles.input}> <div className={styles.input}>

View File

@@ -20,7 +20,7 @@ const formId = "booking-widget"
export default function Form({ export default function Form({
locations, locations,
type, type,
setIsOpen, onClose,
}: BookingWidgetFormProps) { }: BookingWidgetFormProps) {
const router = useRouter() const router = useRouter()
const lang = useLang() const lang = useLang()
@@ -56,7 +56,7 @@ export default function Form({
) )
}) })
}) })
setIsOpen(false) onClose()
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`) router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
} }

View File

@@ -3,12 +3,14 @@
align-self: flex-start; align-self: flex-start;
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
container-name: addressContainer;
container-type: inline-size;
} }
.container { .container {
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
grid-template-columns: max(164px) 1fr; grid-template-columns: minmax(100px, 164px) 1fr;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -16,3 +18,9 @@
display: none; display: none;
} }
} }
@container addressContainer (max-width: 350px) {
.container {
grid-template-columns: 1fr;
}
}

View File

@@ -1,12 +1,13 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { FormProvider, useForm } from "react-hook-form" import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { privacyPolicy } from "@/constants/currentWebHrefs" import { privacyPolicy } from "@/constants/currentWebHrefs"
import { trpc } from "@/lib/trpc/client"
import { registerUser } from "@/actions/registerUser"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox" import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import CountrySelect from "@/components/TempDesignSystem/Form/Country" import CountrySelect from "@/components/TempDesignSystem/Form/Country"
@@ -30,11 +31,28 @@ import type { SignUpFormProps } from "@/types/components/form/signupForm"
export default function SignupForm({ link, subtitle, title }: SignUpFormProps) { export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
const intl = useIntl() const intl = useIntl()
const router = useRouter()
const lang = useLang() const lang = useLang()
const country = intl.formatMessage({ id: "Country" }) const country = intl.formatMessage({ id: "Country" })
const email = intl.formatMessage({ id: "Email address" }) const email = intl.formatMessage({ id: "Email address" })
const phoneNumber = intl.formatMessage({ id: "Phone number" }) const phoneNumber = intl.formatMessage({ id: "Phone number" })
const zipCode = intl.formatMessage({ id: "Zip code" }) const zipCode = intl.formatMessage({ id: "Zip code" })
const signupButtonText = intl.formatMessage({
id: "Sign up to Scandic Friends",
})
const signingUpPendingText = intl.formatMessage({ id: "Signing up..." })
const signup = trpc.user.signup.useMutation({
onSuccess: (data) => {
if (data.success && data.redirectUrl) {
router.push(data.redirectUrl)
}
},
onError: (error) => {
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
console.error("Component Signup error:", error)
},
})
const methods = useForm<SignUpSchema>({ const methods = useForm<SignUpSchema>({
defaultValues: { defaultValues: {
@@ -48,7 +66,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
zipCode: "", zipCode: "",
}, },
password: "", password: "",
termsAccepted: false,
}, },
mode: "all", mode: "all",
criteriaMode: "all", criteriaMode: "all",
@@ -57,19 +74,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
}) })
async function onSubmit(data: SignUpSchema) { async function onSubmit(data: SignUpSchema) {
try { signup.mutate({ ...data, language: lang })
const result = await registerUser(data)
if (result && !result.success) {
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
}
} catch (error) {
// The server-side redirect will throw an error, which we can ignore
// as it's handled by Next.js.
if (error instanceof Error && error.message.includes("NEXT_REDIRECT")) {
return
}
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
}
} }
return ( return (
@@ -80,11 +85,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
className={styles.form} className={styles.form}
id="register" id="register"
onSubmit={methods.handleSubmit(onSubmit)} onSubmit={methods.handleSubmit(onSubmit)}
/**
* Ignoring since ts doesn't recognize that tRPC
* parses FormData before reaching the route
* @ts-ignore */
action={registerUser}
> >
<section className={styles.userInfo}> <section className={styles.userInfo}>
<div className={styles.container}> <div className={styles.container}>
@@ -187,7 +187,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
onClick={() => methods.trigger()} onClick={() => methods.trigger()}
data-testid="trigger-validation" data-testid="trigger-validation"
> >
{intl.formatMessage({ id: "Sign up to Scandic Friends" })} {signupButtonText}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -195,10 +195,12 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
type="submit" type="submit"
theme="base" theme="base"
intent="primary" intent="primary"
disabled={methods.formState.isSubmitting} disabled={methods.formState.isSubmitting || signup.isPending}
data-testid="submit" data-testid="submit"
> >
{intl.formatMessage({ id: "Sign up to Scandic Friends" })} {methods.formState.isSubmitting || signup.isPending
? signingUpPendingText
: signupButtonText}
</Button> </Button>
)} )}
</form> </form>

View File

@@ -33,11 +33,10 @@ export default function ChildInfoSelector({
const ageLabel = intl.formatMessage({ id: "Age" }) const ageLabel = intl.formatMessage({ id: "Age" })
const bedLabel = intl.formatMessage({ id: "Bed" }) const bedLabel = intl.formatMessage({ id: "Bed" })
const errorMessage = intl.formatMessage({ id: "Child age is required" }) const errorMessage = intl.formatMessage({ id: "Child age is required" })
const { setValue, formState, register, trigger } = useFormContext() const { setValue, formState, register } = useFormContext()
function updateSelectedBed(bed: number) { function updateSelectedBed(bed: number) {
setValue(`rooms.${roomIndex}.child.${index}.bed`, bed) setValue(`rooms.${roomIndex}.child.${index}.bed`, bed)
trigger()
} }
function updateSelectedAge(age: number) { function updateSelectedAge(age: number) {
@@ -95,7 +94,7 @@ export default function ChildInfoSelector({
updateSelectedAge(key as number) updateSelectedAge(key as number)
}} }}
placeholder={ageLabel} placeholder={ageLabel}
maxHeight={150} maxHeight={180}
{...register(ageFieldName, { {...register(ageFieldName, {
required: true, required: true,
})} })}

View File

@@ -99,7 +99,7 @@ export default function GuestsRoomsPickerDialog({
<Tooltip <Tooltip
heading={disabledBookingOptionsHeader} heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText} text={disabledBookingOptionsText}
position="top" position="bottom"
arrow="left" arrow="left"
> >
{rooms.length < 4 ? ( {rooms.length < 4 ? (
@@ -124,7 +124,7 @@ export default function GuestsRoomsPickerDialog({
<Tooltip <Tooltip
heading={disabledBookingOptionsHeader} heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText} text={disabledBookingOptionsText}
position="top" position="bottom"
arrow="left" arrow="left"
> >
{rooms.length < 4 ? ( {rooms.length < 4 ? (

View File

@@ -0,0 +1,108 @@
"use client"
import { useIntl } from "react-intl"
import { privacyPolicy } from "@/constants/currentWebHrefs"
import { CheckIcon } from "@/components/Icons"
import LoginButton from "@/components/LoginButton"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import useLang from "@/hooks/useLang"
import styles from "./joinScandicFriendsCard.module.css"
import { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
export default function JoinScandicFriendsCard({
name,
memberPrice,
}: JoinScandicFriendsCardProps) {
const lang = useLang()
const intl = useIntl()
const list = [
{ title: intl.formatMessage({ id: "Earn bonus nights & points" }) },
{ title: intl.formatMessage({ id: "Get member benefits & offers" }) },
{ title: intl.formatMessage({ id: "Join at no cost" }) },
]
const saveOnJoiningLabel = intl.formatMessage(
{
id: "Only pay {amount} {currency}",
},
{
amount: intl.formatNumber(memberPrice?.price ?? 0),
currency: memberPrice?.currency ?? "SEK",
}
)
return (
<div className={styles.cardContainer}>
<Checkbox name={name} className={styles.checkBox}>
<div>
{memberPrice ? (
<Caption type="label" textTransform="uppercase" color="red">
{saveOnJoiningLabel}
</Caption>
) : null}
<Caption
type="label"
textTransform="uppercase"
color="uiTextHighContrast"
>
{intl.formatMessage({ id: "Join Scandic Friends" })}
</Caption>
</div>
</Checkbox>
<Footnote color="uiTextHighContrast" className={styles.login}>
{intl.formatMessage({ id: "Already a friend?" })}{" "}
<LoginButton
color="burgundy"
position="enter details"
trackingId="join-scandic-friends-enter-details"
variant="breadcrumb"
target="_blank"
>
{intl.formatMessage({ id: "Log in" })}
</LoginButton>
</Footnote>
<div className={styles.list}>
{list.map((item) => (
<Caption
key={item.title}
color="uiTextPlaceholder"
className={styles.listItem}
>
<CheckIcon color="uiTextPlaceholder" height="20" /> {item.title}
</Caption>
))}
</div>
<Footnote color="uiTextPlaceholder" className={styles.terms}>
{intl.formatMessage<React.ReactNode>(
{
id: "signup.terms",
},
{
termsLink: (str) => (
<Link
variant="default"
textDecoration="underline"
size="tiny"
target="_blank"
color="uiTextPlaceholder"
href={privacyPolicy[lang]}
>
{str}
</Link>
),
}
)}
</Footnote>
</div>
)
}

View File

@@ -0,0 +1,55 @@
.cardContainer {
align-self: flex-start;
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Large);
display: grid;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
grid-template-areas:
"checkbox"
"list"
"login"
"terms";
width: min(100%, 600px);
}
.login {
grid-area: login;
}
.checkBox {
align-self: center;
grid-area: checkbox;
}
.list {
display: grid;
grid-area: list;
gap: var(--Spacing-x1);
}
.listItem {
display: flex;
}
.terms {
border-top: 1px solid var(--Base-Border-Normal);
grid-area: terms;
padding-top: var(--Spacing-x1);
}
@media screen and (min-width: 768px) {
.cardContainer {
grid-template-columns: 1fr auto;
gap: var(--Spacing-x2);
grid-template-areas:
"checkbox login"
"list list"
"terms terms";
}
.list {
display: flex;
gap: var(--Spacing-x1);
}
}

View File

@@ -4,14 +4,8 @@ import { useEffect, useState } from "react"
import { useWatch } from "react-hook-form" import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { privacyPolicy } from "@/constants/currentWebHrefs"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import CheckboxCard from "@/components/TempDesignSystem/Form/ChoiceCard/Checkbox"
import DateSelect from "@/components/TempDesignSystem/Form/Date" import DateSelect from "@/components/TempDesignSystem/Form/Date"
import Input from "@/components/TempDesignSystem/Form/Input" import Input from "@/components/TempDesignSystem/Form/Input"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
@@ -31,67 +25,27 @@ export default function Signup({ name }: { name: string }) {
setIsJoinChecked(joinValue) setIsJoinChecked(joinValue)
}, [joinValue]) }, [joinValue])
const list = [ return isJoinChecked ? (
{ title: intl.formatMessage({ id: "Earn bonus nights & points" }) }, <div className={styles.additionalFormData}>
{ title: intl.formatMessage({ id: "Get member benefits & offers" }) }, <Input
{ title: intl.formatMessage({ id: "Join at no cost" }) }, name="zipCode"
] label={intl.formatMessage({ id: "Zip code" })}
registerOptions={{ required: true }}
return (
<div className={styles.container}>
<CheckboxCard
highlightSubtitle
list={list}
name={name}
subtitle={intl.formatMessage(
{
id: "{difference}{amount} {currency}",
},
{
amount: "491",
currency: "SEK",
difference: "-",
}
)}
title={intl.formatMessage({ id: "Join Scandic Friends" })}
/> />
{isJoinChecked ? ( <div className={styles.dateField}>
<div className={styles.additionalFormData}> <header>
<div className={styles.dateField}> <Caption type="bold">
<header> {intl.formatMessage({ id: "Birth date" })} *
<Caption type="bold"> </Caption>
{intl.formatMessage({ id: "Birth date" })} * </header>
</Caption> <DateSelect name="dateOfBirth" registerOptions={{ required: true }} />
</header> </div>
<DateSelect
name="dateOfBirth"
registerOptions={{ required: true }}
/>
<Input
name="zipCode"
label={intl.formatMessage({ id: "Zip code" })}
registerOptions={{ required: true }}
/>
</div>
<div>
<Checkbox name="termsAccepted" registerOptions={{ required: true }}>
<Body>
{intl.formatMessage({
id: "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with",
})}{" "}
<Link
variant="underscored"
color="peach80"
target="_blank"
href={privacyPolicy[lang]}
>
{intl.formatMessage({ id: "Scandic's Privacy Policy." })}
</Link>
</Body>
</Checkbox>
</div>
</div>
) : null}
</div> </div>
) : (
<Input
label={intl.formatMessage({ id: "Membership no" })}
name="membershipNo"
type="tel"
/>
) )
} }

View File

@@ -1,25 +1,34 @@
.form { .form {
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x3);
} }
.container { .container {
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
grid-template-columns: 1fr 1fr;
width: min(100%, 600px); width: min(100%, 600px);
} }
.header,
.country, .country,
.email, .email,
.membershipNo, .signup,
.phone { .phone {
grid-column: 1/-1; grid-column: 1/-1;
} }
.footer { .footer {
display: grid;
gap: var(--Spacing-x3);
justify-items: flex-start;
margin-top: var(--Spacing-x1); margin-top: var(--Spacing-x1);
} }
@media screen and (min-width: 768px) {
.form {
gap: var(--Spacing-x3);
}
.container {
gap: var(--Spacing-x2);
grid-template-columns: 1fr 1fr;
width: min(100%, 600px);
}
}

View File

@@ -13,6 +13,7 @@ import Input from "@/components/TempDesignSystem/Form/Input"
import Phone from "@/components/TempDesignSystem/Form/Phone" import Phone from "@/components/TempDesignSystem/Form/Phone"
import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
import { guestDetailsSchema, signedInDetailsSchema } from "./schema" import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
import Signup from "./Signup" import Signup from "./Signup"
@@ -24,7 +25,7 @@ import type {
} from "@/types/components/hotelReservation/enterDetails/details" } from "@/types/components/hotelReservation/enterDetails/details"
const formID = "enter-details" const formID = "enter-details"
export default function Details({ user }: DetailsProps) { export default function Details({ user, memberPrice }: DetailsProps) {
const intl = useIntl() const intl = useIntl()
const initialData = useDetailsStore((state) => ({ const initialData = useDetailsStore((state) => ({
countryCode: state.data.countryCode, countryCode: state.data.countryCode,
@@ -35,7 +36,6 @@ export default function Details({ user }: DetailsProps) {
join: state.data.join, join: state.data.join,
dateOfBirth: state.data.dateOfBirth, dateOfBirth: state.data.dateOfBirth,
zipCode: state.data.zipCode, zipCode: state.data.zipCode,
termsAccepted: state.data.termsAccepted,
membershipNo: state.data.membershipNo, membershipNo: state.data.membershipNo,
})) }))
@@ -52,7 +52,6 @@ export default function Details({ user }: DetailsProps) {
join: initialData.join, join: initialData.join,
dateOfBirth: initialData.dateOfBirth, dateOfBirth: initialData.dateOfBirth,
zipCode: initialData.zipCode, zipCode: initialData.zipCode,
termsAccepted: initialData.termsAccepted,
membershipNo: initialData.membershipNo, membershipNo: initialData.membershipNo,
}, },
criteriaMode: "all", criteriaMode: "all",
@@ -76,15 +75,18 @@ export default function Details({ user }: DetailsProps) {
id={formID} id={formID}
onSubmit={methods.handleSubmit(onSubmit)} onSubmit={methods.handleSubmit(onSubmit)}
> >
{user ? null : <Signup name="join" />} {user ? null : (
<Footnote <JoinScandicFriendsCard name="join" memberPrice={memberPrice} />
color="uiTextHighContrast" )}
textTransform="uppercase"
type="label"
>
{intl.formatMessage({ id: "Guest information" })}
</Footnote>
<div className={styles.container}> <div className={styles.container}>
<Footnote
color="uiTextHighContrast"
textTransform="uppercase"
type="label"
className={styles.header}
>
{intl.formatMessage({ id: "Guest information" })}
</Footnote>
<Input <Input
label={intl.formatMessage({ id: "First name" })} label={intl.formatMessage({ id: "First name" })}
name="firstName" name="firstName"
@@ -118,13 +120,10 @@ export default function Details({ user }: DetailsProps) {
readOnly={!!user} readOnly={!!user}
registerOptions={{ required: true }} registerOptions={{ required: true }}
/> />
{user || methods.watch("join") ? null : ( {user ? null : (
<Input <div className={styles.signup}>
className={styles.membershipNo} <Signup name="join" />
label={intl.formatMessage({ id: "Membership no" })} </div>
name="membershipNo"
type="tel"
/>
)} )}
</div> </div>
<footer className={styles.footer}> <footer className={styles.footer}>

View File

@@ -15,7 +15,6 @@ export const notJoinDetailsSchema = baseDetailsSchema.merge(
join: z.literal<boolean>(false), join: z.literal<boolean>(false),
zipCode: z.string().optional(), zipCode: z.string().optional(),
dateOfBirth: z.string().optional(), dateOfBirth: z.string().optional(),
termsAccepted: z.boolean().default(false),
membershipNo: z membershipNo: z
.string() .string()
.optional() .optional()
@@ -39,15 +38,6 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
join: z.literal<boolean>(true), join: z.literal<boolean>(true),
zipCode: z.string().min(1, { message: "Zip code is required" }), zipCode: z.string().min(1, { message: "Zip code is required" }),
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }), dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
termsAccepted: z.literal<boolean>(true, {
errorMap: (err, ctx) => {
switch (err.code) {
case "invalid_literal":
return { message: "You must accept the terms and conditions" }
}
return { message: ctx.defaultError }
},
}),
membershipNo: z.string().optional(), membershipNo: z.string().optional(),
}) })
) )

View File

@@ -66,7 +66,7 @@ export default function SectionAccordion({
const textColor = const textColor =
isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled" isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled"
return ( return (
<section className={styles.wrapper} data-open={isOpen} data-step={step}> <div className={styles.accordion} data-open={isOpen} data-step={step}>
<div className={styles.iconWrapper}> <div className={styles.iconWrapper}>
<div className={styles.circle} data-checked={isComplete}> <div className={styles.circle} data-checked={isComplete}>
{isComplete ? ( {isComplete ? (
@@ -74,31 +74,33 @@ export default function SectionAccordion({
) : null} ) : null}
</div> </div>
</div> </div>
<div className={styles.main}> <header className={styles.header}>
<header> <button
<button onClick={onModify} className={styles.modifyButton}> onClick={onModify}
<Footnote disabled={!isComplete}
className={styles.title} className={styles.modifyButton}
asChild >
textTransform="uppercase" <Footnote
type="label" className={styles.title}
color={textColor} asChild
> textTransform="uppercase"
<h2>{header}</h2> type="label"
</Footnote> color={textColor}
<Subtitle className={styles.selection} type="two" color={textColor}> >
{title} <h2>{header}</h2>
</Subtitle> </Footnote>
<Subtitle className={styles.selection} type="two" color={textColor}>
{title}
</Subtitle>
{isComplete && !isOpen && ( {isComplete && !isOpen && (
<ChevronDownIcon className={styles.button} color="burgundy" /> <ChevronDownIcon className={styles.button} color="burgundy" />
)} )}
</button> </button>
</header> </header>
<div className={styles.content}> <div className={styles.content}>
<div className={styles.contentWrapper}>{children}</div> <div className={styles.contentWrapper}>{children}</div>
</div>
</div> </div>
</section> </div>
) )
} }

View File

@@ -1,15 +1,28 @@
.wrapper { .accordion {
position: relative; --header-height: 2.4em;
display: flex; --circle-height: 24px;
flex-direction: row;
gap: var(--Spacing-x-one-and-half); gap: var(--Spacing-x3);
width: 100%;
padding-top: var(--Spacing-x3); padding-top: var(--Spacing-x3);
transition: 0.4s ease-out;
display: grid;
grid-template-areas: "circle header" "content content";
grid-template-columns: auto 1fr;
grid-template-rows: var(--header-height) 0fr;
column-gap: var(--Spacing-x-one-and-half);
} }
.wrapper:last-child .main { .accordion:last-child {
border-bottom: none; border-bottom: none;
} }
.header {
grid-area: header;
}
.modifyButton { .modifyButton {
display: grid; display: grid;
grid-template-areas: "title button" "selection button"; grid-template-areas: "title button" "selection button";
@@ -17,6 +30,11 @@
background-color: transparent; background-color: transparent;
border: none; border: none;
width: 100%; width: 100%;
padding: 0;
}
.modifyButton:disabled {
cursor: default;
} }
.title { .title {
@@ -29,15 +47,6 @@
justify-self: flex-end; justify-self: flex-end;
} }
.main {
display: grid;
width: 100%;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
padding-bottom: var(--Spacing-x3);
transition: 0.4s ease-out;
grid-template-rows: 2em 0fr;
}
.selection { .selection {
font-weight: 450; font-weight: 450;
font-size: var(--typography-Title-4-fontSize); font-size: var(--typography-Title-4-fontSize);
@@ -46,11 +55,12 @@
.iconWrapper { .iconWrapper {
position: relative; position: relative;
grid-area: circle;
} }
.circle { .circle {
width: 24px; width: var(--circle-height);
height: 24px; height: var(--circle-height);
border-radius: 100px; border-radius: 100px;
transition: background-color 0.4s; transition: background-color 0.4s;
border: 2px solid var(--Base-Border-Inverted); border: 2px solid var(--Base-Border-Inverted);
@@ -63,42 +73,45 @@
background-color: var(--UI-Input-Controls-Fill-Selected); background-color: var(--UI-Input-Controls-Fill-Selected);
} }
.wrapper[data-open="true"] .circle[data-checked="false"] { .accordion[data-open="true"] .circle[data-checked="false"] {
background-color: var(--UI-Text-Placeholder); background-color: var(--UI-Text-Placeholder);
} }
.wrapper[data-open="false"] .circle[data-checked="false"] { .accordion[data-open="false"] .circle[data-checked="false"] {
background-color: var(--Base-Surface-Subtle-Hover); background-color: var(--Base-Surface-Subtle-Hover);
} }
.wrapper[data-open="true"] .main { .accordion[data-open="true"] {
grid-template-rows: 2em 1fr; grid-template-rows: var(--header-height) 1fr;
}
.contentWrapper {
padding-bottom: var(--Spacing-x3);
} }
.content { .content {
overflow: hidden; overflow: hidden;
grid-area: content;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
} }
.contentWrapper { @media screen and (min-width: 768px) {
padding-top: var(--Spacing-x3); .accordion {
} column-gap: var(--Spacing-x3);
grid-template-areas: "circle header" "circle content";
@media screen and (min-width: 1367px) {
.wrapper {
gap: var(--Spacing-x3);
} }
.iconWrapper { .iconWrapper {
top: var(--Spacing-x1); top: var(--Spacing-x1);
} }
.wrapper:not(:last-child)::after { .accordion:not(:last-child) .iconWrapper::after {
position: absolute; position: absolute;
left: 12px; left: 12px;
bottom: 0; bottom: calc(0px - var(--Spacing-x7));
top: var(--Spacing-x7); top: var(--circle-height);
height: 100%;
content: ""; content: "";
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
} }
} }

View File

@@ -63,7 +63,7 @@
justify-content: flex-start; justify-content: flex-start;
} }
@media screen and (min-width: 1367px) { @media screen and (min-width: 768px) {
.wrapper { .wrapper {
gap: var(--Spacing-x3); gap: var(--Spacing-x3);
padding-top: var(--Spacing-x3); padding-top: var(--Spacing-x3);

View File

@@ -81,7 +81,6 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
useEffect(() => { useEffect(() => {
setChosenBed(bedType) setChosenBed(bedType)
setChosenBreakfast(breakfast)
if (breakfast || breakfast === false) { if (breakfast || breakfast === false) {
setChosenBreakfast(breakfast) setChosenBreakfast(breakfast)
@@ -94,9 +93,9 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
euro: euro:
room.euroPrice && roomsPriceEuro room.euroPrice && roomsPriceEuro
? { ? {
price: roomsPriceEuro, price: roomsPriceEuro,
currency: room.euroPrice.currency, currency: room.euroPrice.currency,
} }
: undefined, : undefined,
}) })
} else { } else {
@@ -108,11 +107,11 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
euro: euro:
room.euroPrice && roomsPriceEuro room.euroPrice && roomsPriceEuro
? { ? {
price: price:
roomsPriceEuro + roomsPriceEuro +
parseInt(breakfast.requestedPrice.totalPrice), parseInt(breakfast.requestedPrice.totalPrice),
currency: room.euroPrice.currency, currency: room.euroPrice.currency,
} }
: undefined, : undefined,
}) })
} }
@@ -199,24 +198,24 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
</div> </div>
{room.packages {room.packages
? room.packages.map((roomPackage) => ( ? room.packages.map((roomPackage) => (
<div className={styles.entry} key={roomPackage.code}> <div className={styles.entry} key={roomPackage.code}>
<div> <div>
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">
{roomPackage.description} {roomPackage.description}
</Body> </Body>
</div> </div>
<Caption color="uiTextHighContrast"> <Caption color="uiTextHighContrast">
{intl.formatMessage( {intl.formatMessage(
{ id: "{amount} {currency}" }, { id: "{amount} {currency}" },
{ {
amount: roomPackage.localPrice.price, amount: roomPackage.localPrice.price,
currency: roomPackage.localPrice.currency, currency: roomPackage.localPrice.currency,
} }
)} )}
</Caption> </Caption>
</div> </div>
)) ))
: null} : null}
{chosenBed ? ( {chosenBed ? (
<div className={styles.entry}> <div className={styles.entry}>
@@ -263,9 +262,8 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
)} )}
</Caption> </Caption>
</div> </div>
) : null ) : null}
} </div>
</div >
<Divider color="primaryLightSubtle" /> <Divider color="primaryLightSubtle" />
<div className={styles.total}> <div className={styles.total}>
<div className={styles.entry}> <div className={styles.entry}>
@@ -306,6 +304,6 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
</div> </div>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" /> <Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div> </div>
</section > </section>
) )
} }

View File

@@ -15,7 +15,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Spacing-x-one-and-half); gap: var(--Spacing-x-one-and-half);
max-width: 260px;
} }
.divider { .divider {
@@ -38,3 +37,9 @@
font-weight: 400; font-weight: 400;
font-size: var(--typography-Caption-Regular-fontSize); font-size: var(--typography-Caption-Regular-fontSize);
} }
@media screen and (min-width: 1367px) {
.prices {
max-width: 260px;
}
}

View File

@@ -7,7 +7,6 @@ import { selectHotelMap, selectRate } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data" import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import ImageGallery from "@/components/ImageGallery" import ImageGallery from "@/components/ImageGallery"
import Alert from "@/components/TempDesignSystem/Alert"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
@@ -133,13 +132,6 @@ export default function HotelCard({
hotel={hotelData} hotel={hotelData}
showCTA={true} showCTA={true}
/> />
{hotelData.specialAlerts.length > 0 && (
<div className={styles.specialAlerts}>
{hotelData.specialAlerts.map((alert) => (
<Alert key={alert.id} type={alert.type} text={alert.text} />
))}
</div>
)}
</section> </section>
<HotelPriceList price={price} hotelId={hotel.hotelData.operaId} /> <HotelPriceList price={price} hotelId={hotel.hotelData.operaId} />
</div> </div>

View File

@@ -1,9 +1,11 @@
"use client" "use client"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { useMemo } from "react" import { useEffect, useMemo, useState } from "react"
import { useHotelFilterStore } from "@/stores/hotel-filters" import { useHotelFilterStore } from "@/stores/hotel-filters"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import HotelCard from "../HotelCard" import HotelCard from "../HotelCard"
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter" import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
@@ -25,6 +27,7 @@ export default function HotelCardListing({
const searchParams = useSearchParams() const searchParams = useSearchParams()
const activeFilters = useHotelFilterStore((state) => state.activeFilters) const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount) const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
const sortBy = useMemo( const sortBy = useMemo(
() => searchParams.get("sort") ?? DEFAULT_SORT, () => searchParams.get("sort") ?? DEFAULT_SORT,
@@ -48,7 +51,7 @@ export default function HotelCardListing({
return ( return (
hotel.price?.member?.localPrice?.pricePerNight ?? hotel.price?.member?.localPrice?.pricePerNight ??
hotel.price?.public?.localPrice?.pricePerNight ?? hotel.price?.public?.localPrice?.pricePerNight ??
0 Infinity
) )
} }
return [...hotelData].sort( return [...hotelData].sort(
@@ -82,6 +85,20 @@ export default function HotelCardListing({
return filteredHotels return filteredHotels
}, [activeFilters, sortedHotels, setResultCount]) }, [activeFilters, sortedHotels, setResultCount])
useEffect(() => {
const handleScroll = () => {
const hasScrolledPast = window.scrollY > 490
setShowBackToTop(hasScrolledPast)
}
window.addEventListener("scroll", handleScroll, { passive: true })
return () => window.removeEventListener("scroll", handleScroll)
}, [])
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" })
}
return ( return (
<section className={styles.hotelCards}> <section className={styles.hotelCards}>
{hotels?.length {hotels?.length
@@ -95,6 +112,7 @@ export default function HotelCardListing({
/> />
)) ))
: null} : null}
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
</section> </section>
) )
} }

View File

@@ -81,6 +81,10 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
.title {
display: none;
}
.close { .close {
background: none; background: none;
border: none; border: none;
@@ -97,3 +101,80 @@
flex: 0 0 auto; flex: 0 0 auto;
border-top: 1px solid var(--Base-Border-Subtle); border-top: 1px solid var(--Base-Border-Subtle);
} }
@media screen and (min-width: 768px) {
.modal {
left: 50%;
bottom: 50%;
height: min(80dvh, 680px);
width: min(80dvw, 960px);
translate: -50% 50%;
overflow-y: auto;
}
.header {
display: grid;
grid-template-columns: auto 1fr;
padding: var(--Spacing-x2) var(--Spacing-x3);
align-items: center;
border-bottom: 1px solid var(--Base-Border-Subtle);
position: sticky;
top: 0;
background: var(--Base-Surface-Primary-light-Normal);
z-index: 1;
border-top-left-radius: var(--Corner-radius-large);
border-top-right-radius: var(--Corner-radius-large);
}
.title {
display: block;
}
.content {
gap: var(--Spacing-x4);
height: auto;
}
.filters {
overflow-y: unset;
}
.sorter,
.filters,
.footer,
.divider {
padding: 0 var(--Spacing-x3);
}
.footer {
flex-direction: row-reverse;
justify-content: space-between;
position: sticky;
bottom: 0;
background: var(--Base-Surface-Primary-light-Normal);
z-index: 1;
border-bottom-left-radius: var(--Corner-radius-large);
border-bottom-right-radius: var(--Corner-radius-large);
padding: var(--Spacing-x2) var(--Spacing-x3);
}
.filters aside h1 {
margin-bottom: var(--Spacing-x2);
}
.filters aside > div:last-child {
margin-top: var(--Spacing-x4);
padding-bottom: 0;
}
.filters aside ul {
display: grid;
grid-template-columns: 1fr 1fr;
margin-top: var(--Spacing-x3);
}
}
@media screen and (min-width: 1024) {
.facilities ul {
grid-template-columns: 1fr 1fr 1fr;
}
}

View File

@@ -12,6 +12,8 @@ import { useHotelFilterStore } from "@/stores/hotel-filters"
import { CloseLargeIcon, FilterIcon } from "@/components/Icons" import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import HotelFilter from "../HotelFilter" import HotelFilter from "../HotelFilter"
import HotelSorter from "../HotelSorter" import HotelSorter from "../HotelSorter"
@@ -47,10 +49,18 @@ export default function FilterAndSortModal({
> >
<CloseLargeIcon /> <CloseLargeIcon />
</button> </button>
<Subtitle
type="two"
textAlign="center"
className={styles.title}
>
{intl.formatMessage({ id: "Filter and sort" })}
</Subtitle>
</header> </header>
<div className={styles.sorter}> <div className={styles.sorter}>
<HotelSorter /> <HotelSorter />
</div> </div>
<Divider color="subtle" className="divider" />
<div className={styles.filters}> <div className={styles.filters}>
<HotelFilter filters={filters} /> <HotelFilter filters={filters} />
</div> </div>

View File

@@ -0,0 +1,22 @@
"use client"
import { useIntl } from "react-intl"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
export default function HotelCount() {
const intl = useIntl()
const resultCount = useHotelFilterStore((state) => state.resultCount)
return (
<Preamble>
{intl.formatMessage(
{
id: "Hotel(s)",
},
{ amount: resultCount }
)}
</Preamble>
)
}

View File

@@ -7,11 +7,13 @@ import { useMediaQuery } from "usehooks-ts"
import { selectHotel } from "@/constants/routes/hotelReservation" import { selectHotel } from "@/constants/routes/hotelReservation"
import { CloseIcon, CloseLargeIcon } from "@/components/Icons" import { ArrowUpIcon, CloseIcon, CloseLargeIcon } from "@/components/Icons"
import InteractiveMap from "@/components/Maps/InteractiveMap" import InteractiveMap from "@/components/Maps/InteractiveMap"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import FilterAndSortModal from "../FilterAndSortModal"
import HotelListing from "./HotelListing" import HotelListing from "./HotelListing"
import { getCentralCoordinates } from "./utils" import { getCentralCoordinates } from "./utils"
@@ -24,6 +26,7 @@ export default function SelectHotelMap({
hotelPins, hotelPins,
mapId, mapId,
hotels, hotels,
filterList,
}: SelectHotelMapProps) { }: SelectHotelMapProps) {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
@@ -101,25 +104,14 @@ export default function SelectHotelMap({
> >
<CloseLargeIcon /> <CloseLargeIcon />
</Button> </Button>
<span>Filter and sort</span> <FilterAndSortModal filters={filterList} />
{/* TODO: Add filter and sort button */}
</div> </div>
<HotelListing <HotelListing
hotels={hotels} hotels={hotels}
activeHotelPin={activeHotelPin} activeHotelPin={activeHotelPin}
setActiveHotelPin={setActiveHotelPin} setActiveHotelPin={setActiveHotelPin}
/> />
{showBackToTop && ( {showBackToTop && <BackToTopButton onClick={scrollToTop} />}
<Button
intent="inverted"
size="small"
theme="base"
className={styles.backToTopButton}
onClick={scrollToTop}
>
{intl.formatMessage({ id: "Back to top" })}
</Button>
)}
</div> </div>
<InteractiveMap <InteractiveMap
closeButton={closeButton} closeButton={closeButton}

View File

@@ -23,10 +23,6 @@
height: 44px; height: 44px;
} }
.backToTopButton {
display: none;
}
@media (min-width: 768px) { @media (min-width: 768px) {
.container .closeButton { .container .closeButton {
display: flex; display: flex;
@@ -34,12 +30,7 @@
.container .listingContainer .filterContainer .filterContainerCloseButton { .container .listingContainer .filterContainer .filterContainerCloseButton {
display: none; display: none;
} }
.backToTopButton {
position: fixed;
bottom: 24px;
left: 32px;
display: flex;
}
.listingContainer { .listingContainer {
background-color: var(--Base-Surface-Secondary-light-Normal); background-color: var(--Base-Surface-Secondary-light-Normal);
padding: var(--Spacing-x3) var(--Spacing-x4); padding: var(--Spacing-x3) var(--Spacing-x4);
@@ -50,4 +41,9 @@
.container { .container {
display: flex; display: flex;
} }
.filterContainer {
justify-content: flex-end;
padding: 0 0 var(--Spacing-x1);
}
} }

View File

@@ -54,9 +54,22 @@ export default function RoomCard({
: undefined : undefined
} }
function getPriceInformationForRate(rate: RateDefinition | undefined) { function getRateDefinitionForRate(rate: RateDefinition | undefined) {
return rateDefinitions.find((def) => def.rateCode === rate?.rateCode) return rateDefinitions.find((def) => def.rateCode === rate?.rateCode)
?.generalTerms }
const getBreakfastMessage = (rate: RateDefinition | undefined) => {
const breakfastIncluded = getRateDefinitionForRate(rate)?.breakfastIncluded
switch (breakfastIncluded) {
case true:
return intl.formatMessage({ id: "Breakfast is included." })
case false:
return intl.formatMessage({ id: "Breakfast selection in next step." })
default:
return intl.formatMessage({
id: "Breakfast deal can be purchased at the hotel.",
})
}
} }
const petRoomPackage = const petRoomPackage =
@@ -69,7 +82,6 @@ export default function RoomCard({
) )
const { roomSize, occupancy, images } = selectedRoom || {} const { roomSize, occupancy, images } = selectedRoom || {}
const mainImage = images?.[0]
const freeCancelation = intl.formatMessage({ id: "Free cancellation" }) const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
const nonRefundable = intl.formatMessage({ id: "Non-refundable" }) const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
@@ -101,53 +113,56 @@ export default function RoomCard({
return ( return (
<div className={classNames}> <div className={classNames}>
<div> <div>
{mainImage && ( <div className={styles.imageContainer}>
<div className={styles.imageContainer}> <div className={styles.chipContainer}>
<div className={styles.chipContainer}> {roomConfiguration.roomsLeft < 5 && (
{roomConfiguration.roomsLeft < 5 && ( <span className={styles.chip}>
<span className={styles.chip}> <Footnote
<Footnote color="burgundy"
color="burgundy" textTransform="uppercase"
textTransform="uppercase" >{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote> </span>
</span>
)}
{roomConfiguration.features
.filter((feature) => selectedPackages.includes(feature.code))
.map((feature) => (
<span className={styles.chip} key={feature.code}>
{createElement(getIconForFeatureCode(feature.code), {
width: 16,
height: 16,
color: "burgundy",
})}
</span>
))}
</div>
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
<ImageGallery
images={images}
title={roomConfiguration.roomType}
fill
/>
</div>
)}
<div className={styles.specification}>
<Caption color="uiTextMediumContrast" className={styles.guests}>
{intl.formatMessage(
{
id: "booking.guests",
},
{ nrOfGuests: occupancy?.total }
)} )}
</Caption> {roomConfiguration.features
<Caption color="uiTextMediumContrast"> .filter((feature) => selectedPackages.includes(feature.code))
{roomSize?.min === roomSize?.max .map((feature) => (
? roomSize?.min <span className={styles.chip} key={feature.code}>
: `${roomSize?.min}-${roomSize?.max}`} {createElement(getIconForFeatureCode(feature.code), {
m² width: 16,
</Caption> height: 16,
color: "burgundy",
})}
</span>
))}
</div>
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
<ImageGallery
images={images}
title={roomConfiguration.roomType}
fill
/>
</div>
<div className={styles.specification}>
{occupancy && (
<Caption color="uiTextMediumContrast" className={styles.guests}>
{intl.formatMessage(
{
id: "booking.guests",
},
{ nrOfGuests: occupancy?.total }
)}
</Caption>
)}
{roomSize && (
<Caption color="uiTextMediumContrast">
{roomSize.min === roomSize.max
? roomSize.min
: `${roomSize.min}-${roomSize.max}`}
m²
</Caption>
)}
<div className={styles.toggleSidePeek}> <div className={styles.toggleSidePeek}>
{roomConfiguration.roomTypeCode && ( {roomConfiguration.roomTypeCode && (
<ToggleSidePeek <ToggleSidePeek
@@ -168,9 +183,7 @@ export default function RoomCard({
</div> </div>
<div className={styles.container}> <div className={styles.container}>
<Caption color="uiTextHighContrast" type="bold"> <Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ {getBreakfastMessage(rates.flexRate)}
id: "Breakfast selection in next step.",
})}
</Caption> </Caption>
{roomConfiguration.status === "NotAvailable" ? ( {roomConfiguration.status === "NotAvailable" ? (
<div className={styles.noRoomsContainer}> <div className={styles.noRoomsContainer}>
@@ -192,7 +205,7 @@ export default function RoomCard({
value={key.toLowerCase()} value={key.toLowerCase()}
paymentTerm={key === "flexRate" ? payLater : payNow} paymentTerm={key === "flexRate" ? payLater : payNow}
product={findProductForRate(rate)} product={findProductForRate(rate)}
priceInformation={getPriceInformationForRate(rate)} priceInformation={getRateDefinitionForRate(rate)?.generalTerms}
handleSelectRate={handleSelectRate} handleSelectRate={handleSelectRate}
roomType={roomConfiguration.roomType} roomType={roomConfiguration.roomType}
roomTypeCode={roomConfiguration.roomTypeCode} roomTypeCode={roomConfiguration.roomTypeCode}

View File

@@ -7,23 +7,13 @@
border: 1px solid var(--Base-Border-Subtle); border: 1px solid var(--Base-Border-Subtle);
position: relative; position: relative;
height: 100%; height: 100%;
min-height: 730px;
justify-content: space-between; justify-content: space-between;
} }
.card.noAvailability { .card.noAvailability {
justify-content: flex-start; justify-content: flex-start;
} opacity: 0.6;
.card.noAvailability:before {
background-color: rgba(0, 0, 0, 40%);
content: "";
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 2;
} }
.specification { .specification {

View File

@@ -31,7 +31,10 @@ export function filterDuplicateRoomTypesByLowestPrice(
products.forEach((product) => { products.forEach((product) => {
const { productType } = product const { productType } = product
const publicProduct = productType.public const publicProduct = productType.public || {
requestedPrice: null,
localPrice: null,
}
const memberProduct = productType.member || { const memberProduct = productType.member || {
requestedPrice: null, requestedPrice: null,
localPrice: null, localPrice: null,
@@ -53,7 +56,7 @@ export function filterDuplicateRoomTypesByLowestPrice(
Number(memberRequestedPrice?.pricePerNight) ?? Infinity Number(memberRequestedPrice?.pricePerNight) ?? Infinity
) )
const currentLocalPrice = Math.min( const currentLocalPrice = Math.min(
Number(publicLocalPrice.pricePerNight) ?? Infinity, Number(publicLocalPrice?.pricePerNight) ?? Infinity,
Number(memberLocalPrice?.pricePerNight) ?? Infinity Number(memberLocalPrice?.pricePerNight) ?? Infinity
) )
@@ -63,7 +66,7 @@ export function filterDuplicateRoomTypesByLowestPrice(
Math.min( Math.min(
Number( Number(
previousLowest.products[0].productType.public.requestedPrice previousLowest.products[0].productType.public.requestedPrice
.pricePerNight ?.pricePerNight
) ?? Infinity, ) ?? Infinity,
Number( Number(
previousLowest.products[0].productType.member?.requestedPrice previousLowest.products[0].productType.member?.requestedPrice
@@ -74,7 +77,7 @@ export function filterDuplicateRoomTypesByLowestPrice(
Math.min( Math.min(
Number( Number(
previousLowest.products[0].productType.public.requestedPrice previousLowest.products[0].productType.public.requestedPrice
.pricePerNight ?.pricePerNight
) ?? Infinity, ) ?? Infinity,
Number( Number(
previousLowest.products[0].productType.member?.requestedPrice previousLowest.products[0].productType.member?.requestedPrice
@@ -85,7 +88,7 @@ export function filterDuplicateRoomTypesByLowestPrice(
Math.min( Math.min(
Number( Number(
previousLowest.products[0].productType.public.localPrice previousLowest.products[0].productType.public.localPrice
.pricePerNight ?.pricePerNight
) ?? Infinity, ) ?? Infinity,
Number( Number(
previousLowest.products[0].productType.member?.localPrice previousLowest.products[0].productType.member?.localPrice

View File

@@ -0,0 +1,33 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function ArrowUpIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask
id="a"
width="20"
height="20"
x="0"
y="0"
maskUnits="userSpaceOnUse"
>
<path fill="#D9D9D9" d="M0 0h20v20H0z" />
</mask>
<path
fill="#4D001B"
d="m9.219 6.541-4.021 4.021a.74.74 0 0 1-.552.235.778.778 0 0 1-.552-.245.796.796 0 0 1-.235-.552.74.74 0 0 1 .235-.552l5.354-5.355a.77.77 0 0 1 .849-.171.77.77 0 0 1 .255.171l5.354 5.355a.782.782 0 0 1 0 1.104.764.764 0 0 1-1.114 0l-4.01-4.01v9.135c0 .215-.077.4-.23.552a.752.752 0 0 1-.552.229.752.752 0 0 1-.552-.23.752.752 0 0 1-.23-.551V6.54Z"
/>
</svg>
)
}

View File

@@ -6,6 +6,7 @@ export { default as AirIcon } from "./Air"
export { default as AirplaneIcon } from "./Airplane" export { default as AirplaneIcon } from "./Airplane"
export { default as AllergyIcon } from "./Allergy" export { default as AllergyIcon } from "./Allergy"
export { default as ArrowRightIcon } from "./ArrowRight" export { default as ArrowRightIcon } from "./ArrowRight"
export { default as ArrowUpIcon } from "./ArrowUp"
export { default as BarIcon } from "./Bar" export { default as BarIcon } from "./Bar"
export { default as BathtubIcon } from "./Bathtub" export { default as BathtubIcon } from "./Bathtub"
export { default as BedDoubleIcon } from "./BedDouble" export { default as BedDoubleIcon } from "./BedDouble"

View File

@@ -13,19 +13,16 @@ import { trackLoginClick } from "@/utils/tracking"
import { TrackingPosition } from "@/types/components/tracking" import { TrackingPosition } from "@/types/components/tracking"
export default function LoginButton({ export default function LoginButton({
className,
position, position,
trackingId, trackingId,
children, children,
color = "black", ...props
variant = "navigation", }: PropsWithChildren<
}: PropsWithChildren<{ {
className: string trackingId: string
trackingId: string position: TrackingPosition
position: TrackingPosition } & Omit<LinkProps, "href">
color?: LinkProps["color"] >) {
variant?: "navigation" | "signupVerification"
}>) {
const lang = useLang() const lang = useLang()
const pathName = useLazyPathname({ includeSearchParams: true }) const pathName = useLazyPathname({ includeSearchParams: true })
@@ -34,25 +31,19 @@ export default function LoginButton({
: login[lang] : login[lang]
useEffect(() => { useEffect(() => {
document function trackLogin() {
.getElementById(trackingId) trackLoginClick(position)
?.addEventListener("click", () => trackLoginClick(position)) }
document.getElementById(trackingId)?.addEventListener("click", trackLogin)
return () => { return () => {
document document
.getElementById(trackingId) .getElementById(trackingId)
?.removeEventListener("click", () => trackLoginClick(position)) ?.removeEventListener("click", trackLogin)
} }
}, [position, trackingId]) }, [position, trackingId])
return ( return (
<Link <Link id={trackingId} prefetch={false} {...props} href={href}>
className={className}
id={trackingId}
color={color}
href={href}
prefetch={false}
variant={variant}
>
{children} {children}
</Link> </Link>
) )

View File

@@ -66,7 +66,7 @@ export function MapModal({ children }: { children: React.ReactNode }) {
return ( return (
<div className={styles.wrapper} ref={rootDiv}> <div className={styles.wrapper} ref={rootDiv}>
<Modal isDismissable isOpen={isOpen} onOpenChange={handleOnOpenChange}> <Modal isOpen={isOpen} onOpenChange={handleOnOpenChange}>
<Dialog <Dialog
style={ style={
{ {

View File

@@ -1,22 +1,15 @@
import { IconName } from "@/types/components/icon" import { IconName } from "@/types/components/icon"
import { import { PointOfInterestGroupEnum } from "@/types/hotel"
PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum,
} from "@/types/hotel"
export function getIconByPoiGroupAndCategory( export function getIconByPoiGroupAndCategory(
group: PointOfInterestGroupEnum, group: PointOfInterestGroupEnum,
category?: PointOfInterestCategoryNameEnum category?: string
) { ) {
switch (group) { switch (group) {
case PointOfInterestGroupEnum.PUBLIC_TRANSPORT: case PointOfInterestGroupEnum.PUBLIC_TRANSPORT:
return category === PointOfInterestCategoryNameEnum.AIRPORT return category === "Airport" ? IconName.Airplane : IconName.Train
? IconName.Airplane
: IconName.Train
case PointOfInterestGroupEnum.ATTRACTIONS: case PointOfInterestGroupEnum.ATTRACTIONS:
return category === PointOfInterestCategoryNameEnum.MUSEUM return category === "Museum" ? IconName.Museum : IconName.Camera
? IconName.Museum
: IconName.Camera
case PointOfInterestGroupEnum.BUSINESS: case PointOfInterestGroupEnum.BUSINESS:
return IconName.Business return IconName.Business
case PointOfInterestGroupEnum.PARKING: case PointOfInterestGroupEnum.PARKING:

View File

@@ -0,0 +1,45 @@
.backToTopButton {
border-radius: var(--Corner-radius-Rounded);
cursor: pointer;
display: flex;
align-items: flex-end;
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
background-color: var(--Base-Surface-Primary-light-Normal);
color: var(--Base-Button-Secondary-On-Fill-Normal);
border: 2px solid var(--Base-Button-Secondary-On-Fill-Normal);
gap: var(--Spacing-x-half);
padding: var(--Spacing-x1);
text-align: center;
transition:
background-color 300ms ease,
color 300ms ease;
font-family: var(--typography-Body-Bold-fontFamily);
font-weight: 500;
font-size: var(--typography-Caption-Bold-fontSize);
line-height: var(--typography-Caption-Bold-lineHeight);
letter-spacing: 0.6%;
text-decoration: none;
}
.backToTopButtonText {
display: none;
}
@media (min-width: 768px) {
.backToTopButtonText {
display: initial;
}
.backToTopButton:hover {
background-color: var(--Base-Button-Tertiary-Fill-Normal);
color: var(--Base-Button-Tertiary-On-Fill-Hover);
}
.backToTopButton:hover > svg * {
fill: var(--Base-Button-Tertiary-On-Fill-Hover);
}
.backToTopButton {
padding: calc(var(--Spacing-x1) + 2px) var(--Spacing-x2);
}
}

View File

@@ -0,0 +1,20 @@
"use client"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { ArrowUpIcon } from "@/components/Icons"
import styles from "./backToTopButton.module.css"
export function BackToTopButton({ onClick }: { onClick: () => void }) {
const intl = useIntl()
return (
<ButtonRAC className={styles.backToTopButton} onPress={onClick}>
<ArrowUpIcon color="burgundy" />
<span className={styles.backToTopButtonText}>
{intl.formatMessage({ id: "Back to top" })}
</span>
</ButtonRAC>
)
}

View File

@@ -2,6 +2,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: var(--text-color); color: var(--text-color);
cursor: pointer;
} }
.container[data-selected] .checkbox { .container[data-selected] .checkbox {

View File

@@ -12,6 +12,7 @@ import styles from "./checkbox.module.css"
import { CheckboxProps } from "@/types/components/checkbox" import { CheckboxProps } from "@/types/components/checkbox"
export default function Checkbox({ export default function Checkbox({
className,
name, name,
children, children,
registerOptions, registerOptions,
@@ -25,16 +26,17 @@ export default function Checkbox({
return ( return (
<AriaCheckbox <AriaCheckbox
className={styles.container} className={`${styles.container} ${className}`}
isSelected={field.value} isSelected={field.value}
onChange={field.onChange} onChange={field.onChange}
data-testid={name} data-testid={name}
isDisabled={registerOptions?.disabled} isDisabled={registerOptions?.disabled}
excludeFromTabOrder
> >
{({ isSelected }) => ( {({ isSelected }) => (
<> <>
<span className={styles.checkboxContainer}> <span className={styles.checkboxContainer}>
<span className={styles.checkbox}> <span className={styles.checkbox} tabIndex={0}>
{isSelected && <CheckIcon color="white" />} {isSelected && <CheckIcon color="white" />}
</span> </span>
{children} {children}

View File

@@ -1,7 +0,0 @@
import Card from "./_Card"
import type { CheckboxProps } from "./_Card/card"
export default function CheckboxCard(props: CheckboxProps) {
return <Card {...props} type="checkbox" />
}

View File

@@ -1,5 +1,4 @@
.label { .label {
align-self: flex-start;
background-color: var(--Base-Surface-Primary-light-Normal); background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle); border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Large); border-radius: var(--Corner-radius-Large);

View File

@@ -1,4 +1,8 @@
/* Leaving, will most likely get deleted */ /* Leaving, will most likely get deleted */
.datePicker {
container-name: datePickerContainer;
container-type: inline-size;
}
.container { .container {
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
@@ -27,3 +31,10 @@
.year.invalid > div > div { .year.invalid > div > div {
border-color: var(--Scandic-Red-60); border-color: var(--Scandic-Red-60);
} }
@container datePickerContainer (max-width: 350px) {
.container {
display: flex;
flex-direction: column;
}
}

View File

@@ -115,6 +115,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
ref={field.ref} ref={field.ref}
value={dateValue} value={dateValue}
data-testid={name} data-testid={name}
className={styles.datePicker}
> >
<Group> <Group>
<DateInput className={styles.container}> <DateInput className={styles.container}>

View File

@@ -78,67 +78,69 @@ export default function Phone({
} }
return ( return (
<div className={`${styles.phone} ${className}`}> <div className={`${styles.wrapper} ${className}`}>
<CountrySelector <div className={styles.phone}>
disabled={readOnly} <CountrySelector
dropdownArrowClassName={styles.arrow} disabled={readOnly}
flagClassName={styles.flag} dropdownArrowClassName={styles.arrow}
onSelect={handleSelectCountry} flagClassName={styles.flag}
preferredCountries={["de", "dk", "fi", "no", "se", "gb"]} onSelect={handleSelectCountry}
selectedCountry={country.iso2} preferredCountries={["de", "dk", "fi", "no", "se", "gb"]}
renderButtonWrapper={(props) => ( selectedCountry={country.iso2}
<button renderButtonWrapper={(props) => (
{...props.rootProps} <button
className={styles.select} {...props.rootProps}
tabIndex={0} className={styles.select}
type="button" tabIndex={0}
data-testid="country-selector" type="button"
> data-testid="country-selector"
<Label required={!!registerOptions.required} size="small"> >
{intl.formatMessage({ id: "Country code" })} <Label required={!!registerOptions.required} size="small">
</Label> {intl.formatMessage({ id: "Country code" })}
<span className={styles.selectContainer}> </Label>
{props.children} <span className={styles.selectContainer}>
<Body asChild fontOnly> {props.children}
<DialCodePreview <Body asChild fontOnly>
className={styles.dialCode} <DialCodePreview
dialCode={country.dialCode} className={styles.dialCode}
prefix="+" dialCode={country.dialCode}
prefix="+"
/>
</Body>
<ChevronDownIcon
className={styles.chevron}
color="grey80"
height={18}
width={18}
/> />
</Body> </span>
<ChevronDownIcon </button>
className={styles.chevron} )}
color="grey80"
height={18}
width={18}
/>
</span>
</button>
)}
/>
<TextField
aria-label={ariaLabel}
defaultValue={field.value}
isDisabled={disabled ?? field.disabled}
isInvalid={fieldState.invalid}
isRequired={!!registerOptions?.required}
isReadOnly={readOnly}
name={field.name}
type="tel"
>
<AriaInputWithLabel
{...field}
id={field.name}
label={label}
onChange={handleChange}
placeholder={placeholder}
readOnly={readOnly}
required={!!registerOptions.required}
type="tel"
value={inputValue}
/> />
<ErrorMessage errors={formState.errors} name={field.name} /> <TextField
</TextField> aria-label={ariaLabel}
defaultValue={field.value}
isDisabled={disabled ?? field.disabled}
isInvalid={fieldState.invalid}
isRequired={!!registerOptions?.required}
isReadOnly={readOnly}
name={field.name}
type="tel"
>
<AriaInputWithLabel
{...field}
id={field.name}
label={label}
onChange={handleChange}
placeholder={placeholder}
readOnly={readOnly}
required={!!registerOptions.required}
type="tel"
value={inputValue}
/>
<ErrorMessage errors={formState.errors} name={field.name} />
</TextField>
</div>
</div> </div>
) )
} }

View File

@@ -1,3 +1,7 @@
.wrapper {
container-name: phoneContainer;
container-type: inline-size;
}
.phone { .phone {
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
@@ -100,3 +104,10 @@
justify-self: flex-start; justify-self: flex-start;
padding: 0; padding: 0;
} }
@container phoneContainer (max-width: 350px) {
.phone {
display: flex;
flex-direction: column;
}
}

View File

@@ -16,7 +16,7 @@
.breadcrumb { .breadcrumb {
font-family: var(--typography-Footnote-Bold-fontFamily); font-family: var(--typography-Footnote-Bold-fontFamily);
font-size: var(--typography-Footnote-Bold-fontSize); font-size: var(--typography-Footnote-Bold-fontSize);
font-weight: var(--typography-Footnote-Bold-fontWeight); font-weight: 500; /* var(--typography-Footnote-Bold-fontWeight); */
letter-spacing: var(--typography-Footnote-Bold-letterSpacing); letter-spacing: var(--typography-Footnote-Bold-letterSpacing);
line-height: var(--typography-Footnote-Bold-lineHeight); line-height: var(--typography-Footnote-Bold-lineHeight);
} }
@@ -24,7 +24,7 @@
.link.breadcrumb { .link.breadcrumb {
font-family: var(--typography-Footnote-Bold-fontFamily); font-family: var(--typography-Footnote-Bold-fontFamily);
font-size: var(--typography-Footnote-Bold-fontSize); font-size: var(--typography-Footnote-Bold-fontSize);
font-weight: var(--typography-Footnote-Bold-fontWeight); font-weight: 500; /* var(--typography-Footnote-Bold-fontWeight); */
letter-spacing: var(--typography-Footnote-Bold-letterSpacing); letter-spacing: var(--typography-Footnote-Bold-letterSpacing);
line-height: var(--typography-Footnote-Bold-lineHeight); line-height: var(--typography-Footnote-Bold-lineHeight);
} }
@@ -128,6 +128,15 @@
color: #000; color: #000;
} }
.uiTextPlaceholder {
color: var(--Base-Text-Placeholder);
}
.uiTextPlaceholder:hover,
.uiTextPlaceholder:active {
color: var(--Base-Text-Medium-contrast);
}
.burgundy { .burgundy {
color: var(--Base-Text-High-contrast); color: var(--Base-Text-High-contrast);
} }
@@ -211,6 +220,14 @@
line-height: var(--typography-Caption-Regular-lineHeight); line-height: var(--typography-Caption-Regular-lineHeight);
} }
.tiny {
font-family: var(--typography-Footnote-Regular-fontFamily);
font-size: var(--typography-Footnote-Regular-fontSize);
font-weight: var(--typography-Footnote-Regular-fontWeight);
letter-spacing: var(--typography-Footnote-Regular-letterSpacing);
line-height: var(--typography-Footnote-Regular-lineHeight);
}
.activeSmall { .activeSmall {
font-family: var(--typography-Caption-Bold-fontFamily); font-family: var(--typography-Caption-Bold-fontFamily);
font-size: var(--typography-Caption-Bold-fontSize); font-size: var(--typography-Caption-Bold-fontSize);

View File

@@ -17,10 +17,12 @@ export const linkVariants = cva(styles.link, {
peach80: styles.peach80, peach80: styles.peach80,
white: styles.white, white: styles.white,
red: styles.red, red: styles.red,
uiTextPlaceholder: styles.uiTextPlaceholder,
}, },
size: { size: {
small: styles.small, small: styles.small,
regular: styles.regular, regular: styles.regular,
tiny: styles.tiny,
}, },
textDecoration: { textDecoration: {
none: styles.noDecoration, none: styles.noDecoration,

View File

@@ -28,6 +28,7 @@ export function Tooltip<P extends TooltipPosition>({
role="tooltip" role="tooltip"
aria-label={text} aria-label={text}
onClick={handleToggle} onClick={handleToggle}
onTouchStart={handleToggle}
data-active={isActive} data-active={isActive}
> >
<div className={className}> <div className={className}>

View File

@@ -16,6 +16,7 @@
transition: opacity 0.3s; transition: opacity 0.3s;
max-width: 200px; max-width: 200px;
min-width: 150px; min-width: 150px;
height: fit-content;
} }
.tooltipContainer:hover .tooltip { .tooltipContainer:hover .tooltip {

View File

@@ -48,7 +48,7 @@ export function selectHotel(lang) {
* @param {Lang} lang * @param {Lang} lang
*/ */
export function selectHotelMap(lang) { export function selectHotelMap(lang) {
return `${base(lang)}/map` return `${base(lang)}/select-hotel/map`
} }
/** /**

View File

@@ -47,8 +47,10 @@
"Booking number": "Bookingnummer", "Booking number": "Bookingnummer",
"Breakfast": "Morgenmad", "Breakfast": "Morgenmad",
"Breakfast buffet": "Morgenbuffet", "Breakfast buffet": "Morgenbuffet",
"Breakfast deal can be purchased at the hotel.": "Morgenmad kan købes på hotellet.",
"Breakfast excluded": "Morgenmad ikke inkluderet", "Breakfast excluded": "Morgenmad ikke inkluderet",
"Breakfast included": "Morgenmad inkluderet", "Breakfast included": "Morgenmad inkluderet",
"Breakfast is included.": "Morgenmad er inkluderet.",
"Breakfast restaurant": "Breakfast restaurant", "Breakfast restaurant": "Breakfast restaurant",
"Breakfast selection in next step.": "Valg af morgenmad i næste trin.", "Breakfast selection in next step.": "Valg af morgenmad i næste trin.",
"Bus terminal": "Busstation", "Bus terminal": "Busstation",
@@ -152,6 +154,7 @@
"Hotel": "Hotel", "Hotel": "Hotel",
"Hotel facilities": "Hotel faciliteter", "Hotel facilities": "Hotel faciliteter",
"Hotel surroundings": "Hotel omgivelser", "Hotel surroundings": "Hotel omgivelser",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hoteller}}",
"Hotels": "Hoteller", "Hotels": "Hoteller",
"How do you want to sleep?": "Hvordan vil du sove?", "How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det virker", "How it works": "Hvordan det virker",
@@ -236,6 +239,7 @@
"Number of parking spots": "Antal parkeringspladser", "Number of parking spots": "Antal parkeringspladser",
"OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER", "OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER",
"On your journey": "På din rejse", "On your journey": "På din rejse",
"Only pay {amount} {currency}": "Betal kun {amount} {currency}",
"Open": "Åben", "Open": "Åben",
"Open gift(s)": "Åbne {amount, plural, one {gave} other {gaver}}", "Open gift(s)": "Åbne {amount, plural, one {gave} other {gaver}}",
"Open image gallery": "Åbn billedgalleri", "Open image gallery": "Åbn billedgalleri",
@@ -333,6 +337,7 @@
"Show wellness & exercise": "Vis velvære og motion", "Show wellness & exercise": "Vis velvære og motion",
"Sign up bonus": "Velkomstbonus", "Sign up bonus": "Velkomstbonus",
"Sign up to Scandic Friends": "Tilmeld dig Scandic Friends", "Sign up to Scandic Friends": "Tilmeld dig Scandic Friends",
"Signing up...": "Tilmelder...",
"Skip to main content": "Spring over og gå til hovedindhold", "Skip to main content": "Spring over og gå til hovedindhold",
"Something went wrong and we couldn't add your card. Please try again later.": "Noget gik galt, og vi kunne ikke tilføje dit kort. Prøv venligst igen senere.", "Something went wrong and we couldn't add your card. Please try again later.": "Noget gik galt, og vi kunne ikke tilføje dit kort. Prøv venligst igen senere.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Noget gik galt, og vi kunne ikke fjerne dit kort. Prøv venligst igen senere.", "Something went wrong and we couldn't remove your card. Please try again later.": "Noget gik galt, og vi kunne ikke fjerne dit kort. Prøv venligst igen senere.",
@@ -456,6 +461,5 @@
"to": "til", "to": "til",
"uppercase letter": "stort bogstav", "uppercase letter": "stort bogstav",
"{amount} out of {total}": "{amount} ud af {total}", "{amount} out of {total}": "{amount} ud af {total}",
"{amount} {currency}": "{amount} {currency}", "{amount} {currency}": "{amount} {currency}"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
} }

View File

@@ -47,8 +47,10 @@
"Booking number": "Buchungsnummer", "Booking number": "Buchungsnummer",
"Breakfast": "Frühstück", "Breakfast": "Frühstück",
"Breakfast buffet": "Frühstücksbuffet", "Breakfast buffet": "Frühstücksbuffet",
"Breakfast deal can be purchased at the hotel.": "Frühstücksangebot kann im Hotel gekauft werden.",
"Breakfast excluded": "Frühstück nicht inbegriffen", "Breakfast excluded": "Frühstück nicht inbegriffen",
"Breakfast included": "Frühstück inbegriffen", "Breakfast included": "Frühstück inbegriffen",
"Breakfast is included.": "Frühstück ist inbegriffen.",
"Breakfast restaurant": "Breakfast restaurant", "Breakfast restaurant": "Breakfast restaurant",
"Breakfast selection in next step.": "Frühstücksauswahl in nächsten Schritt.", "Breakfast selection in next step.": "Frühstücksauswahl in nächsten Schritt.",
"Bus terminal": "Busbahnhof", "Bus terminal": "Busbahnhof",
@@ -152,6 +154,7 @@
"Hotel": "Hotel", "Hotel": "Hotel",
"Hotel facilities": "Hotel-Infos", "Hotel facilities": "Hotel-Infos",
"Hotel surroundings": "Umgebung des Hotels", "Hotel surroundings": "Umgebung des Hotels",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hotels}}",
"Hotels": "Hotels", "Hotels": "Hotels",
"How do you want to sleep?": "Wie möchtest du schlafen?", "How do you want to sleep?": "Wie möchtest du schlafen?",
"How it works": "Wie es funktioniert", "How it works": "Wie es funktioniert",
@@ -234,6 +237,7 @@
"Number of parking spots": "Anzahl der Parkplätze", "Number of parking spots": "Anzahl der Parkplätze",
"OTHER PAYMENT METHODS": "ANDERE BEZAHLMETHODE", "OTHER PAYMENT METHODS": "ANDERE BEZAHLMETHODE",
"On your journey": "Auf deiner Reise", "On your journey": "Auf deiner Reise",
"Only pay {amount} {currency}": "Nur bezahlen {amount} {currency}",
"Open": "Offen", "Open": "Offen",
"Open gift(s)": "{amount, plural, one {Geschenk} other {Geschenke}} öffnen", "Open gift(s)": "{amount, plural, one {Geschenk} other {Geschenke}} öffnen",
"Open image gallery": "Bildergalerie öffnen", "Open image gallery": "Bildergalerie öffnen",
@@ -332,6 +336,7 @@
"Show wellness & exercise": "Zeige Wellness und Bewegung", "Show wellness & exercise": "Zeige Wellness und Bewegung",
"Sign up bonus": "Anmelde-Bonus", "Sign up bonus": "Anmelde-Bonus",
"Sign up to Scandic Friends": "Treten Sie Scandic Friends bei", "Sign up to Scandic Friends": "Treten Sie Scandic Friends bei",
"Signing up...": "Registrierung läuft...",
"Skip to main content": "Direkt zum Inhalt", "Skip to main content": "Direkt zum Inhalt",
"Something went wrong and we couldn't add your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht hinzufügen. Bitte versuchen Sie es später erneut.", "Something went wrong and we couldn't add your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht hinzufügen. Bitte versuchen Sie es später erneut.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht entfernen. Bitte versuchen Sie es später noch einmal.", "Something went wrong and we couldn't remove your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht entfernen. Bitte versuchen Sie es später noch einmal.",
@@ -454,6 +459,5 @@
"to": "zu", "to": "zu",
"uppercase letter": "großbuchstabe", "uppercase letter": "großbuchstabe",
"{amount} out of {total}": "{amount} von {total}", "{amount} out of {total}": "{amount} von {total}",
"{amount} {currency}": "{amount} {currency}", "{amount} {currency}": "{amount} {currency}"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
} }

View File

@@ -51,8 +51,10 @@
"Booking number": "Booking number", "Booking number": "Booking number",
"Breakfast": "Breakfast", "Breakfast": "Breakfast",
"Breakfast buffet": "Breakfast buffet", "Breakfast buffet": "Breakfast buffet",
"Breakfast deal can be purchased at the hotel.": "Breakfast deal can be purchased at the hotel.",
"Breakfast excluded": "Breakfast excluded", "Breakfast excluded": "Breakfast excluded",
"Breakfast included": "Breakfast included", "Breakfast included": "Breakfast included",
"Breakfast is included.": "Breakfast is included.",
"Breakfast restaurant": "Breakfast restaurant", "Breakfast restaurant": "Breakfast restaurant",
"Breakfast selection in next step.": "Breakfast selection in next step.", "Breakfast selection in next step.": "Breakfast selection in next step.",
"Bus terminal": "Bus terminal", "Bus terminal": "Bus terminal",
@@ -164,6 +166,7 @@
"Hotel": "Hotel", "Hotel": "Hotel",
"Hotel facilities": "Hotel facilities", "Hotel facilities": "Hotel facilities",
"Hotel surroundings": "Hotel surroundings", "Hotel surroundings": "Hotel surroundings",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hotels}}",
"Hotels": "Hotels", "Hotels": "Hotels",
"How do you want to sleep?": "How do you want to sleep?", "How do you want to sleep?": "How do you want to sleep?",
"How it works": "How it works", "How it works": "How it works",
@@ -253,6 +256,7 @@
"Number of parking spots": "Number of parking spots", "Number of parking spots": "Number of parking spots",
"OTHER PAYMENT METHODS": "OTHER PAYMENT METHODS", "OTHER PAYMENT METHODS": "OTHER PAYMENT METHODS",
"On your journey": "On your journey", "On your journey": "On your journey",
"Only pay {amount} {currency}": "Only pay {amount} {currency}",
"Open": "Open", "Open": "Open",
"Open gift(s)": "Open {amount, plural, one {gift} other {gifts}}", "Open gift(s)": "Open {amount, plural, one {gift} other {gifts}}",
"Open image gallery": "Open image gallery", "Open image gallery": "Open image gallery",
@@ -362,6 +366,7 @@
"Show wellness & exercise": "Show wellness & exercise", "Show wellness & exercise": "Show wellness & exercise",
"Sign up bonus": "Sign up bonus", "Sign up bonus": "Sign up bonus",
"Sign up to Scandic Friends": "Sign up to Scandic Friends", "Sign up to Scandic Friends": "Sign up to Scandic Friends",
"Signing up...": "Signing up...",
"Skip to main content": "Skip to main content", "Skip to main content": "Skip to main content",
"Something went wrong and we couldn't add your card. Please try again later.": "Something went wrong and we couldn't add your card. Please try again later.", "Something went wrong and we couldn't add your card. Please try again later.": "Something went wrong and we couldn't add your card. Please try again later.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Something went wrong and we couldn't remove your card. Please try again later.", "Something went wrong and we couldn't remove your card. Please try again later.": "Something went wrong and we couldn't remove your card. Please try again later.",
@@ -425,7 +430,6 @@
"Which room class suits you the best?": "Which room class suits you the best?", "Which room class suits you the best?": "Which room class suits you the best?",
"Year": "Year", "Year": "Year",
"Yes": "Yes", "Yes": "Yes",
"Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with": "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with",
"Yes, discard changes": "Yes, discard changes", "Yes, discard changes": "Yes, discard changes",
"Yes, remove my card": "Yes, remove my card", "Yes, remove my card": "Yes, remove my card",
"You can always change your mind later and add breakfast at the hotel.": "You can always change your mind later and add breakfast at the hotel.", "You can always change your mind later and add breakfast at the hotel.": "You can always change your mind later and add breakfast at the hotel.",
@@ -488,12 +492,12 @@
"points": "Points", "points": "Points",
"room type": "room type", "room type": "room type",
"room types": "room types", "room types": "room types",
"signup.terms": "By signing up you accept the Scandic Friends <termsLink>Terms and Conditions</termsLink>. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandics customer service",
"special character": "special character", "special character": "special character",
"spendable points expiring by": "{points} spendable points expiring by {date}", "spendable points expiring by": "{points} spendable points expiring by {date}",
"to": "to", "to": "to",
"uppercase letter": "uppercase letter", "uppercase letter": "uppercase letter",
"{amount} out of {total}": "{amount} out of {total}", "{amount} out of {total}": "{amount} out of {total}",
"{amount} {currency}": "{amount} {currency}", "{amount} {currency}": "{amount} {currency}",
"{card} ending with {cardno}": "{card} ending with {cardno}", "{card} ending with {cardno}": "{card} ending with {cardno}"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
} }

View File

@@ -47,8 +47,10 @@
"Booking number": "Varausnumero", "Booking number": "Varausnumero",
"Breakfast": "Aamiainen", "Breakfast": "Aamiainen",
"Breakfast buffet": "Aamiaisbuffet", "Breakfast buffet": "Aamiaisbuffet",
"Breakfast deal can be purchased at the hotel.": "Aamiaisdeali voidaan ostaa hotellissa.",
"Breakfast excluded": "Aamiainen ei sisälly", "Breakfast excluded": "Aamiainen ei sisälly",
"Breakfast included": "Aamiainen sisältyy", "Breakfast included": "Aamiainen sisältyy",
"Breakfast is included.": "Aamiainen sisältyy.",
"Breakfast restaurant": "Breakfast restaurant", "Breakfast restaurant": "Breakfast restaurant",
"Breakfast selection in next step.": "Aamiaisvalinta seuraavassa vaiheessa.", "Breakfast selection in next step.": "Aamiaisvalinta seuraavassa vaiheessa.",
"Bus terminal": "Bussiasema", "Bus terminal": "Bussiasema",
@@ -152,6 +154,7 @@
"Hotel": "Hotelli", "Hotel": "Hotelli",
"Hotel facilities": "Hotellin palvelut", "Hotel facilities": "Hotellin palvelut",
"Hotel surroundings": "Hotellin ympäristö", "Hotel surroundings": "Hotellin ympäristö",
"Hotel(s)": "{amount} {amount, plural, one {hotelli} other {hotellit}}",
"Hotels": "Hotellit", "Hotels": "Hotellit",
"How do you want to sleep?": "Kuinka haluat nukkua?", "How do you want to sleep?": "Kuinka haluat nukkua?",
"How it works": "Kuinka se toimii", "How it works": "Kuinka se toimii",
@@ -236,6 +239,7 @@
"Number of parking spots": "Pysäköintipaikkojen määrä", "Number of parking spots": "Pysäköintipaikkojen määrä",
"OTHER PAYMENT METHODS": "MUISE KORT", "OTHER PAYMENT METHODS": "MUISE KORT",
"On your journey": "Matkallasi", "On your journey": "Matkallasi",
"Only pay {amount} {currency}": "Vain maksaa {amount} {currency}",
"Open": "Avata", "Open": "Avata",
"Open gift(s)": "{amount, plural, one {Avoin lahja} other {Avoimet lahjat}}", "Open gift(s)": "{amount, plural, one {Avoin lahja} other {Avoimet lahjat}}",
"Open image gallery": "Avaa kuvagalleria", "Open image gallery": "Avaa kuvagalleria",
@@ -334,6 +338,7 @@
"Show wellness & exercise": "Näytä hyvinvointi ja liikunta", "Show wellness & exercise": "Näytä hyvinvointi ja liikunta",
"Sign up bonus": "Liittymisbonus", "Sign up bonus": "Liittymisbonus",
"Sign up to Scandic Friends": "Liity Scandic Friends -jäseneksi", "Sign up to Scandic Friends": "Liity Scandic Friends -jäseneksi",
"Signing up...": "Rekisteröidytään...",
"Skip to main content": "Siirry pääsisältöön", "Skip to main content": "Siirry pääsisältöön",
"Something went wrong and we couldn't add your card. Please try again later.": "Jotain meni pieleen, emmekä voineet lisätä korttiasi. Yritä myöhemmin uudelleen.", "Something went wrong and we couldn't add your card. Please try again later.": "Jotain meni pieleen, emmekä voineet lisätä korttiasi. Yritä myöhemmin uudelleen.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Jotain meni pieleen, emmekä voineet poistaa korttiasi. Yritä myöhemmin uudelleen.", "Something went wrong and we couldn't remove your card. Please try again later.": "Jotain meni pieleen, emmekä voineet poistaa korttiasi. Yritä myöhemmin uudelleen.",
@@ -454,6 +459,5 @@
"to": "to", "to": "to",
"uppercase letter": "iso kirjain", "uppercase letter": "iso kirjain",
"{amount} out of {total}": "{amount}/{total}", "{amount} out of {total}": "{amount}/{total}",
"{amount} {currency}": "{amount} {currency}", "{amount} {currency}": "{amount} {currency}"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
} }

View File

@@ -47,8 +47,10 @@
"Booking number": "Bestillingsnummer", "Booking number": "Bestillingsnummer",
"Breakfast": "Frokost", "Breakfast": "Frokost",
"Breakfast buffet": "Breakfast buffet", "Breakfast buffet": "Breakfast buffet",
"Breakfast deal can be purchased at the hotel.": "Frokostdeal kan kjøpes på hotellet.",
"Breakfast excluded": "Frokost ekskludert", "Breakfast excluded": "Frokost ekskludert",
"Breakfast included": "Frokost inkludert", "Breakfast included": "Frokost inkludert",
"Breakfast is included.": "Frokost er inkludert.",
"Breakfast restaurant": "Breakfast restaurant", "Breakfast restaurant": "Breakfast restaurant",
"Breakfast selection in next step.": "Frokostvalg i neste steg.", "Breakfast selection in next step.": "Frokostvalg i neste steg.",
"Bus terminal": "Bussterminal", "Bus terminal": "Bussterminal",
@@ -151,6 +153,7 @@
"Hotel": "Hotel", "Hotel": "Hotel",
"Hotel facilities": "Hotelfaciliteter", "Hotel facilities": "Hotelfaciliteter",
"Hotel surroundings": "Hotellomgivelser", "Hotel surroundings": "Hotellomgivelser",
"Hotel(s)": "{amount} {amount, plural, one {hotell} other {hoteller}}",
"Hotels": "Hoteller", "Hotels": "Hoteller",
"How do you want to sleep?": "Hvordan vil du sove?", "How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det fungerer", "How it works": "Hvordan det fungerer",
@@ -234,6 +237,7 @@
"Number of parking spots": "Antall parkeringsplasser", "Number of parking spots": "Antall parkeringsplasser",
"OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER", "OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER",
"On your journey": "På reisen din", "On your journey": "På reisen din",
"Only pay {amount} {currency}": "Bare betal {amount} {currency}",
"Open": "Åpen", "Open": "Åpen",
"Open gift(s)": "{amount, plural, one {Åpen gave} other {Åpnen gaver}}", "Open gift(s)": "{amount, plural, one {Åpen gave} other {Åpnen gaver}}",
"Open image gallery": "Åpne bildegalleri", "Open image gallery": "Åpne bildegalleri",
@@ -331,6 +335,7 @@
"Show wellness & exercise": "Vis velvære og trening", "Show wellness & exercise": "Vis velvære og trening",
"Sign up bonus": "Velkomstbonus", "Sign up bonus": "Velkomstbonus",
"Sign up to Scandic Friends": "Bli med i Scandic Friends", "Sign up to Scandic Friends": "Bli med i Scandic Friends",
"Signing up...": "Registrerer...",
"Skip to main content": "Gå videre til hovedsiden", "Skip to main content": "Gå videre til hovedsiden",
"Something went wrong and we couldn't add your card. Please try again later.": "Noe gikk galt, og vi kunne ikke legge til kortet ditt. Prøv igjen senere.", "Something went wrong and we couldn't add your card. Please try again later.": "Noe gikk galt, og vi kunne ikke legge til kortet ditt. Prøv igjen senere.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Noe gikk galt, og vi kunne ikke fjerne kortet ditt. Vennligst prøv igjen senere.", "Something went wrong and we couldn't remove your card. Please try again later.": "Noe gikk galt, og vi kunne ikke fjerne kortet ditt. Vennligst prøv igjen senere.",
@@ -452,6 +457,5 @@
"to": "til", "to": "til",
"uppercase letter": "stor bokstav", "uppercase letter": "stor bokstav",
"{amount} out of {total}": "{amount} av {total}", "{amount} out of {total}": "{amount} av {total}",
"{amount} {currency}": "{amount} {currency}", "{amount} {currency}": "{amount} {currency}"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
} }

View File

@@ -47,8 +47,10 @@
"Booking number": "Bokningsnummer", "Booking number": "Bokningsnummer",
"Breakfast": "Frukost", "Breakfast": "Frukost",
"Breakfast buffet": "Frukostbuffé", "Breakfast buffet": "Frukostbuffé",
"Breakfast deal can be purchased at the hotel.": "Frukostdeal kan köpas på hotellet.",
"Breakfast excluded": "Frukost ingår ej", "Breakfast excluded": "Frukost ingår ej",
"Breakfast included": "Frukost ingår", "Breakfast included": "Frukost ingår",
"Breakfast is included.": "Frukost ingår.",
"Breakfast restaurant": "Breakfast restaurant", "Breakfast restaurant": "Breakfast restaurant",
"Breakfast selection in next step.": "Frukostval i nästa steg.", "Breakfast selection in next step.": "Frukostval i nästa steg.",
"Bus terminal": "Bussterminal", "Bus terminal": "Bussterminal",
@@ -151,6 +153,7 @@
"Hotel": "Hotell", "Hotel": "Hotell",
"Hotel facilities": "Hotellfaciliteter", "Hotel facilities": "Hotellfaciliteter",
"Hotel surroundings": "Hotellomgivning", "Hotel surroundings": "Hotellomgivning",
"Hotel(s)": "{amount} hotell",
"Hotels": "Hotell", "Hotels": "Hotell",
"How do you want to sleep?": "Hur vill du sova?", "How do you want to sleep?": "Hur vill du sova?",
"How it works": "Hur det fungerar", "How it works": "Hur det fungerar",
@@ -234,6 +237,7 @@
"Number of parking spots": "Antal parkeringsplatser", "Number of parking spots": "Antal parkeringsplatser",
"OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER", "OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER",
"On your journey": "På din resa", "On your journey": "På din resa",
"Only pay {amount} {currency}": "Betala endast {amount} {currency}",
"Open": "Öppna", "Open": "Öppna",
"Open gift(s)": "Öppna {amount, plural, one {gåva} other {gåvor}}", "Open gift(s)": "Öppna {amount, plural, one {gåva} other {gåvor}}",
"Open image gallery": "Öppna bildgalleri", "Open image gallery": "Öppna bildgalleri",
@@ -331,6 +335,7 @@
"Show wellness & exercise": "Visa välbefinnande och träning", "Show wellness & exercise": "Visa välbefinnande och träning",
"Sign up bonus": "Välkomstbonus", "Sign up bonus": "Välkomstbonus",
"Sign up to Scandic Friends": "Bli medlem i Scandic Friends", "Sign up to Scandic Friends": "Bli medlem i Scandic Friends",
"Signing up...": "Registrerar...",
"Skip to main content": "Fortsätt till huvudinnehåll", "Skip to main content": "Fortsätt till huvudinnehåll",
"Something went wrong and we couldn't add your card. Please try again later.": "Något gick fel och vi kunde inte lägga till ditt kort. Försök igen senare.", "Something went wrong and we couldn't add your card. Please try again later.": "Något gick fel och vi kunde inte lägga till ditt kort. Försök igen senare.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Något gick fel och vi kunde inte ta bort ditt kort. Försök igen senare.", "Something went wrong and we couldn't remove your card. Please try again later.": "Något gick fel och vi kunde inte ta bort ditt kort. Försök igen senare.",
@@ -455,6 +460,5 @@
"types": "typer", "types": "typer",
"uppercase letter": "stor bokstav", "uppercase letter": "stor bokstav",
"{amount} out of {total}": "{amount} av {total}", "{amount} out of {total}": "{amount} av {total}",
"{amount} {currency}": "{amount} {currency}", "{amount} {currency}": "{amount} {currency}"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
} }

2
next-env.d.ts vendored
View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

88
package-lock.json generated
View File

@@ -41,7 +41,7 @@
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"immer": "10.1.1", "immer": "10.1.1",
"libphonenumber-js": "^1.10.60", "libphonenumber-js": "^1.10.60",
"next": "^14.2.7", "next": "^14.2.18",
"next-auth": "^5.0.0-beta.19", "next-auth": "^5.0.0-beta.19",
"react": "^18", "react": "^18",
"react-day-picker": "^9.0.8", "react-day-picker": "^9.0.8",
@@ -3425,9 +3425,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "14.2.7", "version": "14.2.18",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.7.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.18.tgz",
"integrity": "sha512-OTx9y6I3xE/eih+qtthppwLytmpJVPM5PPoJxChFsbjIEFXIayG0h/xLzefHGJviAa3Q5+Fd+9uYojKkHDKxoQ==", "integrity": "sha512-2vWLOUwIPgoqMJKG6dt35fVXVhgM09tw4tK3/Q34GFXDrfiHlG7iS33VA4ggnjWxjiz9KV5xzfsQzJX6vGAekA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
@@ -3440,9 +3440,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "14.2.7", "version": "14.2.18",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.18.tgz",
"integrity": "sha512-UhZGcOyI9LE/tZL3h9rs/2wMZaaJKwnpAyegUVDGZqwsla6hMfeSj9ssBWQS9yA4UXun3pPhrFLVnw5KXZs3vw==", "integrity": "sha512-tOBlDHCjGdyLf0ube/rDUs6VtwNOajaWV+5FV/ajPgrvHeisllEdymY/oDgv2cx561+gJksfMUtqf8crug7sbA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3456,9 +3456,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "14.2.7", "version": "14.2.18",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.18.tgz",
"integrity": "sha512-ys2cUgZYRc+CbyDeLAaAdZgS7N1Kpyy+wo0b/gAj+SeOeaj0Lw/q+G1hp+DuDiDAVyxLBCJXEY/AkhDmtihUTA==", "integrity": "sha512-uJCEjutt5VeJ30jjrHV1VIHCsbMYnEqytQgvREx+DjURd/fmKy15NaVK4aR/u98S1LGTnjq35lRTnRyygglxoA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3472,9 +3472,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.7", "version": "14.2.18",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.18.tgz",
"integrity": "sha512-2xoWtE13sUJ3qrC1lwE/HjbDPm+kBQYFkkiVECJWctRASAHQ+NwjMzgrfqqMYHfMxFb5Wws3w9PqzZJqKFdWcQ==", "integrity": "sha512-IL6rU8vnBB+BAm6YSWZewc+qvdL1EaA+VhLQ6tlUc0xp+kkdxQrVqAnh8Zek1ccKHlTDFRyAft0e60gteYmQ4A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3488,9 +3488,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.7", "version": "14.2.18",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.18.tgz",
"integrity": "sha512-+zJ1gJdl35BSAGpkCbfyiY6iRTaPrt3KTl4SF/B1NyELkqqnrNX6cp4IjjjxKpd64/7enI0kf6b9O1Uf3cL0pw==", "integrity": "sha512-RCaENbIZqKKqTlL8KNd+AZV/yAdCsovblOpYFp0OJ7ZxgLNbV5w23CUU1G5On+0fgafrsGcW+GdMKdFjaRwyYA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3504,9 +3504,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.7", "version": "14.2.18",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.18.tgz",
"integrity": "sha512-m6EBqrskeMUzykBrv0fDX/28lWIBGhMzOYaStp0ihkjzIYJiKUOzVYD1gULHc8XDf5EMSqoH/0/TRAgXqpQwmw==", "integrity": "sha512-3kmv8DlyhPRCEBM1Vavn8NjyXtMeQ49ID0Olr/Sut7pgzaQTo4h01S7Z8YNE0VtbowyuAL26ibcz0ka6xCTH5g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3520,9 +3520,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.7", "version": "14.2.18",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.18.tgz",
"integrity": "sha512-gUu0viOMvMlzFRz1r1eQ7Ql4OE+hPOmA7smfZAhn8vC4+0swMZaZxa9CSIozTYavi+bJNDZ3tgiSdMjmMzRJlQ==", "integrity": "sha512-mliTfa8seVSpTbVEcKEXGjC18+TDII8ykW4a36au97spm9XMPqQTpdGPNBJ9RySSFw9/hLuaCMByluQIAnkzlw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3536,9 +3536,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.7", "version": "14.2.18",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.18.tgz",
"integrity": "sha512-PGbONHIVIuzWlYmLvuFKcj+8jXnLbx4WrlESYlVnEzDsa3+Q2hI1YHoXaSmbq0k4ZwZ7J6sWNV4UZfx1OeOlbQ==", "integrity": "sha512-J5g0UFPbAjKYmqS3Cy7l2fetFmWMY9Oao32eUsBPYohts26BdrMUyfCJnZFQkX9npYaHNDOWqZ6uV9hSDPw9NA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3552,9 +3552,9 @@
} }
}, },
"node_modules/@next/swc-win32-ia32-msvc": { "node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.7", "version": "14.2.18",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.18.tgz",
"integrity": "sha512-BiSY5umlx9ed5RQDoHcdbuKTUkuFORDqzYKPHlLeS+STUWQKWziVOn3Ic41LuTBvqE0TRJPKpio9GSIblNR+0w==", "integrity": "sha512-Ynxuk4ZgIpdcN7d16ivJdjsDG1+3hTvK24Pp8DiDmIa2+A4CfhJSEHHVndCHok6rnLUzAZD+/UOKESQgTsAZGg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -3568,9 +3568,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.7", "version": "14.2.18",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.18.tgz",
"integrity": "sha512-pxsI23gKWRt/SPHFkDEsP+w+Nd7gK37Hpv0ngc5HpWy2e7cKx9zR/+Q2ptAUqICNTecAaGWvmhway7pj/JLEWA==", "integrity": "sha512-dtRGMhiU9TN5nyhwzce+7c/4CCeykYS+ipY/4mIrGzJ71+7zNo55ZxCB7cAVuNqdwtYniFNR2c9OFQ6UdFIMcg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -15460,12 +15460,12 @@
} }
}, },
"node_modules/next": { "node_modules/next": {
"version": "14.2.7", "version": "14.2.18",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.7.tgz", "resolved": "https://registry.npmjs.org/next/-/next-14.2.18.tgz",
"integrity": "sha512-4Qy2aK0LwH4eQiSvQWyKuC7JXE13bIopEQesWE0c/P3uuNRnZCQanI0vsrMLmUQJLAto+A+/8+sve2hd+BQuOQ==", "integrity": "sha512-H9qbjDuGivUDEnK6wa+p2XKO+iMzgVgyr9Zp/4Iv29lKa+DYaxJGjOeEA+5VOvJh/M7HLiskehInSa0cWxVXUw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "14.2.7", "@next/env": "14.2.18",
"@swc/helpers": "0.5.5", "@swc/helpers": "0.5.5",
"busboy": "1.6.0", "busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
@@ -15480,15 +15480,15 @@
"node": ">=18.17.0" "node": ">=18.17.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.7", "@next/swc-darwin-arm64": "14.2.18",
"@next/swc-darwin-x64": "14.2.7", "@next/swc-darwin-x64": "14.2.18",
"@next/swc-linux-arm64-gnu": "14.2.7", "@next/swc-linux-arm64-gnu": "14.2.18",
"@next/swc-linux-arm64-musl": "14.2.7", "@next/swc-linux-arm64-musl": "14.2.18",
"@next/swc-linux-x64-gnu": "14.2.7", "@next/swc-linux-x64-gnu": "14.2.18",
"@next/swc-linux-x64-musl": "14.2.7", "@next/swc-linux-x64-musl": "14.2.18",
"@next/swc-win32-arm64-msvc": "14.2.7", "@next/swc-win32-arm64-msvc": "14.2.18",
"@next/swc-win32-ia32-msvc": "14.2.7", "@next/swc-win32-ia32-msvc": "14.2.18",
"@next/swc-win32-x64-msvc": "14.2.7" "@next/swc-win32-x64-msvc": "14.2.18"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",

View File

@@ -56,7 +56,7 @@
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"immer": "10.1.1", "immer": "10.1.1",
"libphonenumber-js": "^1.10.60", "libphonenumber-js": "^1.10.60",
"next": "^14.2.7", "next": "^14.2.18",
"next-auth": "^5.0.0-beta.19", "next-auth": "^5.0.0-beta.19",
"react": "^18", "react": "^18",
"react-day-picker": "^9.0.8", "react-day-picker": "^9.0.8",

View File

@@ -1,4 +1,5 @@
"use client" "use client"
import { useRouter } from "next/navigation"
import { useRef } from "react" import { useRef } from "react"
import { useDetailsStore } from "@/stores/details" import { useDetailsStore } from "@/stores/details"
@@ -14,6 +15,7 @@ export default function StepsProvider({
breakfastPackages, breakfastPackages,
children, children,
isMember, isMember,
searchParams,
step, step,
}: StepsProviderProps) { }: StepsProviderProps) {
const storeRef = useRef<StepsStore>() const storeRef = useRef<StepsStore>()
@@ -21,6 +23,7 @@ export default function StepsProvider({
const updateBreakfast = useDetailsStore( const updateBreakfast = useDetailsStore(
(state) => state.actions.updateBreakfast (state) => state.actions.updateBreakfast
) )
const router = useRouter()
if (!storeRef.current) { if (!storeRef.current) {
const noBedChoices = bedTypes.length === 1 const noBedChoices = bedTypes.length === 1
@@ -41,7 +44,9 @@ export default function StepsProvider({
step, step,
isMember, isMember,
noBedChoices, noBedChoices,
noBreakfast noBreakfast,
searchParams,
router.push
) )
} }

View File

@@ -13,7 +13,6 @@ import { AlertTypeEnum } from "@/types/enums/alert"
import { CurrencyEnum } from "@/types/enums/currency" import { CurrencyEnum } from "@/types/enums/currency"
import { FacilityEnum } from "@/types/enums/facilities" import { FacilityEnum } from "@/types/enums/facilities"
import { PackageTypeEnum } from "@/types/enums/packages" import { PackageTypeEnum } from "@/types/enums/packages"
import { PointOfInterestCategoryNameEnum } from "@/types/hotel"
const ratingsSchema = z const ratingsSchema = z
.object({ .object({
@@ -199,14 +198,12 @@ const rewardNightSchema = z.object({
}), }),
}) })
const poiCategoryNames = z.nativeEnum(PointOfInterestCategoryNameEnum)
export const pointOfInterestSchema = z export const pointOfInterestSchema = z
.object({ .object({
name: z.string(), name: z.string(),
distance: z.number(), distance: z.number(),
category: z.object({ category: z.object({
name: poiCategoryNames, name: z.string(),
group: z.string(), group: z.string(),
}), }),
location: locationSchema, location: locationSchema,
@@ -515,7 +512,16 @@ export const productTypePriceSchema = z.object({
const productSchema = z.object({ const productSchema = z.object({
productType: z.object({ productType: z.object({
public: productTypePriceSchema, public: productTypePriceSchema.default({
rateCode: "",
rateType: "",
localPrice: {
currency: "SEK",
pricePerNight: 0,
pricePerStay: 0,
},
requestedPrice: undefined,
}),
member: productTypePriceSchema.optional(), member: productTypePriceSchema.optional(),
}), }),
}) })

View File

@@ -731,7 +731,7 @@ export const hotelQueryRouter = router({
const rateTypes = selectedRoom.products.find( const rateTypes = selectedRoom.products.find(
(rate) => (rate) =>
rate.productType.public.rateCode === rateCode || rate.productType.public?.rateCode === rateCode ||
rate.productType.member?.rateCode === rateCode rate.productType.member?.rateCode === rateCode
) )

View File

@@ -12,39 +12,34 @@ import {
type Countries, type Countries,
} from "./output" } from "./output"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import {
PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum,
} from "@/types/hotel"
import { HotelLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints" import type { Endpoint } from "@/lib/api/endpoints"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import { PointOfInterestGroupEnum } from "@/types/hotel"
import { HotelLocation } from "@/types/trpc/routers/hotel/locations"
export function getPoiGroupByCategoryName( export function getPoiGroupByCategoryName(category: string) {
category: PointOfInterestCategoryNameEnum
) {
switch (category) { switch (category) {
case PointOfInterestCategoryNameEnum.AIRPORT: case "Airport":
case PointOfInterestCategoryNameEnum.BUS_TERMINAL: case "Bus terminal":
case PointOfInterestCategoryNameEnum.TRANSPORTATIONS: case "Transportations":
return PointOfInterestGroupEnum.PUBLIC_TRANSPORT return PointOfInterestGroupEnum.PUBLIC_TRANSPORT
case PointOfInterestCategoryNameEnum.AMUSEMENT_PARK: case "Amusement park":
case PointOfInterestCategoryNameEnum.MUSEUM: case "Museum":
case PointOfInterestCategoryNameEnum.SPORTS: case "Sports":
case PointOfInterestCategoryNameEnum.THEATRE: case "Theatre":
case PointOfInterestCategoryNameEnum.TOURIST: case "Tourist":
case PointOfInterestCategoryNameEnum.ZOO: case "Zoo":
return PointOfInterestGroupEnum.ATTRACTIONS return PointOfInterestGroupEnum.ATTRACTIONS
case PointOfInterestCategoryNameEnum.NEARBY_COMPANIES: case "Nearby companies":
case PointOfInterestCategoryNameEnum.FAIR: case "Fair":
return PointOfInterestGroupEnum.BUSINESS return PointOfInterestGroupEnum.BUSINESS
case PointOfInterestCategoryNameEnum.PARKING_GARAGE: case "Parking / Garage":
return PointOfInterestGroupEnum.PARKING return PointOfInterestGroupEnum.PARKING
case PointOfInterestCategoryNameEnum.SHOPPING: case "Shopping":
case PointOfInterestCategoryNameEnum.RESTAURANT: case "Restaurant":
return PointOfInterestGroupEnum.SHOPPING_DINING return PointOfInterestGroupEnum.SHOPPING_DINING
case PointOfInterestCategoryNameEnum.HOSPITAL: case "Hospital":
default: default:
return PointOfInterestGroupEnum.LOCATION return PointOfInterestGroupEnum.LOCATION
} }

View File

@@ -1,5 +1,9 @@
import { z } from "zod" import { z } from "zod"
import { Lang } from "@/constants/languages"
import { signUpSchema } from "@/components/Forms/Signup/schema"
// Query // Query
export const staysInput = z export const staysInput = z
.object({ .object({
@@ -35,3 +39,19 @@ export const saveCreditCardInput = z.object({
transactionId: z.string(), transactionId: z.string(),
merchantId: z.string().optional(), merchantId: z.string().optional(),
}) })
export const signupInput = signUpSchema
.extend({
language: z.nativeEnum(Lang),
})
.omit({ termsAccepted: true })
.transform((data) => ({
...data,
phoneNumber: data.phoneNumber.replace(/\s+/g, ""),
address: {
...data.address,
city: "",
country: "",
streetAddress: "",
},
}))

View File

@@ -1,17 +1,20 @@
import { metrics } from "@opentelemetry/api" import { metrics } from "@opentelemetry/api"
import { signupVerify } from "@/constants/routes/signup"
import { env } from "@/env/server" import { env } from "@/env/server"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { serverErrorByStatus } from "@/server/errors/trpc"
import { import {
initiateSaveCardSchema, initiateSaveCardSchema,
subscriberIdSchema, subscriberIdSchema,
} from "@/server/routers/user/output" } from "@/server/routers/user/output"
import { protectedProcedure, router } from "@/server/trpc" import { protectedProcedure, router, serviceProcedure } from "@/server/trpc"
import { import {
addCreditCardInput, addCreditCardInput,
deleteCreditCardInput, deleteCreditCardInput,
saveCreditCardInput, saveCreditCardInput,
signupInput,
} from "./input" } from "./input"
const meter = metrics.getMeter("trpc.user") const meter = metrics.getMeter("trpc.user")
@@ -24,6 +27,9 @@ const generatePreferencesLinkSuccessCounter = meter.createCounter(
const generatePreferencesLinkFailCounter = meter.createCounter( const generatePreferencesLinkFailCounter = meter.createCounter(
"trpc.user.generatePreferencesLink-fail" "trpc.user.generatePreferencesLink-fail"
) )
const signupCounter = meter.createCounter("trpc.user.signup")
const signupSuccessCounter = meter.createCounter("trpc.user.signup-success")
const signupFailCounter = meter.createCounter("trpc.user.signup-fail")
export const userMutationRouter = router({ export const userMutationRouter = router({
creditCard: router({ creditCard: router({
@@ -208,4 +214,46 @@ export const userMutationRouter = router({
generatePreferencesLinkSuccessCounter.add(1) generatePreferencesLinkSuccessCounter.add(1)
return preferencesLink.toString() return preferencesLink.toString()
}), }),
signup: serviceProcedure.input(signupInput).mutation(async function ({
ctx,
input,
}) {
signupCounter.add(1)
const apiResponse = await api.post(api.endpoints.v1.Profile.profile, {
body: input,
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
})
if (!apiResponse.ok) {
const text = await apiResponse.text()
signupFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
error: text,
}),
})
console.error(
"api.user.signup api error",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
error: text,
},
})
)
throw serverErrorByStatus(apiResponse.status, text)
}
signupSuccessCounter.add(1)
console.info("api.user.signup success")
return {
success: true,
redirectUrl: signupVerify[input.language],
}
}),
}) })

View File

@@ -1,6 +1,8 @@
import { z } from "zod" import { z } from "zod"
import { countriesMap } from "@/components/TempDesignSystem/Form/Country/countries" import { countriesMap } from "@/components/TempDesignSystem/Form/Country/countries"
import { passwordValidator } from "@/utils/passwordValidator"
import { phoneValidator } from "@/utils/phoneValidator"
import { getMembership } from "@/utils/user" import { getMembership } from "@/utils/user"
export const membershipSchema = z.object({ export const membershipSchema = z.object({

View File

@@ -121,7 +121,7 @@ export const safeProtectedProcedure = t.procedure.use(async function (opts) {
}) })
}) })
export const serviceProcedure = t.procedure.use(async (opts) => { export const serviceProcedure = t.procedure.use(async function (opts) {
const { access_token } = await getServiceToken() const { access_token } = await getServiceToken()
if (!access_token) { if (!access_token) {
throw internalServerError(`[serviceProcedure] No service token`) throw internalServerError(`[serviceProcedure] No service token`)

View File

@@ -94,7 +94,6 @@ export function createDetailsStore(
state.data.membershipNo = data.membershipNo state.data.membershipNo = data.membershipNo
} }
state.data.phoneNumber = data.phoneNumber state.data.phoneNumber = data.phoneNumber
state.data.termsAccepted = data.termsAccepted
state.data.zipCode = data.zipCode state.data.zipCode = data.zipCode
}) })
) )

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import merge from "deepmerge" import merge from "deepmerge"
import { produce } from "immer" import { produce } from "immer"
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
import { useContext } from "react" import { useContext } from "react"
import { create, useStore } from "zustand" import { create, useStore } from "zustand"
@@ -18,17 +19,13 @@ import { StepEnum } from "@/types/enums/step"
import type { DetailsState } from "@/types/stores/details" import type { DetailsState } from "@/types/stores/details"
import type { StepState } from "@/types/stores/steps" import type { StepState } from "@/types/stores/steps"
function push(data: Record<string, string>, url: string) {
if (typeof window !== "undefined") {
window.history.pushState(data, "", url + window.location.search)
}
}
export function createStepsStore( export function createStepsStore(
currentStep: StepEnum, currentStep: StepEnum,
isMember: boolean, isMember: boolean,
noBedChoices: boolean, noBedChoices: boolean,
noBreakfast: boolean noBreakfast: boolean,
searchParams: string,
push: AppRouterInstance["push"]
) { ) {
const isBrowser = typeof window !== "undefined" const isBrowser = typeof window !== "undefined"
const steps = [ const steps = [
@@ -51,14 +48,14 @@ export function createStepsStore(
steps.splice(1, 1) steps.splice(1, 1)
if (currentStep === StepEnum.breakfast) { if (currentStep === StepEnum.breakfast) {
currentStep = steps[1] currentStep = steps[1]
push({ step: currentStep }, currentStep) push(`${currentStep}?${searchParams}`)
} }
} }
if (noBedChoices) { if (noBedChoices) {
if (currentStep === StepEnum.selectBed) { if (currentStep === StepEnum.selectBed) {
currentStep = steps[1] currentStep = steps[1]
push({ step: currentStep }, currentStep) push(`${currentStep}?${searchParams}`)
} }
} }
@@ -94,7 +91,7 @@ export function createStepsStore(
if (!validPaths.includes(currentStep) && isBrowser) { if (!validPaths.includes(currentStep) && isBrowser) {
// We will always have at least one valid path // We will always have at least one valid path
currentStep = validPaths.pop()! currentStep = validPaths.pop()!
push({ step: currentStep }, currentStep) push(`${currentStep}?${searchParams}`)
} }
} }

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