Merge branch 'feat/sw-929-release-preps' of bitbucket.org:scandic-swap/web into feat/sw-929-release-preps

This commit is contained in:
Linus Flood
2024-11-20 13:24:06 +01:00
34 changed files with 339 additions and 185 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

@@ -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

@@ -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

@@ -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: {
@@ -56,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 (
@@ -79,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}>
@@ -186,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
@@ -194,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

@@ -94,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

@@ -1,7 +1,6 @@
.form { .form {
display: grid; display: grid;
gap: var(--Spacing-x3); gap: var(--Spacing-x3);
margin-bottom: var(--Spacing-x3);
} }
.container { .container {

View File

@@ -75,7 +75,11 @@ export default function SectionAccordion({
</div> </div>
</div> </div>
<header className={styles.header}> <header className={styles.header}>
<button onClick={onModify} className={styles.modifyButton}> <button
onClick={onModify}
disabled={!isComplete}
className={styles.modifyButton}
>
<Footnote <Footnote
className={styles.title} className={styles.title}
asChild asChild
@@ -94,7 +98,9 @@ export default function SectionAccordion({
)} )}
</button> </button>
</header> </header>
<div className={styles.content}>{children}</div> <div className={styles.content}>
<div className={styles.contentWrapper}>{children}</div>
</div>
</div> </div>
) )
} }

View File

@@ -33,6 +33,10 @@
padding: 0; padding: 0;
} }
.modifyButton:disabled {
cursor: default;
}
.title { .title {
grid-area: title; grid-area: title;
text-align: start; text-align: start;
@@ -79,7 +83,10 @@
.accordion[data-open="true"] { .accordion[data-open="true"] {
grid-template-rows: var(--header-height) 1fr; grid-template-rows: var(--header-height) 1fr;
gap: var(--Spacing-x3); }
.contentWrapper {
padding-bottom: var(--Spacing-x3);
} }
.content { .content {
@@ -90,7 +97,7 @@
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.accordion { .accordion {
gap: var(--Spacing-x3); column-gap: var(--Spacing-x3);
grid-template-areas: "circle header" "circle content"; grid-template-areas: "circle header" "circle content";
} }

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

@@ -13,6 +13,7 @@ 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"
@@ -25,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()
@@ -102,8 +104,7 @@ 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}

View File

@@ -41,4 +41,9 @@
.container { .container {
display: flex; display: flex;
} }
.filterContainer {
justify-content: flex-end;
padding: 0 0 var(--Spacing-x1);
}
} }

View File

@@ -60,7 +60,6 @@ export default function RoomCard({
const getBreakfastMessage = (rate: RateDefinition | undefined) => { const getBreakfastMessage = (rate: RateDefinition | undefined) => {
const breakfastIncluded = getRateDefinitionForRate(rate)?.breakfastIncluded const breakfastIncluded = getRateDefinitionForRate(rate)?.breakfastIncluded
switch (breakfastIncluded) { switch (breakfastIncluded) {
case true: case true:
return intl.formatMessage({ id: "Breakfast is included." }) return intl.formatMessage({ id: "Breakfast is included." })
@@ -83,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" })
@@ -115,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

View File

@@ -7,6 +7,7 @@
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;
} }

View File

@@ -66,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
@@ -77,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
@@ -88,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

@@ -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

@@ -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

@@ -154,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",
@@ -336,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.",

View File

@@ -154,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",
@@ -335,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.",

View File

@@ -166,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",
@@ -365,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.",

View File

@@ -154,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",
@@ -337,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.",

View File

@@ -153,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",
@@ -334,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.",

View File

@@ -153,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",
@@ -334,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.",

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

@@ -16,3 +16,7 @@ export type Filter = {
sortOrder: number sortOrder: number
filter?: string filter?: string
} }
export type HotelFilterModalProps = {
filters: CategorizedFilters
}

View File

@@ -6,7 +6,7 @@ import {
} from "@/server/routers/hotels/schemas/image" } from "@/server/routers/hotels/schemas/image"
import { HotelData } from "./hotelCardListingProps" import { HotelData } from "./hotelCardListingProps"
import { Filter } from "./hotelFilters" import { CategorizedFilters, Filter } from "./hotelFilters"
import type { Coordinates } from "@/types/components/maps/coordinates" import type { Coordinates } from "@/types/components/maps/coordinates"
@@ -21,6 +21,7 @@ export interface SelectHotelMapProps {
hotelPins: HotelPin[] hotelPins: HotelPin[]
mapId: string mapId: string
hotels: HotelData[] hotels: HotelData[]
filterList: CategorizedFilters
} }
type ImageSizes = z.infer<typeof imageSizesSchema> type ImageSizes = z.infer<typeof imageSizesSchema>