Merged master into fix/add-missing-cache

This commit is contained in:
Joakim Jäderberg
2024-11-20 12:52:37 +00:00
63 changed files with 422 additions and 247 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 { setLang } from "@/i18n/serverContext"
import { fetchAvailableHotels } from "../../utils"
import { fetchAvailableHotels, getFiltersFromHotels } from "../../utils"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { LangParams, PageArgs } from "@/types/params"
@@ -57,6 +57,7 @@ export default async function SelectHotelMapPage({
})
const hotelPins = getHotelPins(hotels)
const filterList = getFiltersFromHotels(hotels)
return (
<MapModal>
@@ -65,6 +66,7 @@ export default async function SelectHotelMapPage({
hotelPins={hotelPins}
mapId={googleMapId}
hotels={hotels}
filterList={filterList}
/>
</MapModal>
)

View File

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

View File

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

View File

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

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)
preload()
void preload()
return (
<Suspense>

View File

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

View File

@@ -1,12 +1,13 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { privacyPolicy } from "@/constants/currentWebHrefs"
import { trpc } from "@/lib/trpc/client"
import { registerUser } from "@/actions/registerUser"
import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
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) {
const intl = useIntl()
const router = useRouter()
const lang = useLang()
const country = intl.formatMessage({ id: "Country" })
const email = intl.formatMessage({ id: "Email address" })
const phoneNumber = intl.formatMessage({ id: "Phone number" })
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>({
defaultValues: {
@@ -56,19 +74,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
})
async function onSubmit(data: SignUpSchema) {
try {
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!" }))
}
signup.mutate({ ...data, language: lang })
}
return (
@@ -79,11 +85,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
className={styles.form}
id="register"
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}>
<div className={styles.container}>
@@ -186,7 +187,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
onClick={() => methods.trigger()}
data-testid="trigger-validation"
>
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
{signupButtonText}
</Button>
) : (
<Button
@@ -194,10 +195,12 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
type="submit"
theme="base"
intent="primary"
disabled={methods.formState.isSubmitting}
disabled={methods.formState.isSubmitting || signup.isPending}
data-testid="submit"
>
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
{methods.formState.isSubmitting || signup.isPending
? signingUpPendingText
: signupButtonText}
</Button>
)}
</form>

View File

@@ -94,7 +94,7 @@ export default function ChildInfoSelector({
updateSelectedAge(key as number)
}}
placeholder={ageLabel}
maxHeight={150}
maxHeight={180}
{...register(ageFieldName, {
required: true,
})}

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,6 +81,10 @@
flex: 0 0 auto;
}
.title {
display: none;
}
.close {
background: none;
border: none;
@@ -97,3 +101,80 @@
flex: 0 0 auto;
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 Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import HotelFilter from "../HotelFilter"
import HotelSorter from "../HotelSorter"
@@ -47,10 +49,18 @@ export default function FilterAndSortModal({
>
<CloseLargeIcon />
</button>
<Subtitle
type="two"
textAlign="center"
className={styles.title}
>
{intl.formatMessage({ id: "Filter and sort" })}
</Subtitle>
</header>
<div className={styles.sorter}>
<HotelSorter />
</div>
<Divider color="subtle" className="divider" />
<div className={styles.filters}>
<HotelFilter filters={filters} />
</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 useLang from "@/hooks/useLang"
import FilterAndSortModal from "../FilterAndSortModal"
import HotelListing from "./HotelListing"
import { getCentralCoordinates } from "./utils"
@@ -25,6 +26,7 @@ export default function SelectHotelMap({
hotelPins,
mapId,
hotels,
filterList,
}: SelectHotelMapProps) {
const searchParams = useSearchParams()
const router = useRouter()
@@ -102,8 +104,7 @@ export default function SelectHotelMap({
>
<CloseLargeIcon />
</Button>
<span>Filter and sort</span>
{/* TODO: Add filter and sort button */}
<FilterAndSortModal filters={filterList} />
</div>
<HotelListing
hotels={hotels}

View File

@@ -41,4 +41,9 @@
.container {
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 breakfastIncluded = getRateDefinitionForRate(rate)?.breakfastIncluded
switch (breakfastIncluded) {
case true:
return intl.formatMessage({ id: "Breakfast is included." })
@@ -83,7 +82,6 @@ export default function RoomCard({
)
const { roomSize, occupancy, images } = selectedRoom || {}
const mainImage = images?.[0]
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
@@ -115,53 +113,56 @@ export default function RoomCard({
return (
<div className={classNames}>
<div>
{mainImage && (
<div className={styles.imageContainer}>
<div className={styles.chipContainer}>
{roomConfiguration.roomsLeft < 5 && (
<span className={styles.chip}>
<Footnote
color="burgundy"
textTransform="uppercase"
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
</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 }
<div className={styles.imageContainer}>
<div className={styles.chipContainer}>
{roomConfiguration.roomsLeft < 5 && (
<span className={styles.chip}>
<Footnote
color="burgundy"
textTransform="uppercase"
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
</span>
)}
</Caption>
<Caption color="uiTextMediumContrast">
{roomSize?.min === roomSize?.max
? roomSize?.min
: `${roomSize?.min}-${roomSize?.max}`}
m²
</Caption>
{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}>
{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}>
{roomConfiguration.roomTypeCode && (
<ToggleSidePeek

View File

@@ -7,6 +7,7 @@
border: 1px solid var(--Base-Border-Subtle);
position: relative;
height: 100%;
min-height: 730px;
justify-content: space-between;
}

View File

@@ -66,7 +66,7 @@ export function filterDuplicateRoomTypesByLowestPrice(
Math.min(
Number(
previousLowest.products[0].productType.public.requestedPrice
.pricePerNight
?.pricePerNight
) ?? Infinity,
Number(
previousLowest.products[0].productType.member?.requestedPrice
@@ -77,7 +77,7 @@ export function filterDuplicateRoomTypesByLowestPrice(
Math.min(
Number(
previousLowest.products[0].productType.public.requestedPrice
.pricePerNight
?.pricePerNight
) ?? Infinity,
Number(
previousLowest.products[0].productType.member?.requestedPrice
@@ -88,7 +88,7 @@ export function filterDuplicateRoomTypesByLowestPrice(
Math.min(
Number(
previousLowest.products[0].productType.public.localPrice
.pricePerNight
?.pricePerNight
) ?? Infinity,
Number(
previousLowest.products[0].productType.member?.localPrice

View File

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

View File

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

View File

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

View File

@@ -154,6 +154,7 @@
"Hotel": "Hotel",
"Hotel facilities": "Hotel faciliteter",
"Hotel surroundings": "Hotel omgivelser",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hoteller}}",
"Hotels": "Hoteller",
"How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det virker",
@@ -336,6 +337,7 @@
"Show wellness & exercise": "Vis velvære og motion",
"Sign up bonus": "Velkomstbonus",
"Sign up to Scandic Friends": "Tilmeld dig Scandic Friends",
"Signing up...": "Tilmelder...",
"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 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 facilities": "Hotel-Infos",
"Hotel surroundings": "Umgebung des Hotels",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hotels}}",
"Hotels": "Hotels",
"How do you want to sleep?": "Wie möchtest du schlafen?",
"How it works": "Wie es funktioniert",
@@ -335,6 +336,7 @@
"Show wellness & exercise": "Zeige Wellness und Bewegung",
"Sign up bonus": "Anmelde-Bonus",
"Sign up to Scandic Friends": "Treten Sie Scandic Friends bei",
"Signing up...": "Registrierung läuft...",
"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 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 facilities": "Hotel facilities",
"Hotel surroundings": "Hotel surroundings",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hotels}}",
"Hotels": "Hotels",
"How do you want to sleep?": "How do you want to sleep?",
"How it works": "How it works",
@@ -365,6 +366,7 @@
"Show wellness & exercise": "Show wellness & exercise",
"Sign up bonus": "Sign up bonus",
"Sign up to Scandic Friends": "Sign up to Scandic Friends",
"Signing up...": "Signing up...",
"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 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 facilities": "Hotellin palvelut",
"Hotel surroundings": "Hotellin ympäristö",
"Hotel(s)": "{amount} {amount, plural, one {hotelli} other {hotellit}}",
"Hotels": "Hotellit",
"How do you want to sleep?": "Kuinka haluat nukkua?",
"How it works": "Kuinka se toimii",
@@ -337,6 +338,7 @@
"Show wellness & exercise": "Näytä hyvinvointi ja liikunta",
"Sign up bonus": "Liittymisbonus",
"Sign up to Scandic Friends": "Liity Scandic Friends -jäseneksi",
"Signing up...": "Rekisteröidytää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 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 facilities": "Hotelfaciliteter",
"Hotel surroundings": "Hotellomgivelser",
"Hotel(s)": "{amount} {amount, plural, one {hotell} other {hoteller}}",
"Hotels": "Hoteller",
"How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det fungerer",
@@ -334,6 +335,7 @@
"Show wellness & exercise": "Vis velvære og trening",
"Sign up bonus": "Velkomstbonus",
"Sign up to Scandic Friends": "Bli med i Scandic Friends",
"Signing up...": "Registrerer...",
"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 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 facilities": "Hotellfaciliteter",
"Hotel surroundings": "Hotellomgivning",
"Hotel(s)": "{amount} hotell",
"Hotels": "Hotell",
"How do you want to sleep?": "Hur vill du sova?",
"How it works": "Hur det fungerar",
@@ -334,6 +335,7 @@
"Show wellness & exercise": "Visa välbefinnande och träning",
"Sign up bonus": "Välkomstbonus",
"Sign up to Scandic Friends": "Bli medlem i Scandic Friends",
"Signing up...": "Registrerar...",
"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 remove your card. Please try again later.": "Något gick fel och vi kunde inte ta bort ditt kort. Försök igen senare.",

2
next-env.d.ts vendored
View File

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

View File

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

View File

@@ -1,5 +1,9 @@
import { z } from "zod"
import { Lang } from "@/constants/languages"
import { signUpSchema } from "@/components/Forms/Signup/schema"
// Query
export const staysInput = z
.object({
@@ -35,3 +39,19 @@ export const saveCreditCardInput = z.object({
transactionId: z.string(),
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 { signupVerify } from "@/constants/routes/signup"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { serverErrorByStatus } from "@/server/errors/trpc"
import {
initiateSaveCardSchema,
subscriberIdSchema,
} from "@/server/routers/user/output"
import { protectedProcedure, router } from "@/server/trpc"
import { protectedProcedure, router, serviceProcedure } from "@/server/trpc"
import {
addCreditCardInput,
deleteCreditCardInput,
saveCreditCardInput,
signupInput,
} from "./input"
const meter = metrics.getMeter("trpc.user")
@@ -24,6 +27,9 @@ const generatePreferencesLinkSuccessCounter = meter.createCounter(
const generatePreferencesLinkFailCounter = meter.createCounter(
"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({
creditCard: router({
@@ -208,4 +214,46 @@ export const userMutationRouter = router({
generatePreferencesLinkSuccessCounter.add(1)
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 { countriesMap } from "@/components/TempDesignSystem/Form/Country/countries"
import { passwordValidator } from "@/utils/passwordValidator"
import { phoneValidator } from "@/utils/phoneValidator"
import { getMembership } from "@/utils/user"
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()
if (!access_token) {
throw internalServerError(`[serviceProcedure] No service token`)

View File

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

View File

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