Merge branch 'develop' into feature/tracking

This commit is contained in:
Linus Flood
2024-10-15 07:46:54 +02:00
209 changed files with 4413 additions and 1046 deletions

View File

@@ -42,3 +42,4 @@ GOOGLE_STATIC_MAP_SIGNATURE_SECRET="test"
GOOGLE_STATIC_MAP_ID="test"
GOOGLE_DYNAMIC_MAP_ID="test"
HIDE_FOR_NEXT_RELEASE="true"
SALESFORCE_PREFERENCE_BASE_URL="test"

88
actions/registerUser.ts Normal file
View File

@@ -0,0 +1,88 @@
"use server"
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 { registerSchema } from "@/components/Forms/Register/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(registerSchema)
.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, {
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

@@ -1,5 +1,4 @@
.layout {
background-color: var(--Base-Background-Primary-Normal);
display: grid;
font-family: var(--typography-Body-Regular-fontFamily);
gap: var(--Spacing-x3);
@@ -9,6 +8,10 @@
margin: 0 auto;
}
.container {
background-color: var(--Base-Background-Primary-Normal);
}
.content {
display: grid;
padding-bottom: var(--Spacing-x9);

View File

@@ -9,12 +9,14 @@ export default async function MyPagesLayout({
breadcrumbs: React.ReactNode
}>) {
return (
<section className={styles.layout}>
{breadcrumbs}
<section className={styles.content}>
<Sidebar />
{children}
<div className={styles.container}>
<section className={styles.layout}>
{breadcrumbs}
<section className={styles.content}>
<Sidebar />
{children}
</section>
</section>
</section>
</div>
)
}

View File

@@ -1,10 +1,11 @@
.layout {
background-color: var(--Base-Background-Primary-Normal);
display: grid;
font-family: var(--typography-Body-Regular-fontFamily);
gap: var(--Spacing-x3);
grid-template-rows: auto 1fr;
position: relative;
max-width: var(--max-width);
margin: 0 auto;
}
.container {
background-color: var(--Base-Background-Primary-Normal);
}

View File

@@ -16,9 +16,11 @@ export default function ContentTypeLayout({
}
>) {
return (
<section className={styles.layout}>
{breadcrumbs}
{children}
</section>
<div className={styles.container}>
<section className={styles.layout}>
{breadcrumbs}
{children}
</section>
</div>
)
}

View File

@@ -9,8 +9,12 @@
grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr;
margin: var(--Spacing-x5) auto 0;
max-width: var(--max-width-navigation);
padding: var(--Spacing-x6) var(--Spacing-x2) 0;
padding-top: var(--Spacing-x6);
/* simulates padding on viewport smaller than --max-width-navigation */
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.summary {

View File

@@ -2,18 +2,22 @@ import { redirect } from "next/navigation"
import { serverClient } from "@/lib/trpc/server"
import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader"
import Summary from "@/components/HotelReservation/SelectRate/Summary"
import { setLang } from "@/i18n/serverContext"
import styles from "./layout.module.css"
import { StepEnum } from "@/types/components/enterDetails/step"
import type { LangParams, LayoutArgs } from "@/types/params"
export default async function StepLayout({
children,
params,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
}: React.PropsWithChildren<LayoutArgs<LangParams & { step: StepEnum }>>) {
setLang(params.lang)
const hotel = await serverClient().hotel.hotelData.get({
hotelId: "811",
language: params.lang,
@@ -24,15 +28,17 @@ export default async function StepLayout({
}
return (
<main className={styles.layout}>
<HotelSelectionHeader hotel={hotel.data.attributes} />
<div className={styles.content}>
<SelectedRoom />
{children}
<aside className={styles.summary}>
<Summary />
</aside>
</div>
</main>
<EnterDetailsProvider step={params.step}>
<main className={styles.layout}>
<HotelSelectionHeader hotel={hotel.data.attributes} />
<div className={styles.content}>
<SelectedRoom />
{children}
<aside className={styles.summary}>
<Summary />
</aside>
</div>
</main>
</EnterDetailsProvider>
)
}

View File

@@ -1,110 +1,67 @@
"use client"
import { notFound } from "next/navigation"
import { useState } from "react"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
import Details from "@/components/HotelReservation/EnterDetails/Details"
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
import Payment from "@/components/HotelReservation/SelectRate/Payment"
import SectionAccordion from "@/components/HotelReservation/SelectRate/SectionAccordion"
import LoadingSpinner from "@/components/LoadingSpinner"
import { getIntl } from "@/i18n"
import { StepEnum } from "@/types/components/enterDetails/step"
import type { LangParams, PageArgs } from "@/types/params"
enum StepEnum {
selectBed = "select-bed",
breakfast = "breakfast",
details = "details",
payment = "payment",
}
function isValidStep(step: string): step is StepEnum {
return Object.values(StepEnum).includes(step as StepEnum)
}
export default function StepPage({
export default async function StepPage({
params,
}: PageArgs<LangParams & { step: StepEnum }>) {
const { step } = params
const [activeStep, setActiveStep] = useState<StepEnum>(step)
const intl = useIntl()
const { step, lang } = params
if (!isValidStep(activeStep)) {
const intl = await getIntl()
const hotel = await serverClient().hotel.hotelData.get({
hotelId: "811",
language: lang,
})
const user = await getProfileSafely()
if (!isValidStep(step) || !hotel) {
return notFound()
}
const { data: hotel, isLoading: loadingHotel } =
trpc.hotel.hotelData.get.useQuery({
hotelId: "811",
language: params.lang,
})
if (loadingHotel) {
return <LoadingSpinner />
}
if (!hotel) {
// TODO: handle case with hotel missing
return notFound()
}
switch (activeStep) {
case StepEnum.breakfast:
//return <div>Select BREAKFAST</div>
case StepEnum.details:
//return <div>Select DETAILS</div>
case StepEnum.payment:
//return <div>Select PAYMENT</div>
case StepEnum.selectBed:
// return <div>Select BED</div>
}
function onNav(step: StepEnum) {
setActiveStep(step)
if (typeof window !== "undefined") {
window.history.pushState({}, "", step)
}
}
return (
<section>
<SectionAccordion
header="Select bed"
isCompleted={true}
isOpen={activeStep === StepEnum.selectBed}
step={StepEnum.selectBed}
label={intl.formatMessage({ id: "Request bedtype" })}
path="/select-bed"
>
<BedType />
</SectionAccordion>
<SectionAccordion
header="Food options"
isCompleted={true}
isOpen={activeStep === StepEnum.breakfast}
step={StepEnum.breakfast}
label={intl.formatMessage({ id: "Select breakfast options" })}
path="/breakfast"
>
<Breakfast />
</SectionAccordion>
<SectionAccordion
header="Details"
isCompleted={false}
isOpen={activeStep === StepEnum.details}
step={StepEnum.details}
label={intl.formatMessage({ id: "Enter your details" })}
path="/details"
>
<Details user={null} />
<Details user={user} />
</SectionAccordion>
<SectionAccordion
header="Payment"
isCompleted={false}
isOpen={activeStep === StepEnum.payment}
step={StepEnum.payment}
label={intl.formatMessage({ id: "Select payment method" })}
path="/hotelreservation/select-bed"
>
<Payment hotel={hotel.data.attributes} />
</SectionAccordion>

View File

@@ -4,6 +4,8 @@
padding: var(--Spacing-x4);
background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh;
max-width: var(--max-width);
margin: 0 auto;
}
.section {
@@ -11,10 +13,4 @@
flex-direction: column;
gap: var(--Spacing-x4);
width: 100%;
max-width: 365px;
}
@media screen and (min-width: 1367px) {
.section {
max-width: 525px;
}
}

View File

@@ -1,6 +1,4 @@
.layout {
min-height: 100dvh;
max-width: var(--max-width);
margin: 0 auto;
background-color: var(--Base-Background-Primary-Normal);
}

View File

@@ -4,6 +4,9 @@
padding: var(--Spacing-x4) var(--Spacing-x4) 0 var(--Spacing-x4);
background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh;
flex-direction: column;
max-width: var(--max-width);
margin: 0 auto;
}
.section {
@@ -15,3 +18,9 @@
display: flex;
padding: var(--Spacing-x2) var(--Spacing-x0);
}
@media (min-width: 768px) {
.main {
flex-direction: row;
}
}

View File

@@ -7,13 +7,12 @@
}
.content {
max-width: 1134px;
margin-top: var(--Spacing-x5);
margin-left: auto;
margin-right: auto;
max-width: var(--max-width);
margin: 0 auto;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: var(--Spacing-x7);
padding: var(--Spacing-x2);
}
.main {

View File

@@ -15,8 +15,11 @@ export default async function SelectRatePage({
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
setLang(params.lang)
// TODO: Use real endpoint.
const hotel = tempHotelData.data.attributes
const hotelData = await serverClient().hotel.hotelData.get({
hotelId: searchParams.hotel,
language: params.lang,
include: ["RoomCategories"],
})
const roomConfigurations = await serverClient().hotel.availability.rooms({
hotelId: parseInt(searchParams.hotel, 10),
@@ -24,18 +27,27 @@ export default async function SelectRatePage({
roomStayEndDate: "2024-11-03",
adults: 1,
})
if (!roomConfigurations) {
return "No rooms found"
return "No rooms found" // TODO: Add a proper error message
}
if (!hotelData) {
return "No hotel data found" // TODO: Add a proper error message
}
const roomCategories = hotelData?.included
return (
<div>
{/* TODO: Add Hotel Listing Card */}
<div>Hotel Listing Card TBI</div>
<div className={styles.content}>
{/* TODO: Add Hotel Listing Card */}
<div>Hotel Listing Card TBI</div>
<div className={styles.main}>
<RoomSelection roomConfigurations={roomConfigurations} />
<RoomSelection
roomConfigurations={roomConfigurations}
roomCategories={roomCategories ?? []}
/>
</div>
</div>
</div>

View File

@@ -107,13 +107,15 @@
--main-menu-mobile-height: 75px;
--main-menu-desktop-height: 118px;
--booking-widget-desktop-height: 95px;
--booking-widget-mobile-height: 75px;
--booking-widget-desktop-height: 77px;
--hotel-page-map-desktop-width: 23.75rem;
/* Z-INDEX */
--header-z-index: 10;
--menu-overlay-z-index: 10;
--dialog-z-index: 9;
--sidepeek-z-index: 11;
}
* {

View File

@@ -6,12 +6,15 @@ import LoyaltyCard from "@/components/TempDesignSystem/LoyaltyCard"
import TeaserCard from "@/components/TempDesignSystem/TeaserCard"
import type { CardsGridProps } from "@/types/components/blocks/cardsGrid"
import { CardsGridEnum } from "@/types/enums/cardsGrid"
import { CardsGridEnum, CardsGridLayoutEnum } from "@/types/enums/cardsGrid"
export default function CardsGrid({
cards_grid,
firstItem = false,
}: CardsGridProps) {
const columns =
cards_grid.layout === CardsGridLayoutEnum.THREE_COLUMNS ? 3 : 2
return (
<SectionContainer>
<SectionHeader
@@ -19,7 +22,7 @@ export default function CardsGrid({
preamble={cards_grid.preamble}
topTitle={firstItem}
/>
<Grids.Stackable>
<Grids.Stackable columns={columns}>
{cards_grid.cards.map((card) => {
switch (card.__typename) {
case CardsGridEnum.cards.Card:
@@ -43,6 +46,7 @@ export default function CardsGrid({
primaryButton={card.primaryButton}
secondaryButton={card.secondaryButton}
sidePeekButton={card.sidePeekButton}
sidePeekContent={card.sidePeekContent}
image={card.image}
/>
)

View File

@@ -0,0 +1,35 @@
import { redirect } from "next/navigation"
import { overview } from "@/constants/routes/myPages"
import { auth } from "@/auth"
import LoginButton from "@/components/Current/Header/LoginButton"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./signUpVerification.module.css"
import type { SignUpVerificationProps } from "@/types/components/blocks/dynamicContent"
export default async function SignUpVerification({
dynamic_content,
}: SignUpVerificationProps) {
const session = await auth()
if (session) {
redirect(overview[getLang()])
}
const intl = await getIntl()
return (
<div className={styles.container}>
<LoginButton
className={styles.loginButton}
trackingId="signUpVerificationLogin"
position="sign up verification"
variant="signupVerification"
>
{intl.formatMessage({ id: "Proceed to login" })}
</LoginButton>
</div>
)
}

View File

@@ -0,0 +1,6 @@
.container {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: var(--Spacing-x3);
}

View File

@@ -0,0 +1,20 @@
import { redirect } from "next/navigation"
import { overview } from "@/constants/routes/myPages"
import { auth } from "@/auth"
import Form from "@/components/Forms/Register"
import { getLang } from "@/i18n/serverContext"
import { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
export default async function SignupFormWrapper({
dynamic_content,
}: SignupFormWrapperProps) {
const session = await auth()
if (session) {
// We don't want to allow users to access signup if they are already authenticated.
redirect(overview[getLang()])
}
return <Form {...dynamic_content} />
}

View File

@@ -7,6 +7,8 @@ import ExpiringPoints from "@/components/Blocks/DynamicContent/Points/ExpiringPo
import PointsOverview from "@/components/Blocks/DynamicContent/Points/Overview"
import CurrentRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/CurrentLevel"
import NextLevelRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/NextLevel"
import SignupFormWrapper from "@/components/Blocks/DynamicContent/SignupFormWrapper"
import SignUpVerification from "@/components/Blocks/DynamicContent/SignUpVerification"
import PreviousStays from "@/components/Blocks/DynamicContent/Stays/Previous"
import SoonestStays from "@/components/Blocks/DynamicContent/Stays/Soonest"
import UpcomingStays from "@/components/Blocks/DynamicContent/Stays/Upcoming"
@@ -51,6 +53,10 @@ export default async function DynamicContent({
return <PointsOverview {...dynamic_content} />
case DynamicContentEnum.Blocks.components.previous_stays:
return <PreviousStays {...dynamic_content} />
case DynamicContentEnum.Blocks.components.sign_up_form:
return <SignupFormWrapper dynamic_content={dynamic_content} />
case DynamicContentEnum.Blocks.components.sign_up_verification:
return <SignUpVerification dynamic_content={dynamic_content} />
case DynamicContentEnum.Blocks.components.soonest_stays:
return <SoonestStays {...dynamic_content} />
case DynamicContentEnum.Blocks.components.upcoming_stays:

View File

@@ -1,37 +0,0 @@
import { ArrowRightIcon } from "@/components/Icons"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./shortcuts.module.css"
import type { ShortcutsProps } from "@/types/components/myPages/myPage/shortcuts"
export default function Shortcuts({
firstItem = false,
shortcuts,
subtitle,
title,
}: ShortcutsProps) {
return (
<SectionContainer>
<SectionHeader preamble={subtitle} title={title} topTitle={firstItem} />
<section className={styles.links}>
{shortcuts.map((shortcut) => (
<Link
href={shortcut.url}
key={shortcut.title}
target={shortcut.openInNewTab ? "_blank" : undefined}
variant="shortcut"
>
<Body textTransform="bold" color="burgundy">
<span>{shortcut.text ? shortcut.text : shortcut.title}</span>
</Body>
<ArrowRightIcon color="burgundy" className={styles.arrowRight} />
</Link>
))}
</section>
</SectionContainer>
)
}

View File

@@ -1,11 +0,0 @@
.links {
display: grid;
background-color: var(--Scandic-Brand-Pale-Peach);
border-radius: var(--Corner-radius-Medium);
border: 1px solid var(--Base-Border-Subtle);
}
.arrowRight {
height: 24px;
width: 24px;
}

View File

@@ -0,0 +1,32 @@
import { ArrowRightIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./shortcutsListItems.module.css"
import type { ShortcutsListItemsProps } from "@/types/components/blocks/shortcuts"
export default function ShortcutsListItems({
shortcutsListItems,
className,
}: ShortcutsListItemsProps) {
return (
<ul className={className}>
{shortcutsListItems.map((shortcut) => (
<li key={shortcut.title} className={styles.listItem}>
<Link
href={shortcut.url}
target={shortcut.openInNewTab ? "_blank" : undefined}
variant="shortcut"
className={styles.link}
>
<Body textTransform="bold" color="burgundy">
<span>{shortcut.text || shortcut.title}</span>
</Body>
<ArrowRightIcon color="burgundy" width={24} height={24} />
</Link>
</li>
))}
</ul>
)
}

View File

@@ -0,0 +1,11 @@
.link {
background-color: var(--Base-Surface-Primary-light-Normal);
}
.listItem {
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.listItem:last-child {
border-bottom: none;
}

View File

@@ -0,0 +1,48 @@
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import ShortcutsListItems from "./ShortcutsListItems"
import styles from "./shortcutsList.module.css"
import type { ShortcutsListProps } from "@/types/components/blocks/shortcuts"
export default function ShortcutsList({
firstItem = false,
shortcuts,
subtitle,
title,
hasTwoColumns,
}: ShortcutsListProps) {
const middleIndex = Math.ceil(shortcuts.length / 2)
const leftColumn = shortcuts.slice(0, middleIndex)
const rightColumn = shortcuts.slice(middleIndex)
const classNames = hasTwoColumns
? {
section: styles.twoColumnSection,
leftColumn: styles.leftColumn,
rightColumn: styles.rightColumn,
}
: {
section: styles.oneColumnSection,
leftColumn: styles.leftColumnBottomBorder,
rightColumn: "",
}
return (
<SectionContainer>
<SectionHeader preamble={subtitle} title={title} topTitle={firstItem} />
<section className={classNames.section}>
<ShortcutsListItems
shortcutsListItems={leftColumn}
className={classNames.leftColumn}
/>
<ShortcutsListItems
shortcutsListItems={rightColumn}
className={classNames.rightColumn}
/>
</section>
</SectionContainer>
)
}

View File

@@ -0,0 +1,29 @@
.oneColumnSection,
.twoColumnSection {
display: grid;
border-radius: var(--Corner-radius-Medium);
border: 1px solid var(--Base-Border-Subtle);
overflow: hidden;
}
.leftColumn,
.leftColumnBottomBorder {
border-bottom: 1px solid var(--Base-Border-Subtle);
}
@media screen and (min-width: 1367px) {
.twoColumnSection {
grid-template-columns: 1fr 1fr;
column-gap: var(--Spacing-x2);
border-radius: 0;
border: none;
}
.leftColumn,
.rightColumn {
height: fit-content;
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
overflow: hidden;
}
}

View File

@@ -1,6 +1,6 @@
import CardsGrid from "@/components/Blocks/CardsGrid"
import DynamicContent from "@/components/Blocks/DynamicContent"
import Shortcuts from "@/components/Blocks/Shortcuts"
import ShortcutsList from "@/components/Blocks/ShortcutsList"
import TextCols from "@/components/Blocks/TextCols"
import UspGrid from "@/components/Blocks/UspGrid"
import JsonToHtml from "@/components/JsonToHtml"
@@ -41,12 +41,13 @@ export default function Blocks({ blocks }: BlocksProps) {
)
case BlocksEnums.block.Shortcuts:
return (
<Shortcuts
<ShortcutsList
firstItem={firstItem}
key={`${block.shortcuts.title}-${idx}`}
shortcuts={block.shortcuts.shortcuts}
subtitle={block.shortcuts.subtitle}
title={block.shortcuts.title}
hasTwoColumns={block.shortcuts.hasTwoColumns}
/>
)
case BlocksEnums.block.Table:

View File

@@ -7,7 +7,7 @@ import { dt } from "@/lib/dt"
import Form from "@/components/Forms/BookingWidget"
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
import { CloseLarge } from "@/components/Icons"
import { CloseLargeIcon } from "@/components/Icons"
import { debounce } from "@/utils/debounce"
import MobileToggleButton from "./MobileToggleButton"
@@ -98,7 +98,7 @@ export default function BookingWidgetClient({
onClick={closeMobileSearch}
type="button"
>
<CloseLarge />
<CloseLargeIcon />
</button>
<Form locations={locations} type={type} />
</section>

View File

@@ -3,6 +3,9 @@
padding-left: var(--Spacing-x2);
padding-right: var(--Spacing-x2);
padding-top: var(--Spacing-x2);
max-width: var(--max-width);
margin: 0 auto;
width: 100%;
}
.list {

View File

@@ -6,8 +6,7 @@
display: grid;
gap: var(--Spacing-x-one-and-half);
height: fit-content;
width: 100%;
max-width: 300px;
width: min(100%, 300px);
}
.amenityItemList {

View File

@@ -1,3 +1,7 @@
.cardContainer {
scroll-margin-top: var(--hotel-page-scroll-margin-top);
}
.spanOne {
grid-column: span 1;
}

View File

@@ -26,7 +26,7 @@ export default function FacilitiesCardGrid({
}
return (
<section id={imageCard.card.id}>
<section id={imageCard.card.id} className={styles.cardContainer}>
<Grids.Stackable className={styles.desktopGrid}>
{facilitiesCardGrid.map((card: FacilityCardType) => (
<Card {...card} key={card.id} className={getCardClassName(card)} />

View File

@@ -2,7 +2,7 @@
display: grid;
gap: var(--Spacing-x2);
position: relative;
max-width: var(--max-width-text-block);
max-width: 607px; /* Max width according to Figma */
}
.mainContent {

View File

@@ -13,6 +13,7 @@ import { RoomCard } from "./RoomCard"
import styles from "./rooms.module.css"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
import type { RoomsProps } from "./types"
export function Rooms({ rooms }: RoomsProps) {
@@ -22,20 +23,20 @@ export function Rooms({ rooms }: RoomsProps) {
const mappedRooms = rooms
.map((room) => {
const size = `${room.attributes.roomSize.min} - ${room.attributes.roomSize.max}`
const size = `${room.roomSize.min} - ${room.roomSize.max}`
const personLabel =
room.attributes.occupancy.total === 1
room.occupancy.total === 1
? intl.formatMessage({ id: "hotelPages.rooms.roomCard.person" })
: intl.formatMessage({ id: "hotelPages.rooms.roomCard.persons" })
const subtitle = `${size} (${room.attributes.occupancy.total} ${personLabel})`
const subtitle = `${size} (${room.occupancy.total} ${personLabel})`
return {
id: room.id,
images: room.attributes.content.images,
title: room.attributes.name,
images: room.images,
title: room.name,
subtitle: subtitle,
sortOrder: room.attributes.sortOrder,
sortOrder: room.sortOrder,
popularChoice: null,
}
})
@@ -50,8 +51,11 @@ export function Rooms({ rooms }: RoomsProps) {
}
return (
<SectionContainer id="rooms-section">
<div ref={scrollRef}></div>
<SectionContainer
id={HotelHashValues.rooms}
className={styles.roomsContainer}
>
<div ref={scrollRef} className={styles.scrollRef}></div>
<SectionHeader
textTransform="capitalize"
title={intl.formatMessage({ id: "Rooms" })}

View File

@@ -1,3 +1,13 @@
.roomsContainer {
position: relative;
scroll-margin-top: var(--hotel-page-scroll-margin-top);
}
.scrollRef {
position: absolute;
top: calc(-1 * var(--hotel-page-scroll-margin-top));
}
.ctaContainer {
display: flex;
justify-content: center;

View File

@@ -1,8 +1,12 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import Link from "@/components/TempDesignSystem/Link"
import useHash from "@/hooks/useHash"
import useScrollSpy from "@/hooks/useScrollSpy"
import styles from "./tabNavigation.module.css"
@@ -11,50 +15,78 @@ import {
type TabNavigationProps,
} from "@/types/components/hotelPage/tabNavigation"
export default function TabNavigation({ restaurantTitle }: TabNavigationProps) {
export default function TabNavigation({
restaurantTitle,
hasActivities,
hasFAQ,
}: TabNavigationProps) {
const hash = useHash()
const intl = useIntl()
const router = useRouter()
const hotelTabLinks: { href: HotelHashValues | string; text: string }[] = [
const tabLinks: { hash: HotelHashValues; text: string }[] = [
{
href: HotelHashValues.overview,
hash: HotelHashValues.overview,
text: intl.formatMessage({ id: "Overview" }),
},
{ href: HotelHashValues.rooms, text: intl.formatMessage({ id: "Rooms" }) },
{ hash: HotelHashValues.rooms, text: intl.formatMessage({ id: "Rooms" }) },
{
href: HotelHashValues.restaurant,
hash: HotelHashValues.restaurant,
text: intl.formatMessage({ id: restaurantTitle }, { count: 1 }),
},
{
href: HotelHashValues.meetings,
hash: HotelHashValues.meetings,
text: intl.formatMessage({ id: "Meetings & Conferences" }),
},
{
href: HotelHashValues.wellness,
hash: HotelHashValues.wellness,
text: intl.formatMessage({ id: "Wellness & Exercise" }),
},
{
href: HotelHashValues.activities,
text: intl.formatMessage({ id: "Activities" }),
},
{ href: HotelHashValues.faq, text: intl.formatMessage({ id: "FAQ" }) },
...(hasActivities
? [
{
hash: HotelHashValues.activities,
text: intl.formatMessage({ id: "Activities" }),
},
]
: []),
...(hasFAQ
? [
{
hash: HotelHashValues.faq,
text: intl.formatMessage({ id: "FAQ" }),
},
]
: []),
]
const { activeSectionId, pauseScrollSpy } = useScrollSpy(
tabLinks.map(({ hash }) => hash)
)
useEffect(() => {
if (activeSectionId) {
router.replace(`#${activeSectionId}`, { scroll: false })
}
}, [activeSectionId, router])
return (
<div className={styles.stickyWrapper}>
<nav className={styles.tabsContainer}>
{hotelTabLinks.map((link) => {
{tabLinks.map((link) => {
const isActive =
hash === link.href ||
(hash === "" && link.href === HotelHashValues.overview)
hash === link.hash ||
(!hash && link.hash === HotelHashValues.overview)
return (
<Link
key={link.href}
href={link.href}
key={link.hash}
href={`#${link.hash}`}
active={isActive}
variant="tab"
color="burgundy"
textDecoration="none"
scroll={true}
onClick={pauseScrollSpy}
>
{intl.formatMessage({ id: link.text })}
</Link>

View File

@@ -1,6 +1,6 @@
.stickyWrapper {
position: sticky;
top: 0;
top: var(--booking-widget-mobile-height);
z-index: 1;
background-color: var(--Base-Surface-Subtle-Normal);
border-bottom: 1px solid var(--Base-Border-Subtle);
@@ -16,6 +16,12 @@
width: 100%;
}
@media screen and (min-width: 768px) {
.stickyWrapper {
top: var(--booking-widget-desktop-height);
}
}
@media screen and (min-width: 1367px) {
.tabsContainer {
padding: 0 var(--Spacing-x5);

View File

@@ -1,10 +1,16 @@
.pageContainer {
--hotel-page-navigation-height: 59px;
--hotel-page-scroll-margin-top: calc(
var(--hotel-page-navigation-height) + var(--Spacing-x2)
);
display: grid;
grid-template-areas:
"hotelImages"
"tabNavigation"
"mainSection"
"mapContainer";
margin: 0 auto;
max-width: var(--max-width);
}
.hotelImages {
@@ -24,8 +30,11 @@
}
.introContainer {
display: grid;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--Spacing-x4);
scroll-margin-top: var(--hotel-page-scroll-margin-top);
}
@media screen and (min-width: 1367px) {
@@ -52,7 +61,7 @@
.mapWithCard {
position: sticky;
top: 0;
top: var(--booking-widget-desktop-height);
min-height: 500px; /* Fixed min to not cover the marker with the card */
height: calc(
100vh - var(--main-menu-desktop-height) -

View File

@@ -21,6 +21,8 @@ import TabNavigation from "./TabNavigation"
import styles from "./hotelPage.module.css"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
export default async function HotelPage() {
const intl = await getIntl()
const lang = getLang()
@@ -61,9 +63,11 @@ export default async function HotelPage() {
</div>
<TabNavigation
restaurantTitle={getRestaurantHeading(hotelDetailedFacilities)}
hasActivities={!!activitiesCard}
hasFAQ={false}
/>
<main className={styles.mainSection}>
<div className={styles.introContainer}>
<div id={HotelHashValues.overview} className={styles.introContainer}>
<IntroSection
hotelName={hotelName}
hotelDescription={hotelDescription}
@@ -71,51 +75,7 @@ export default async function HotelPage() {
address={hotelAddress}
tripAdvisor={hotelRatings?.tripAdvisor}
/>
<SidePeekProvider>
{/* eslint-disable import/no-named-as-default-member */}
<SidePeek
contentKey={hotelPageParams.amenities[lang]}
title={intl.formatMessage({ id: "Amenities" })}
>
{/* TODO: Render amenities as per the design. */}
Read more about the amenities here
</SidePeek>
<SidePeek
contentKey={hotelPageParams.about[lang]}
title={intl.formatMessage({ id: "Read more about the hotel" })}
>
Some additional information about the hotel
</SidePeek>
<SidePeek
contentKey={hotelPageParams.restaurantAndBar[lang]}
title={intl.formatMessage({ id: "Restaurant & Bar" })}
>
{/* TODO */}
Restaurant & Bar
</SidePeek>
<SidePeek
contentKey={hotelPageParams.wellnessAndExercise[lang]}
title={intl.formatMessage({ id: "Wellness & Exercise" })}
>
{/* TODO */}
Wellness & Exercise
</SidePeek>
<SidePeek
contentKey={hotelPageParams.activities[lang]}
title={intl.formatMessage({ id: "Activities" })}
>
{/* TODO */}
Activities
</SidePeek>
<SidePeek
contentKey={hotelPageParams.meetingsAndConferences[lang]}
title={intl.formatMessage({ id: "Meetings & Conferences" })}
>
{/* TODO */}
Meetings & Conferences
</SidePeek>
{/* eslint-enable import/no-named-as-default-member */}
</SidePeekProvider>
<AmenitiesList detailedFacilities={hotelDetailedFacilities} />
</div>
<Rooms rooms={roomCategories} />
@@ -139,6 +99,51 @@ export default async function HotelPage() {
/>
</>
) : null}
<SidePeekProvider>
{/* eslint-disable import/no-named-as-default-member */}
<SidePeek
contentKey={hotelPageParams.amenities[lang]}
title={intl.formatMessage({ id: "Amenities" })}
>
{/* TODO: Render amenities as per the design. */}
Read more about the amenities here
</SidePeek>
<SidePeek
contentKey={hotelPageParams.about[lang]}
title={intl.formatMessage({ id: "Read more about the hotel" })}
>
Some additional information about the hotel
</SidePeek>
<SidePeek
contentKey={hotelPageParams.restaurantAndBar[lang]}
title={intl.formatMessage({ id: "Restaurant & Bar" })}
>
{/* TODO */}
Restaurant & Bar
</SidePeek>
<SidePeek
contentKey={hotelPageParams.wellnessAndExercise[lang]}
title={intl.formatMessage({ id: "Wellness & Exercise" })}
>
{/* TODO */}
Wellness & Exercise
</SidePeek>
<SidePeek
contentKey={hotelPageParams.activities[lang]}
title={intl.formatMessage({ id: "Activities" })}
>
{/* TODO */}
Activities
</SidePeek>
<SidePeek
contentKey={hotelPageParams.meetingsAndConferences[lang]}
title={intl.formatMessage({ id: "Meetings & Conferences" })}
>
{/* TODO */}
Meetings & Conferences
</SidePeek>
{/* eslint-enable import/no-named-as-default-member */}
</SidePeekProvider>
</div>
)
}

View File

@@ -3,11 +3,12 @@
padding-bottom: var(--Spacing-x9);
padding-left: var(--Spacing-x0);
padding-right: var(--Spacing-x0);
position: relative;
justify-content: center;
align-items: flex-start;
container-name: loyalty-page;
container-type: inline-size;
max-width: var(--max-width);
margin: 0 auto;
width: 100%;
}
.blocks {

View File

@@ -18,11 +18,13 @@ export default function LoginButton({
trackingId,
children,
color = "black",
variant = "default",
}: PropsWithChildren<{
className: string
trackingId: string
position: TrackingPosition
color?: LinkProps["color"]
variant?: "default" | "signupVerification"
}>) {
const lang = useLang()
const pathName = useLazyPathname()
@@ -38,6 +40,7 @@ export default function LoginButton({
color={color}
href={href}
prefetch={false}
variant={variant}
onClick={() => trackLoginClick(position)}
>
{children}

View File

@@ -6,7 +6,7 @@ import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import { CloseLarge } from "@/components/Icons"
import { CloseLargeIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -127,7 +127,7 @@ export default function DatePickerMobile({
))}
</select>
<button className={styles.close} onClick={close} type="button">
<CloseLarge />
<CloseLargeIcon />
</button>
</header>
{children}

View File

@@ -17,6 +17,7 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) {
color="burgundy"
href={link.url}
className={styles.mainNavigationLink}
target={link.openInNewTab ? "_blank" : undefined}
>
{link.title}

View File

@@ -56,26 +56,13 @@ export default function FooterSecondaryNav({
<ul className={styles.secondaryNavigationList}>
{link?.links?.map((link) => (
<li key={link.title} className={styles.secondaryNavigationItem}>
{link.isExternal ? (
<a
href={link.url}
key={link.title}
target={link.openInNewTab ? "_blank" : "_self"}
aria-label={link.title}
className={styles.secondaryNavigationLink}
>
{link.title}
</a>
) : (
<Link
href={link.url}
key={link.title}
target={link.openInNewTab ? "_blank" : "_self"}
color="burgundy"
>
{link.title}
</Link>
)}
<Link
href={link.url}
target={link.openInNewTab ? "_blank" : undefined}
color="burgundy"
>
{link.title}
</Link>
</li>
))}
</ul>

View File

@@ -1,23 +1,18 @@
.dialog {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Large);
box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
left: 0;
list-style: none;
max-height: 380px;
overflow-y: auto;
padding: var(--Spacing-x2) var(--Spacing-x3);
position: absolute;
/**
* var(--Spacing-x4) to account for padding inside
* the bookingwidget and to add the padding for the
* box itself
*/
top: calc(100% + var(--Spacing-x4));
width: 360px;
z-index: 99;
position: fixed;
top: 170px;
width: 100%;
height: calc(100% - 200px);
z-index: 10010;
}
.default {
@@ -31,3 +26,20 @@
.search {
gap: var(--Spacing-x3);
}
@media (min-width: 768px) {
.dialog {
position: absolute;
width: 360px;
/**
* var(--Spacing-x4) to account for padding inside
* the bookingwidget and to add the padding for the
* box itself
*/
top: calc(100% + var(--Spacing-x4));
z-index: 99;
box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1);
max-height: 380px;
height: auto;
}
}

View File

@@ -10,7 +10,6 @@ import {
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Input from "../Input"

View File

@@ -4,6 +4,7 @@
border-width: 1px;
border-radius: var(--Corner-radius-Small);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
position: relative;
}
.container:hover,

View File

@@ -32,6 +32,10 @@
display: none;
}
.infoIcon {
stroke: var(--Base-Text-Disabled);
}
@media screen and (min-width: 768px) {
.vouchers {
display: none;

View File

@@ -1,7 +1,3 @@
.infoIcon {
stroke: var(--Base-Text-Disabled);
}
.vouchersHeader {
display: flex;
gap: var(--Spacing-x-one-and-half);

View File

@@ -7,7 +7,6 @@ import { dt } from "@/lib/dt"
import DatePicker from "@/components/DatePicker"
import { SearchIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Input from "./Input"

View File

@@ -2,8 +2,10 @@
align-items: center;
display: grid;
margin: 0 auto;
max-width: var(--max-width-navigation);
width: 100%;
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.form {
@@ -32,7 +34,8 @@
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2)
var(--Spacing-x-one-and-half) var(--Spacing-x1);
}
.full {
padding: var(--Spacing-x1) var(--Spacing-x5);
padding: var(--Spacing-x1) 0;
}
}

View File

@@ -69,6 +69,7 @@ export default function Form({ user }: EditFormProps) {
retypeNewPassword: "",
},
mode: "all",
criteriaMode: "all",
resolver: zodResolver(editProfileSchema),
reValidateMode: "onChange",
})

View File

@@ -1,6 +1,6 @@
import { z } from "zod"
import { Key } from "@/components/TempDesignSystem/Form/NewPassword/newPassword"
import { passwordValidator } from "@/utils/passwordValidator"
import { phoneValidator } from "@/utils/phoneValidator"
const countryRequiredMsg = "Country is required"
@@ -26,7 +26,7 @@ export const editProfileSchema = z
),
password: z.string().optional(),
newPassword: z.string().optional(),
newPassword: passwordValidator(),
retypeNewPassword: z.string().optional(),
})
.superRefine((data, ctx) => {
@@ -55,29 +55,6 @@ export const editProfileSchema = z
}
}
if (data.newPassword) {
const msgs = []
if (data.newPassword.length < 10 || data.newPassword.length > 40) {
msgs.push(Key.CHAR_LENGTH)
}
if (!data.newPassword.match(/[A-Z]/g)) {
msgs.push(Key.UPPERCASE)
}
if (!data.newPassword.match(/[0-9]/g)) {
msgs.push(Key.NUM)
}
if (!data.newPassword.match(/[^A-Za-z0-9]/g)) {
msgs.push(Key.SPECIAL_CHAR)
}
if (msgs.length) {
ctx.addIssue({
code: "custom",
message: msgs.join(","),
path: ["newPassword"],
})
}
}
if (data.newPassword && !data.retypeNewPassword) {
ctx.addIssue({
code: "custom",

View File

@@ -0,0 +1,49 @@
.form {
display: grid;
gap: var(--Spacing-x5);
grid-area: form;
}
.title {
grid-area: title;
}
.formWrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.userInfo,
.password,
.terms {
align-self: flex-start;
display: grid;
gap: var(--Spacing-x2);
}
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.nameInputs {
display: grid;
gap: var(--Spacing-x2);
}
.dateField {
display: grid;
gap: var(--Spacing-x1);
}
@media screen and (min-width: 1367px) {
.formWrapper {
gap: var(--Spacing-x5);
}
.nameInputs {
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -0,0 +1,185 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { privacyPolicy } from "@/constants/currentWebHrefs"
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"
import DateSelect from "@/components/TempDesignSystem/Form/Date"
import Input from "@/components/TempDesignSystem/Form/Input"
import NewPassword from "@/components/TempDesignSystem/Form/NewPassword"
import Phone from "@/components/TempDesignSystem/Form/Phone"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import { RegisterSchema, registerSchema } from "./schema"
import styles from "./form.module.css"
import type { RegisterFormProps } from "@/types/components/form/registerForm"
export default function Form({ link, subtitle, title }: RegisterFormProps) {
const intl = useIntl()
const lang = useLang()
const methods = useForm<RegisterSchema>({
defaultValues: {
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
dateOfBirth: "",
address: {
countryCode: "",
zipCode: "",
},
password: "",
termsAccepted: false,
},
mode: "all",
criteriaMode: "all",
resolver: zodResolver(registerSchema),
reValidateMode: "onChange",
})
const country = intl.formatMessage({ id: "Country" })
const email = `${intl.formatMessage({ id: "Email" })} ${intl.formatMessage({ id: "Address" }).toLowerCase()}`
const phoneNumber = intl.formatMessage({ id: "Phone number" })
const zipCode = intl.formatMessage({ id: "Zip code" })
async function handleSubmit(data: RegisterSchema) {
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!" }))
}
}
return (
<section className={styles.formWrapper}>
<Title as="h3">{title}</Title>
<FormProvider {...methods}>
<form
className={styles.form}
id="register"
/**
* Ignoring since ts doesn't recognize that tRPC
* parses FormData before reaching the route
* @ts-ignore */
action={registerUser}
onSubmit={methods.handleSubmit(handleSubmit)}
>
<section className={styles.userInfo}>
<div className={styles.container}>
<header>
<Subtitle type="two">
{intl.formatMessage({ id: "Contact information" })}
</Subtitle>
</header>
<div className={styles.nameInputs}>
<Input
label={"firstName"}
name="firstName"
registerOptions={{ required: true }}
/>
<Input
label={"lastName"}
name="lastName"
registerOptions={{ required: true }}
/>
</div>
</div>
<div className={styles.dateField}>
<header>
<Caption textTransform="bold">
{intl.formatMessage({ id: "Birth date" })}
</Caption>
</header>
<DateSelect
name="dateOfBirth"
registerOptions={{ required: true }}
/>
</div>
<div className={styles.container}>
<Input
label={zipCode}
name="address.zipCode"
registerOptions={{ required: true }}
/>
<CountrySelect
label={country}
name="address.countryCode"
registerOptions={{ required: true }}
/>
</div>
<Input
label={email}
name="email"
registerOptions={{ required: true }}
type="email"
/>
<Phone label={phoneNumber} name="phoneNumber" />
</section>
<section className={styles.password}>
<header>
<Subtitle type="two">
{intl.formatMessage({ id: "Password" })}
</Subtitle>
</header>
<NewPassword
name="password"
placeholder="Password"
label={intl.formatMessage({ id: "Password" })}
/>
</section>
<section className={styles.terms}>
<header>
<Subtitle type="two">
{intl.formatMessage({ id: "Terms and conditions" })}
</Subtitle>
</header>
<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>
</section>
<Button
type="submit"
intent="primary"
disabled={methods.formState.isSubmitting}
data-testid="submit"
>
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
</Button>
</form>
</FormProvider>
</section>
)
}

View File

@@ -0,0 +1,35 @@
import { z } from "zod"
import { passwordValidator } from "@/utils/passwordValidator"
import { phoneValidator } from "@/utils/phoneValidator"
export const registerSchema = z.object({
firstName: z
.string()
.max(250)
.refine((value) => value.trim().length > 0, {
message: "First name is required",
}),
lastName: z
.string()
.max(250)
.refine((value) => value.trim().length > 0, {
message: "Last name is required",
}),
email: z.string().max(250).email(),
phoneNumber: phoneValidator(
"Phone is required",
"Please enter a valid phone number"
),
dateOfBirth: z.string().min(1),
address: z.object({
countryCode: z.string(),
zipCode: z.string().min(1),
}),
password: passwordValidator("Password is required"),
termsAccepted: z.boolean().refine((value) => value === true, {
message: "You must accept the terms and conditions",
}),
})
export type RegisterSchema = z.infer<typeof registerSchema>

View File

@@ -1,9 +1,12 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { KingBedIcon } from "@/components/Icons"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -16,8 +19,14 @@ import { bedTypeEnum } from "@/types/enums/bedType"
export default function BedType() {
const intl = useIntl()
const bedType = useEnterDetailsStore((state) => state.data.bedType)
const methods = useForm<BedTypeSchema>({
defaultValues: bedType
? {
bedType,
}
: undefined,
criteriaMode: "all",
mode: "all",
resolver: zodResolver(bedTypeSchema),
@@ -28,15 +37,32 @@ export default function BedType() {
{ id: "<b>Included</b> (based on availability)" },
{ b: (str) => <b>{str}</b> }
)
const completeStep = useEnterDetailsStore((state) => state.completeStep)
const onSubmit = useCallback(
(values: BedTypeSchema) => {
completeStep(values)
},
[completeStep]
)
useEffect(() => {
if (methods.formState.isSubmitting) {
return
}
const subscription = methods.watch(() => methods.handleSubmit(onSubmit)())
return () => subscription.unsubscribe()
}, [methods, onSubmit])
return (
<FormProvider {...methods}>
<form className={styles.form}>
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
<RadioCard
Icon={KingBedIcon}
iconWidth={46}
id={bedTypeEnum.KING}
name="bed"
name="bedType"
subtitle={intl.formatMessage(
{ id: "{width} cm × {length} cm" },
{
@@ -52,7 +78,7 @@ export default function BedType() {
Icon={KingBedIcon}
iconWidth={46}
id={bedTypeEnum.QUEEN}
name="bed"
name="bedType"
subtitle={intl.formatMessage(
{ id: "{width} cm × {length} cm" },
{

View File

@@ -3,5 +3,5 @@ import { z } from "zod"
import { bedTypeEnum } from "@/types/enums/bedType"
export const bedTypeSchema = z.object({
bed: z.nativeEnum(bedTypeEnum),
bedType: z.nativeEnum(bedTypeEnum),
})

View File

@@ -1,9 +1,12 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { BreakfastIcon, NoBreakfastIcon } from "@/components/Icons"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -17,16 +20,36 @@ import { breakfastEnum } from "@/types/enums/breakfast"
export default function Breakfast() {
const intl = useIntl()
const breakfast = useEnterDetailsStore((state) => state.data.breakfast)
const methods = useForm<BreakfastSchema>({
defaultValues: breakfast ? { breakfast } : undefined,
criteriaMode: "all",
mode: "all",
resolver: zodResolver(breakfastSchema),
reValidateMode: "onChange",
})
const completeStep = useEnterDetailsStore((state) => state.completeStep)
const onSubmit = useCallback(
(values: BreakfastSchema) => {
completeStep(values)
},
[completeStep]
)
useEffect(() => {
if (methods.formState.isSubmitting) {
return
}
const subscription = methods.watch(() => methods.handleSubmit(onSubmit)())
return () => subscription.unsubscribe()
}, [methods, onSubmit])
return (
<FormProvider {...methods}>
<form className={styles.form}>
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
<RadioCard
Icon={BreakfastIcon}
id={breakfastEnum.BREAKFAST}

View File

@@ -1,8 +1,11 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import Button from "@/components/TempDesignSystem/Button"
import CheckboxCard from "@/components/TempDesignSystem/Form/ChoiceCard/Checkbox"
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
@@ -19,6 +22,7 @@ import type {
DetailsSchema,
} from "@/types/components/enterDetails/details"
const formID = "enter-details"
export default function Details({ user }: DetailsProps) {
const intl = useIntl()
@@ -28,13 +32,21 @@ export default function Details({ user }: DetailsProps) {
{ title: intl.formatMessage({ id: "Join at no cost" }) },
]
const initialData = useEnterDetailsStore((state) => ({
countryCode: state.data.countryCode,
email: state.data.email,
firstname: state.data.firstname,
lastname: state.data.lastname,
phoneNumber: state.data.phoneNumber,
}))
const methods = useForm<DetailsSchema>({
defaultValues: {
countryCode: user?.address?.countryCode ?? "",
email: user?.email ?? "",
firstname: user?.firstName ?? "",
lastname: user?.lastName ?? "",
phoneNumber: user?.phoneNumber ?? "",
countryCode: user?.address?.countryCode ?? initialData.countryCode,
email: user?.email ?? initialData.email,
firstname: user?.firstName ?? initialData.firstname,
lastname: user?.lastName ?? initialData.lastname,
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
},
criteriaMode: "all",
mode: "all",
@@ -42,6 +54,15 @@ export default function Details({ user }: DetailsProps) {
reValidateMode: "onChange",
})
const completeStep = useEnterDetailsStore((state) => state.completeStep)
const onSubmit = useCallback(
(values: DetailsSchema) => {
completeStep(values)
},
[completeStep]
)
return (
<FormProvider {...methods}>
<section className={styles.container}>
@@ -50,7 +71,11 @@ export default function Details({ user }: DetailsProps) {
{intl.formatMessage({ id: "Guest information" })}
</Body>
</header>
<form className={styles.form}>
<form
className={styles.form}
id={formID}
onSubmit={methods.handleSubmit(onSubmit)}
>
<Input
label={intl.formatMessage({ id: "Firstname" })}
name="firstname"
@@ -106,9 +131,11 @@ export default function Details({ user }: DetailsProps) {
)}
<Button
disabled={!methods.formState.isValid}
form={formID}
intent="secondary"
size="small"
theme="base"
type="submit"
>
{intl.formatMessage({ id: "Proceed to payment method" })}
</Button>

View File

@@ -0,0 +1,26 @@
"use client"
import { PropsWithChildren, useRef } from "react"
import {
EnterDetailsContext,
type EnterDetailsStore,
initEditDetailsState,
} from "@/stores/enter-details"
import { StepEnum } from "@/types/components/enterDetails/step"
export default function EnterDetailsProvider({
step,
children,
}: PropsWithChildren<{ step: StepEnum }>) {
const initialStore = useRef<EnterDetailsStore>()
if (!initialStore.current) {
initialStore.current = initEditDetailsState(step)
}
return (
<EnterDetailsContext.Provider value={initialStore.current}>
{children}
</EnterDetailsContext.Provider>
)
}

View File

@@ -1,9 +1,11 @@
"use client"
import { useEffect, useRef } from "react"
import { useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import Button from "@/components/TempDesignSystem/Button"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -13,17 +15,22 @@ import { SectionAccordionProps } from "@/types/components/hotelReservation/selec
export default function SectionAccordion({
header,
isOpen,
isCompleted,
label,
path,
step,
children,
}: React.PropsWithChildren<SectionAccordionProps>) {
const intl = useIntl()
const [isComplete, setIsComplete] = useState(false)
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const isValid = useEnterDetailsStore((state) => state.isValid[step])
const navigate = useEnterDetailsStore((state) => state.navigate)
const contentRef = useRef<HTMLDivElement>(null)
const circleRef = useRef<HTMLDivElement>(null)
const isOpen = currentStep === step
useEffect(() => {
const content = contentRef.current
const circle = circleRef.current
@@ -44,15 +51,24 @@ export default function SectionAccordion({
}
}, [isOpen])
useEffect(() => {
// We need to set the state on mount because of hydration errors
setIsComplete(isValid)
}, [isValid])
function onModify() {
navigate(step)
}
return (
<section className={styles.wrapper} data-open={isOpen}>
<div className={styles.iconWrapper}>
<div
className={styles.circle}
data-checked={isCompleted}
data-checked={isComplete}
ref={circleRef}
>
{isCompleted ? (
{isComplete ? (
<CheckIcon color="white" height="16" width="16" />
) : null}
</div>
@@ -75,11 +91,18 @@ export default function SectionAccordion({
{label}
</Subtitle>
</div>
{isCompleted && !isOpen && (
<Link href={path} color="burgundy" variant="icon">
{isComplete && !isOpen && (
<Button
onClick={onModify}
theme="base"
size="small"
variant="icon"
intent="text"
wrapping
>
{intl.formatMessage({ id: "Modify" })}{" "}
<ChevronDownIcon color="burgundy" />
</Link>
</Button>
)}
</header>
<div className={styles.content} ref={contentRef}>

View File

@@ -17,6 +17,10 @@
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
.wrapper:last-child .main {
border-bottom: none;
}
.main {
display: flex;
flex-direction: column;
@@ -71,3 +75,7 @@
transition: max-height 0.4s ease-out;
max-height: 0;
}
.wrapper[data-open="true"] .content {
max-height: 1000px;
}

View File

@@ -0,0 +1,165 @@
import { dt } from "@/lib/dt"
import { ArrowRightIcon, ChevronRightSmallIcon } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./summary.module.css"
// TEMP
const rooms = [
{
adults: 1,
type: "Cozy cabin",
},
]
export default async function Summary() {
const intl = await getIntl()
const lang = getLang()
const fromDate = dt().locale(lang).format("ddd, D MMM")
const toDate = dt().add(1, "day").locale(lang).format("ddd, D MMM")
const diff = dt(toDate).diff(fromDate, "days")
const totalAdults = rooms.reduce((total, room) => total + room.adults, 0)
const adults = intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: totalAdults }
)
const nights = intl.formatMessage(
{ id: "booking.nights" },
{ totalNights: diff }
)
const addOns = [
{
price: intl.formatMessage({ id: "Included" }),
title: intl.formatMessage({ id: "King bed" }),
},
{
price: intl.formatMessage({ id: "Included" }),
title: intl.formatMessage({ id: "Breakfast buffet" }),
},
]
const mappedRooms = Array.from(
rooms
.reduce((acc, room) => {
const currentRoom = acc.get(room.type)
acc.set(room.type, {
total: currentRoom ? currentRoom.total + 1 : 1,
type: room.type,
})
return acc
}, new Map())
.values()
)
return (
<section className={styles.summary}>
<header>
<Body textTransform="bold">
{mappedRooms.map(
(room, idx) =>
`${room.total} x ${room.type}${mappedRooms.length > 1 && idx + 1 !== mappedRooms.length ? ", " : ""}`
)}
</Body>
<Body className={styles.date} color="textMediumContrast">
{fromDate}
<ArrowRightIcon color="uiTextMediumContrast" height={15} width={15} />
{toDate}
</Body>
<Link
className={styles.link}
color="baseButtonTextOnFillNormal"
href="#"
variant="icon"
>
{intl.formatMessage({ id: "See room details" })}
<ChevronRightSmallIcon
color="baseButtonTextOnFillNormal"
height={20}
width={20}
/>
</Link>
</header>
<Divider color="primaryLightSubtle" />
<div className={styles.addOns}>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{`${nights}, ${adults}`}
</Caption>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "4536", currency: "SEK" }
)}
</Caption>
</div>
{addOns.map((addOn) => (
<div className={styles.entry} key={addOn.title}>
<Caption color="uiTextMediumContrast">{addOn.title}</Caption>
<Caption color="uiTextHighContrast">{addOn.price}</Caption>
</div>
))}
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.total}>
<div>
<div className={styles.entry}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Total incl VAT" })}
</Body>
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "4686", currency: "SEK" }
)}
</Body>
</div>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}
</Caption>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "455", currency: "EUR" }
)}
</Caption>
</div>
</div>
<div>
<div className={styles.entry}>
<Body color="red" textTransform="bold">
{intl.formatMessage({ id: "Member price" })}
</Body>
<Body color="red" textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "4219", currency: "SEK" }
)}
</Body>
</div>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}
</Caption>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "412", currency: "EUR" }
)}
</Caption>
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,38 @@
.summary {
background-color: var(--Main-Grey-White);
border: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
border-radius: var(--Corner-radius-Large);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2);
}
.date {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: flex-start;
}
.link {
margin-top: var(--Spacing-x1);
}
.addOns {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
.entry {
display: flex;
gap: var(--Spacing-x-half);
justify-content: space-between;
}
.total {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}

View File

@@ -36,15 +36,18 @@
@media (min-width: 768px) {
.hotelSelectionHeader {
padding: var(--Spacing-x4) var(--Spacing-x5);
padding: var(--Spacing-x4) 0;
}
.hotelSelectionHeaderWrapper {
flex-direction: row;
gap: var(--Spacing-x6);
max-width: var(--max-width-navigation);
margin: 0 auto;
width: 100%;
/* simulates padding on viewport smaller than --max-width-navigation */
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.titleContainer > h1 {

View File

@@ -0,0 +1,43 @@
import Image from "next/image"
import { useFormContext } from "react-hook-form"
import { PAYMENT_METHOD_ICONS } from "@/constants/booking"
import Body from "@/components/TempDesignSystem/Text/Body"
import { PaymentOptionProps } from "./paymentOption"
import styles from "./paymentOption.module.css"
export default function PaymentOption({
name,
value,
label,
}: PaymentOptionProps) {
const { register } = useFormContext()
return (
<label key={value} className={styles.paymentOption} htmlFor={value}>
<div className={styles.titleContainer}>
<input
aria-hidden
hidden
type="radio"
id={value}
value={value}
{...register(name)}
/>
<span className={styles.radio} />
<Body asChild>
<label htmlFor={value}>{label}</label>
</Body>
</div>
<Image
className={styles.paymentOptionIcon}
src={PAYMENT_METHOD_ICONS[value]}
alt={label}
width={48}
height={32}
/>
</label>
)
}

View File

@@ -0,0 +1,37 @@
.paymentOption {
position: relative;
background-color: var(--UI-Input-Controls-Surface-Normal);
padding: var(--Spacing-x3);
border: 1px solid var(--Base-Border-Normal);
border-radius: var(--Corner-radius-Medium);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--Spacing-x2);
cursor: pointer;
}
.paymentOption .radio {
width: 24px;
height: 24px;
border: 1px solid var(--Base-Border-Normal);
border-radius: 50%;
cursor: pointer;
}
.paymentOption input:checked + .radio {
border: 8px solid var(--UI-Input-Controls-Fill-Selected);
}
.titleContainer {
display: flex;
align-items: center;
gap: var(--Spacing-x-one-and-half);
pointer-events: none;
}
.paymentOptionIcon {
position: absolute;
right: var(--Spacing-x3);
top: calc(50% - 16px);
}

View File

@@ -0,0 +1,10 @@
import { RegisterOptions } from "react-hook-form"
import { PaymentMethodEnum } from "@/constants/booking"
export interface PaymentOptionProps {
name: string
value: PaymentMethodEnum
label: string
registerOptions?: RegisterOptions
}

View File

@@ -1,21 +1,36 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import { Label as AriaLabel } from "react-aria-components"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import {
BOOKING_CONFIRMATION_NUMBER,
BookingStatusEnum,
PAYMENT_METHOD_TITLES,
PaymentMethodEnum,
} from "@/constants/booking"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import useLang from "@/hooks/useLang"
import PaymentOption from "./PaymentOption"
import { PaymentFormData, paymentSchema } from "./schema"
import styles from "./payment.module.css"
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
@@ -28,17 +43,21 @@ export default function Payment({ hotel }: PaymentProps) {
const lang = useLang()
const intl = useIntl()
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>("")
const methods = useForm<PaymentFormData>({
defaultValues: {
paymentMethod: PaymentMethodEnum.card,
smsConfirmation: false,
termsAndConditions: false,
},
mode: "all",
reValidateMode: "onChange",
resolver: zodResolver(paymentSchema),
})
const initiateBooking = trpc.booking.booking.create.useMutation({
onSuccess: (result) => {
if (result?.confirmationNumber) {
// Planet doesn't support query params so we have to store values in session storage
sessionStorage.setItem(
BOOKING_CONFIRMATION_NUMBER,
result.confirmationNumber
)
setConfirmationNumber(result.confirmationNumber)
} else {
// TODO: add proper error message
@@ -60,12 +79,14 @@ export default function Payment({ hotel }: PaymentProps) {
)
useEffect(() => {
if (bookingStatus?.data?.paymentUrl) {
if (confirmationNumber && bookingStatus?.data?.paymentUrl) {
// Planet doesn't support query params so we have to store values in session storage
sessionStorage.setItem(BOOKING_CONFIRMATION_NUMBER, confirmationNumber)
router.push(bookingStatus.data.paymentUrl)
}
}, [bookingStatus, router])
}, [confirmationNumber, bookingStatus, router])
function handleSubmit() {
function handleSubmit(data: PaymentFormData) {
initiateBooking.mutate({
hotelId: hotel.operaId,
checkInDate: "2024-12-10",
@@ -91,11 +112,11 @@ export default function Payment({ hotel }: PaymentProps) {
petFriendly: true,
accessibility: true,
},
smsConfirmationRequested: true,
smsConfirmationRequested: data.smsConfirmation,
},
],
payment: {
paymentMethod: selectedPaymentMethod,
paymentMethod: data.paymentMethod,
cardHolder: {
email: "test.user@scandichotels.com",
name: "Test User",
@@ -117,45 +138,80 @@ export default function Payment({ hotel }: PaymentProps) {
}
return (
<div>
<div>
<div className={styles.paymentItemContainer}>
<button
className={styles.paymentItem}
onClick={() => setSelectedPaymentMethod("card")}
>
<input
type="radio"
name="payment-method"
id="card"
value="card"
defaultChecked={selectedPaymentMethod === "card"}
/>
<label htmlFor="card">card</label>
</button>
<FormProvider {...methods}>
<form
className={styles.paymentContainer}
onSubmit={methods.handleSubmit(handleSubmit)}
>
<div className={styles.paymentOptionContainer}>
<PaymentOption
name="paymentMethod"
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
/>
{hotel.merchantInformationData.alternatePaymentOptions.map(
(paymentOption) => (
<button
key={paymentOption}
className={styles.paymentItem}
onClick={() => setSelectedPaymentMethod(paymentOption)}
>
<input
type="radio"
name="payment-method"
id={paymentOption}
value={paymentOption}
defaultChecked={selectedPaymentMethod === paymentOption}
/>
<label htmlFor={paymentOption}>{paymentOption}</label>
</button>
(paymentMethod) => (
<PaymentOption
key={paymentMethod}
name="paymentMethod"
value={paymentMethod as PaymentMethodEnum}
label={
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
}
/>
)
)}
</div>
</div>
<Button disabled={!selectedPaymentMethod} onClick={handleSubmit}>
{intl.formatMessage({ id: "Complete booking & go to payment" })}
</Button>
</div>
<Checkbox name="smsConfirmation">
<Caption>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</Caption>
</Checkbox>
<AriaLabel className={styles.terms}>
<Checkbox name="termsAndConditions" />
<Caption>
{intl.formatMessage<React.ReactNode>(
{
id: "booking.terms",
},
{
termsLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
>
{str}
</Link>
),
privacyLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"
>
{str}
</Link>
),
}
)}
</Caption>
</AriaLabel>
<Button
type="submit"
className={styles.submitButton}
disabled={
!methods.formState.isValid || methods.formState.isSubmitting
}
>
{intl.formatMessage({ id: "Complete booking & go to payment" })}
</Button>
</form>
</FormProvider>
)
}

View File

@@ -1,18 +1,27 @@
.paymentItemContainer {
max-width: 480px;
.paymentContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
padding-bottom: var(--Spacing-x4);
gap: var(--Spacing-x3);
max-width: 480px;
}
.paymentItem {
background-color: var(--Base-Background-Normal);
padding: var(--Spacing-x3);
border: 1px solid var(--Base-Border-Normal);
border-radius: var(--Corner-radius-Medium);
.paymentOptionContainer {
display: flex;
align-items: center;
gap: var(--Spacing-x2);
cursor: pointer;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.submitButton {
align-self: flex-start;
}
.paymentContainer .link {
font-weight: 500;
font-size: var(--Typography-Caption-Regular-fontSize);
}
.terms {
display: flex;
flex-direction: row;
gap: var(--Spacing-x-one-and-half);
}

View File

@@ -0,0 +1,13 @@
import { z } from "zod"
import { PaymentMethodEnum } from "@/constants/booking"
export const paymentSchema = z.object({
paymentMethod: z.nativeEnum(PaymentMethodEnum),
smsConfirmation: z.boolean(),
termsAndConditions: z.boolean().refine((value) => value === true, {
message: "You must accept the terms and conditions",
}),
})
export interface PaymentFormData extends z.output<typeof paymentSchema> {}

View File

@@ -0,0 +1,36 @@
import { Button, Dialog, OverlayArrow, Popover } from "react-aria-components"
import { CloseIcon } from "@/components/Icons"
import styles from "./popover.module.css"
import { PricePopoverProps } from "@/types/components/hotelReservation/selectRate/pricePopover"
export default function PricePopover({
children,
...props
}: PricePopoverProps) {
return (
<Popover {...props}>
<OverlayArrow className={styles.arrow}>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
style={{ display: "block", transform: "rotate(180deg)" }}
>
<path d="M0 0L6 6L12 0" fill="white" />
</svg>
</OverlayArrow>
<Dialog>
<Button
onPress={() => props.onOpenChange?.(false)}
className={styles.closeButton}
>
<CloseIcon className={styles.closeIcon} />
</Button>
{children}
</Dialog>
</Popover>
)
}

View File

@@ -0,0 +1,12 @@
.arrow {
top: -6px;
}
.closeButton {
position: absolute;
top: 5px;
right: 5px;
background: none;
border: none;
cursor: pointer;
}

View File

@@ -0,0 +1,102 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./priceList.module.css"
import { PriceListProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
export default function PriceList({
publicPrice = {},
memberPrice = {},
}: PriceListProps) {
const intl = useIntl()
const { localPrice: publicLocalPrice, requestedPrice: publicRequestedPrice } =
publicPrice
const { localPrice: memberLocalPrice, requestedPrice: memberRequestedPrice } =
memberPrice
const showRequestedPrice = publicRequestedPrice && memberRequestedPrice
return (
<dl className={styles.priceList}>
<div className={styles.priceRow}>
<dt>
<Caption
textTransform="bold"
color={publicLocalPrice ? "uiTextHighContrast" : "disabled"}
>
{intl.formatMessage({ id: "Standard price" })}
</Caption>
</dt>
<dd>
{publicLocalPrice ? (
<div className={styles.price}>
<Subtitle type="two" color="uiTextHighContrast">
{publicLocalPrice.pricePerNight}
</Subtitle>
<Body color="uiTextHighContrast" textTransform="bold">
{publicLocalPrice.currency}
</Body>
</div>
) : (
<Subtitle type="two" color="disabled">
{intl.formatMessage({ id: "n/a" })}
</Subtitle>
)}
</dd>
</div>
<div className={styles.priceRow}>
<dt>
<Caption
textTransform="bold"
color={memberLocalPrice ? "red" : "disabled"}
>
{intl.formatMessage({ id: "Member price" })}
</Caption>
</dt>
<dd>
{memberLocalPrice ? (
<div className={styles.price}>
<Subtitle type="two" color="red">
{memberLocalPrice.pricePerNight}
</Subtitle>
<Body color="red" textTransform="bold">
{memberLocalPrice.currency}
</Body>
</div>
) : (
<Body textTransform="bold" color="disabled">
- {intl.formatMessage({ id: "Currency Code" })}
</Body>
)}
</dd>
</div>
<div className={styles.priceRow}>
<dt>
<Caption
color={showRequestedPrice ? "uiTextMediumContrast" : "disabled"}
>
{intl.formatMessage({ id: "Approx." })}
</Caption>
</dt>
<dd>
{showRequestedPrice ? (
<Caption color="uiTextMediumContrast">
{publicRequestedPrice.pricePerNight}/
{memberRequestedPrice.pricePerNight}{" "}
{publicRequestedPrice.currency}
</Caption>
) : (
<Caption color="disabled">- / - EUR</Caption>
)}
</dd>
</div>
</dl>
)
}

View File

@@ -0,0 +1,14 @@
.priceRow {
display: flex;
justify-content: space-between;
padding: var(--Spacing-x-quarter) 0;
}
.priceTable {
margin: 0;
}
.price {
display: flex;
gap: var(--Spacing-x-half);
}

View File

@@ -1,15 +1,80 @@
.card {
font-size: 14px;
border-radius: var(--Corner-radius-Medium);
border: 1px solid var(--Base-Border-Normal);
.card,
.disabledCard {
border-radius: var(--Corner-radius-Large);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
background-color: var(--Base-Surface-Secondary-light-Normal);
position: relative;
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
input[type="radio"]:checked + .card {
.disabledCard {
opacity: 0.6;
}
.disabledCard:hover {
cursor: not-allowed;
}
.card:hover {
cursor: pointer;
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
.checkIcon {
display: none;
}
input[type="radio"]:checked + .card {
border: 1px solid var(--Primary-Dark-On-Surface-Divider);
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
input[type="radio"]:checked + .card .checkIcon {
display: block;
position: absolute;
top: -10px;
right: -10px;
}
.header {
display: flex;
justify-content: space-between;
gap: var(--Spacing-x-half);
}
.header .infoIcon,
.header .infoIcon path {
stroke: var(--UI-Text-Medium-contrast);
fill: transparent;
}
.button {
background: none;
border: none;
cursor: pointer;
grid-area: chevron;
height: 100%;
justify-self: flex-end;
padding: 0;
}
.popover {
background-color: var(--Main-Grey-White);
border-radius: var(--Corner-radius-Medium);
left: 0px;
max-height: 400px;
padding: var(--Spacing-x2);
top: calc(55px + var(--Spacing-x1));
width: 100%;
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
}
.popover section:focus-visible {
outline: none;
}
.popover .popoverText {
margin-bottom: var(--Spacing-x-half);
}
.popover .popoverHeading {
margin-bottom: var(--Spacing-x1);
font-weight: 600; /* TODO: Remove when this is updated in Design system */
}

View File

@@ -1,9 +1,13 @@
"use client"
import { useIntl } from "react-intl"
import { useState } from "react"
import { Button, DialogTrigger } from "react-aria-components"
import Body from "@/components/TempDesignSystem/Text/Body"
import { CheckCircleIcon, InfoCircleIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import PricePopover from "./Popover"
import PriceTable from "./PriceList"
import styles from "./flexibilityOption.module.css"
import { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
@@ -12,59 +16,90 @@ export default function FlexibilityOption({
product,
name,
paymentTerm,
priceInformation,
}: FlexibilityOptionProps) {
const intl = useIntl()
const [rootDiv, setRootDiv] = useState<Element | undefined>(undefined)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
if (!product) {
// TODO: Implement empty state when this rate can't be booked
return <div>TBI: Rate not available</div>
function setRef(node: Element | null) {
if (node) {
setRootDiv(node)
}
}
const { productType } = product
const { public: publicPrice, member: memberPrice } = productType
const { localPrice: publicLocalPrice, requestedPrice: publicRequestedPrice } =
publicPrice
const { localPrice: memberLocalPrice, requestedPrice: memberRequestedPrice } =
memberPrice
if (!product) {
return (
<div className={styles.disabledCard}>
<div className={styles.header}>
<InfoCircleIcon className={styles.infoIcon} />
<Caption color="disabled">{name}</Caption>
<Caption color="disabled">({paymentTerm})</Caption>
</div>
<PriceTable />
</div>
)
}
const { public: publicPrice, member: memberPrice } = product.productType
return (
<label>
<input
type="radio"
name="rateCode"
value={product.productType.public.rateCode}
/>
<input type="radio" name="rateCode" value={publicPrice?.rateCode} />
<div className={styles.card}>
<div className={styles.header}>
<Body>{name}</Body>
<Caption>{paymentTerm}</Caption>
<div className={styles.header} ref={setRef}>
<DialogTrigger>
<Button
aria-label="Help"
className={styles.button}
onPress={() => setIsPopoverOpen(true)}
>
<InfoCircleIcon className={styles.infoIcon} />
</Button>
<PricePopover
placement="bottom"
className={styles.popover}
isNonModal
shouldFlip={false}
shouldUpdatePosition={false}
/**
* react-aria uses portals to render Popover in body
* unless otherwise specified. We need it to be contained
* by this component to both access css variables assigned
* on the container as well as to not overflow it at any time.
*/
UNSTABLE_portalContainer={rootDiv}
isOpen={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<Caption
color="uiTextHighContrast"
textTransform="bold"
className={styles.popoverHeading}
>
{name}
</Caption>
{priceInformation?.map((info) => (
<Caption
key={info}
color="uiTextHighContrast"
className={styles.popoverText}
>
{info}
</Caption>
))}
</PricePopover>
</DialogTrigger>
<Caption color="uiTextHighContrast">{name}</Caption>
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
</div>
<dl>
<div>
<dt>{intl.formatMessage({ id: "Standard price" })}</dt>
<dd>
{publicLocalPrice.pricePerNight} {publicLocalPrice.currency}/
{intl.formatMessage({ id: "night" })}
</dd>
</div>
<div>
<dt>{intl.formatMessage({ id: "Member price" })}</dt>
<dd>
{memberLocalPrice.pricePerNight} {memberLocalPrice.currency}/
{intl.formatMessage({ id: "night" })}
</dd>
</div>
{publicRequestedPrice && memberRequestedPrice && (
<div>
<dt>{intl.formatMessage({ id: "Approx." })}</dt>
<dd>
{publicRequestedPrice.pricePerNight}/
{memberRequestedPrice.pricePerNight}{" "}
{publicRequestedPrice.currency}
</dd>
</div>
)}
</dl>
<PriceTable publicPrice={publicPrice} memberPrice={memberPrice} />
<CheckCircleIcon
color="blue"
className={styles.checkIcon}
width={24}
height={24}
stroke="white"
/>
</div>
</label>
)

View File

@@ -1,9 +1,16 @@
"use client"
import { useIntl } from "react-intl"
import { RateDefinition } from "@/server/routers/hotels/output"
import FlexibilityOption from "@/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption"
import { ChevronRightSmallIcon, GalleryIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Lightbox from "@/components/Lightbox"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./roomCard.module.css"
@@ -13,6 +20,7 @@ import { RoomCardProps } from "@/types/components/hotelReservation/selectRate/ro
export default function RoomCard({
rateDefinitions,
roomConfiguration,
roomCategories,
}: RoomCardProps) {
const intl = useIntl()
@@ -29,90 +37,134 @@ export default function RoomCard({
(rate) => rate.cancellationRule === "CancellableBefore6PM"
)
const saveProduct = saveRate
? roomConfiguration.products.find(
(product) =>
product.productType.public.rateCode === saveRate.rateCode ||
product.productType.member.rateCode === saveRate.rateCode
)
: undefined
const changeProduct = changeRate
? roomConfiguration.products.find(
(product) =>
product.productType.public.rateCode === changeRate.rateCode ||
product.productType.member.rateCode === changeRate.rateCode
)
: undefined
const flexProduct = flexRate
? roomConfiguration.products.find(
(product) =>
product.productType.public.rateCode === flexRate.rateCode ||
product.productType.member.rateCode === flexRate.rateCode
)
: undefined
function findProductForRate(rate: RateDefinition | undefined) {
return rate
? roomConfiguration.products.find(
(product) =>
product.productType.public?.rateCode === rate.rateCode ||
product.productType.member?.rateCode === rate.rateCode
)
: undefined
}
function getPriceForRate(
rate: typeof saveRate | typeof changeRate | typeof flexRate
) {
return rateDefinitions.find((def) => def.rateCode === rate?.rateCode)
?.generalTerms
}
const selectedRoom = roomCategories.find(
(room) => room.name === roomConfiguration.roomType
)
const roomSize = selectedRoom?.roomSize
const occupancy = selectedRoom?.occupancy.total
const roomDescription = selectedRoom?.descriptions.short
const images = selectedRoom?.images
const mainImage = images?.[0]
return (
<div className={styles.card}>
<div className={styles.cardBody}>
<div className={styles.specification}>
<Subtitle className={styles.name} type="two">
{roomConfiguration.roomType}
</Subtitle>
<Caption>Room size TBI</Caption>
<Button intent="text" type="button" size="small" theme="base">
{intl.formatMessage({ id: "See room details" })}
</Button>
<Caption>
<Caption color="uiTextMediumContrast" className={styles.guests}>
{/*TODO: Handle pluralisation*/}
{intl.formatMessage(
{
id: "Max {nrOfGuests} guests",
defaultMessage: "Max {nrOfGuests} guests",
id: "booking.guests",
},
// TODO: Correct number
{ nrOfGuests: 2 }
{ nrOfGuests: occupancy }
)}
</Caption>
<Caption color="uiTextMediumContrast">
{roomSize?.min === roomSize?.max
? roomSize?.min
: `${roomSize?.min}-${roomSize?.max}`}
m²
</Caption>
<Button
intent="text"
type="button"
size="small"
theme="base"
className={styles.button}
>
{intl.formatMessage({ id: "See room details" })}
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
</Button>
</div>
<div className={styles.container}>
<div className={styles.roomDetails}>
<Subtitle className={styles.name} type="two">
{roomConfiguration.roomType}
</Subtitle>
<Body>{roomDescription}</Body>
</div>
<Caption color="uiTextHighContrast">
{intl.formatMessage({
id: "Breakfast included",
id: "Breakfast selection in next step.",
})}
</Caption>
<div className={styles.flexibilityOptions}>
<FlexibilityOption
name={intl.formatMessage({ id: "Non-refundable" })}
value="non-refundable"
paymentTerm={intl.formatMessage({ id: "Pay now" })}
product={findProductForRate(saveRate)}
priceInformation={getPriceForRate(saveRate)}
/>
<FlexibilityOption
name={intl.formatMessage({ id: "Free rebooking" })}
value="free-rebooking"
paymentTerm={intl.formatMessage({ id: "Pay now" })}
product={findProductForRate(changeRate)}
priceInformation={getPriceForRate(changeRate)}
/>
<FlexibilityOption
name={intl.formatMessage({ id: "Free cancellation" })}
value="free-cancellation"
paymentTerm={intl.formatMessage({ id: "Pay later" })}
product={findProductForRate(flexRate)}
priceInformation={getPriceForRate(flexRate)}
/>
</div>
</div>
<FlexibilityOption
name={intl.formatMessage({ id: "Non-refundable" })}
value="non-refundable"
paymentTerm={intl.formatMessage({ id: "Pay now" })}
product={saveProduct}
/>
<FlexibilityOption
name={intl.formatMessage({ id: "Free rebooking" })}
value="free-rebooking"
paymentTerm={intl.formatMessage({ id: "Pay now" })}
product={changeProduct}
/>
<FlexibilityOption
name={intl.formatMessage({ id: "Free cancellation" })}
value="free-cancellation"
paymentTerm={intl.formatMessage({ id: "Pay later" })}
product={flexProduct}
/>
<Button
type="submit"
size="small"
theme="primaryDark"
className={styles.button}
>
{intl.formatMessage({ id: "Choose room" })}
</Button>
</div>
{/* TODO: maybe use the `Image` component instead of the `img` tag. Waiting until we know how to get the image */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt={intl.formatMessage({ id: "A photo of the room" })}
// TODO: Correct image URL
src="https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg"
/>
{mainImage && (
<div className={styles.imageContainer}>
{roomConfiguration.roomsLeft < 5 && (
<span className={styles.roomsLeft}>
<Footnote
color="burgundy"
textTransform="uppercase"
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
</span>
)}
{/*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. */}
<Image
src={mainImage.imageSizes.small}
alt={mainImage.metaData.altText}
width={330}
height={185}
/>
{images && (
<Lightbox
images={images.map((image) => ({
url: image.imageSizes.small,
alt: image.metaData.altText,
title: image.metaData.title,
}))}
dialogTitle={roomConfiguration.roomType}
>
<div className={styles.galleryIcon} id="lightboxTrigger">
<GalleryIcon color="white" />
<Footnote color="white">{images.length}</Footnote>
</div>
</Lightbox>
)}
</div>
)}
</div>
)
}

View File

@@ -3,34 +3,91 @@
display: flex;
flex-direction: column-reverse;
background-color: #fff;
border-radius: var(--Corner-radius-Small);
border: 1px solid rgba(77, 0, 27, 0.1);
border-radius: var(--Corner-radius-Large);
border: 1px solid var(--Base-Border-Subtle);
position: relative;
}
.cardBody {
padding: var(--Spacing-x1);
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
.specification {
padding: var(--Spacing-x1);
display: flex;
flex-direction: row;
align-items: center;
gap: var(--Spacing-x1);
padding: 0 var(--Spacing-x1) 0 var(--Spacing-x-one-and-half);
height: 40px;
}
.specification .guests {
border-right: 1px solid var(--Base-Border-Subtle);
padding-right: var(--Spacing-x1);
}
.specification .button {
margin-left: auto;
padding: 0 0 0 var(--Spacing-x-half);
text-decoration: none;
}
.container {
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x2);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.roomDetails {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
margin-bottom: var(--Spacing-x2);
}
.name {
display: inline-block;
}
.card .button {
display: inline;
}
.card img {
max-width: 100%;
aspect-ratio: 1.5;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
}
.flexibilityOptions {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.roomsLeft {
position: absolute;
top: 12px;
left: 12px;
background-color: var(--Main-Grey-White);
padding: var(--Spacing-x-half) var(--Spacing-x1);
border-radius: var(--Corner-radius-Small);
}
.imageContainer {
min-height: 185px;
position: relative;
}
.galleryIcon {
position: absolute;
bottom: 16px;
right: 16px;
height: 24px;
background-color: rgba(64, 57, 55, 0.9);
padding: 0 var(--Spacing-x-half);
border-radius: var(--Corner-radius-Small);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--Spacing-x-quarter);
}

View File

@@ -1,8 +1,5 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import RoomCard from "./RoomCard"
@@ -12,10 +9,10 @@ import { RoomSelectionProps } from "@/types/components/hotelReservation/selectRa
export default function RoomSelection({
roomConfigurations,
roomCategories,
}: RoomSelectionProps) {
const router = useRouter()
const searchParams = useSearchParams()
const intl = useIntl()
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
@@ -38,16 +35,17 @@ export default function RoomSelection({
<RoomCard
rateDefinitions={roomConfigurations.rateDefinitions}
roomConfiguration={roomConfiguration}
roomCategories={roomCategories}
/>
</li>
))}
</ul>
<div className={styles.summary}>
{/* <div className={styles.summary}>
This is summary
<Button type="submit" size="small" theme="primaryDark">
{intl.formatMessage({ id: "Choose room" })}
</Button>
</div>
</div> */}
</form>
</div>
)

View File

@@ -1,5 +1,4 @@
.wrapper {
border-bottom: 1px solid rgba(17, 17, 17, 0.2);
padding-bottom: var(--Spacing-x3);
}
@@ -7,9 +6,8 @@
margin-top: var(--Spacing-x4);
list-style: none;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
column-gap: var(--Spacing-x2);
row-gap: var(--Spacing-x4);
grid-template-columns: 1fr;
gap: var(--Spacing-x3);
}
.roomList > li {
@@ -30,3 +28,15 @@
background-color: white;
padding: var(--Spacing-x3) var(--Spacing-x7) var(--Spacing-x5);
}
@media (min-width: 767px) {
.roomList {
grid-template-columns: repeat(3, minmax(240px, 1fr));
}
}
@media (min-width: 1367px) {
.roomList {
grid-template-columns: repeat(4, 1fr);
}
}

View File

@@ -1,6 +0,0 @@
"use client"
import styles from "./summary.module.css"
export default function Summary() {
return <div className={styles.wrapper}>Summary TBI</div>
}

View File

@@ -1,2 +0,0 @@
.wrapper {
}

View File

@@ -0,0 +1,36 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function EyeHideIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask
id="mask0_69_3263"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_69_3263)">
<path
d="M15.9625 13.3L14.6 11.9375C14.7834 11.0335 14.5521 10.2616 13.9063 9.62196C13.2604 8.98231 12.4792 8.74165 11.5625 8.89999L10.2 7.53749C10.4834 7.40832 10.775 7.31145 11.075 7.24686C11.375 7.18228 11.6834 7.14999 12 7.14999C13.2084 7.14999 14.2354 7.5729 15.0813 8.41874C15.9271 9.26457 16.35 10.2917 16.35 11.5C16.35 11.8167 16.3177 12.125 16.2531 12.425C16.1886 12.725 16.0917 13.0167 15.9625 13.3ZM19.05 16.3625L17.7125 15.05C18.3485 14.5667 18.9176 14.0292 19.4197 13.4375C19.9218 12.8458 20.3403 12.2 20.675 11.5C19.8584 9.83332 18.6709 8.51249 17.1125 7.53749C15.5542 6.56249 13.85 6.07499 12 6.07499C11.5167 6.07499 11.0396 6.1104 10.5688 6.18124C10.0979 6.25207 9.62919 6.35832 9.16252 6.49999L7.70002 5.03749C8.39169 4.75415 9.09591 4.54374 9.81267 4.40624C10.5294 4.26874 11.2585 4.19999 12 4.19999C14.2167 4.19999 16.25 4.78749 18.1 5.96249C19.95 7.13749 21.3625 8.71665 22.3375 10.7C22.4042 10.825 22.4521 10.9542 22.4813 11.0877C22.5104 11.2212 22.525 11.3586 22.525 11.5C22.525 11.6413 22.5125 11.7788 22.4875 11.9122C22.4625 12.0457 22.4209 12.1792 22.3625 12.3125C21.9875 13.1208 21.5188 13.8646 20.9563 14.5437C20.3938 15.2229 19.7584 15.8292 19.05 16.3625ZM12 18.8C9.81669 18.8 7.82502 18.2083 6.02502 17.025C4.22502 15.8417 2.79942 14.2901 1.74822 12.3702C1.66609 12.2401 1.60836 12.1013 1.57502 11.9539C1.54169 11.8064 1.52502 11.6552 1.52502 11.5C1.52502 11.3423 1.54077 11.1926 1.57225 11.0507C1.60373 10.9088 1.65882 10.7669 1.73752 10.625C2.07919 9.96665 2.46877 9.33332 2.90627 8.72499C3.34377 8.11665 3.84169 7.56665 4.40002 7.07499L2.32502 4.92499C2.15002 4.74054 2.06669 4.52151 2.07502 4.26791C2.08336 4.0143 2.17502 3.79999 2.35002 3.62499C2.52502 3.44999 2.74169 3.36249 3.00002 3.36249C3.25836 3.36249 3.47502 3.44999 3.65002 3.62499L20.3375 20.3125C20.5209 20.4958 20.6125 20.7167 20.6125 20.975C20.6125 21.2333 20.5167 21.4542 20.325 21.6375C20.1417 21.8125 19.9229 21.8979 19.6688 21.8937C19.4146 21.8896 19.2 21.8 19.025 21.625L15.5875 18.2375C15.0042 18.4208 14.4139 18.5604 13.8166 18.6562C13.2194 18.7521 12.6139 18.8 12 18.8ZM5.72502 8.37499C5.22502 8.80832 4.77086 9.2854 4.36252 9.80624C3.95419 10.3271 3.60836 10.8917 3.32502 11.5C4.14169 13.1667 5.32919 14.4875 6.88752 15.4625C8.44586 16.4375 10.15 16.925 12 16.925C12.3535 16.925 12.6981 16.9042 13.0339 16.8625C13.3696 16.8208 13.7084 16.7667 14.05 16.7L13.1 15.7C12.9167 15.75 12.7363 15.7875 12.5589 15.8125C12.3815 15.8375 12.1952 15.85 12 15.85C10.7917 15.85 9.76461 15.4271 8.91877 14.5812C8.07294 13.7354 7.65002 12.7083 7.65002 11.5C7.65002 11.3167 7.66252 11.1375 7.68752 10.9625C7.71252 10.7875 7.75002 10.6083 7.80002 10.425L5.72502 8.37499Z"
fill="#26201E"
/>
</g>
</svg>
)
}

View File

@@ -0,0 +1,36 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function EyeShowIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask
id="mask0_69_3264"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_69_3264)">
<path
d="M12 15.85C13.2083 15.85 14.2354 15.4271 15.0812 14.5813C15.9271 13.7354 16.35 12.7083 16.35 11.5C16.35 10.2917 15.9271 9.2646 15.0812 8.41876C14.2354 7.57293 13.2083 7.15001 12 7.15001C10.7916 7.15001 9.76456 7.57293 8.91873 8.41876C8.07289 9.2646 7.64998 10.2917 7.64998 11.5C7.64998 12.7083 8.07289 13.7354 8.91873 14.5813C9.76456 15.4271 10.7916 15.85 12 15.85ZM12.0029 14.15C11.2676 14.15 10.6416 13.8927 10.125 13.378C9.60831 12.8632 9.34998 12.2382 9.34998 11.503C9.34998 10.7677 9.60733 10.1417 10.122 9.62501C10.6367 9.10835 11.2617 8.85001 11.997 8.85001C12.7323 8.85001 13.3583 9.10736 13.875 9.62206C14.3916 10.1368 14.65 10.7618 14.65 11.4971C14.65 12.2324 14.3926 12.8583 13.8779 13.375C13.3632 13.8917 12.7382 14.15 12.0029 14.15ZM12.0019 18.8C9.8256 18.8 7.83956 18.2125 6.04373 17.0375C4.24789 15.8625 2.82498 14.3167 1.77498 12.4C1.69164 12.2583 1.63123 12.1124 1.59373 11.9622C1.55623 11.812 1.53748 11.6578 1.53748 11.4997C1.53748 11.3416 1.55623 11.1875 1.59373 11.0375C1.63123 10.8875 1.69164 10.7417 1.77498 10.6C2.82498 8.68335 4.24727 7.13751 6.04185 5.96251C7.83645 4.78751 9.82187 4.20001 11.9981 4.20001C14.1744 4.20001 16.1604 4.78751 17.9562 5.96251C19.7521 7.13751 21.175 8.68335 22.225 10.6C22.3083 10.7417 22.3687 10.8876 22.4062 11.0378C22.4437 11.1881 22.4625 11.3422 22.4625 11.5003C22.4625 11.6585 22.4437 11.8125 22.4062 11.9625C22.3687 12.1125 22.3083 12.2583 22.225 12.4C21.175 14.3167 19.7527 15.8625 17.9581 17.0375C16.1635 18.2125 14.1781 18.8 12.0019 18.8ZM11.9999 16.925C13.8583 16.925 15.5646 16.4375 17.1187 15.4625C18.6729 14.4875 19.8583 13.1667 20.675 11.5C19.8583 9.83335 18.6729 8.51251 17.1188 7.53751C15.5647 6.56251 13.8584 6.07501 12 6.07501C10.1417 6.07501 8.43539 6.56251 6.88123 7.53751C5.32706 8.51251 4.14164 9.83335 3.32498 11.5C4.14164 13.1667 5.32704 14.4875 6.88118 15.4625C8.43529 16.4375 10.1415 16.925 11.9999 16.925Z"
fill="#26201E"
/>
</g>
</svg>
)
}

View File

@@ -0,0 +1,36 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function GalleryIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<mask
id="mask0_69_3274"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_5887_18142)">
<path
d="M9.15 13.9H18.65L15.3725 9.625L13.1875 12.475L11.715 10.575L9.15 13.9ZM8.2 17.7C7.6775 17.7 7.23021 17.514 6.85813 17.1419C6.48604 16.7698 6.3 16.3225 6.3 15.8V4.4C6.3 3.8775 6.48604 3.43021 6.85813 3.05813C7.23021 2.68604 7.6775 2.5 8.2 2.5H19.6C20.1225 2.5 20.5698 2.68604 20.9419 3.05813C21.314 3.43021 21.5 3.8775 21.5 4.4V15.8C21.5 16.3225 21.314 16.7698 20.9419 17.1419C20.5698 17.514 20.1225 17.7 19.6 17.7H8.2ZM8.2 15.8H19.6V4.4H8.2V15.8ZM4.4 21.5C3.8775 21.5 3.43021 21.314 3.05813 20.9419C2.68604 20.5698 2.5 20.1225 2.5 19.6V6.3H4.4V19.6H17.7V21.5H4.4Z"
fill="white"
/>
</g>
</svg>
)
}

View File

@@ -21,7 +21,7 @@ import {
ChevronRightIcon,
ChevronRightSmallIcon,
CloseIcon,
CloseLarge,
CloseLargeIcon,
CoffeeIcon,
ConciergeIcon,
CrossCircle,
@@ -29,7 +29,10 @@ import {
DoorOpenIcon,
ElectricBikeIcon,
EmailIcon,
EyeHideIcon,
EyeShowIcon,
FitnessIcon,
GalleryIcon,
GiftIcon,
GlobeIcon,
HouseIcon,
@@ -101,7 +104,7 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
case IconName.Close:
return CloseIcon
case IconName.CloseLarge:
return CloseLarge
return CloseLargeIcon
case IconName.Coffee:
return CoffeeIcon
case IconName.Concierge:
@@ -114,10 +117,16 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
return ElectricBikeIcon
case IconName.Email:
return EmailIcon
case IconName.EyeHide:
return EyeHideIcon
case IconName.EyeShow:
return EyeShowIcon
case IconName.Facebook:
return FacebookIcon
case IconName.Fitness:
return FitnessIcon
case IconName.Gallery:
return GalleryIcon
case IconName.Gift:
return GiftIcon
case IconName.Globe:

View File

@@ -71,3 +71,8 @@
.baseButtonTertiaryOnFillNormal * {
fill: var(--Base-Button-Tertiary-On-Fill-Normal);
}
.baseButtonTextOnFillNormal,
.baseButtonTextOnFillNormal * {
fill: var(--Base-Button-Text-On-Fill-Normal);
}

View File

@@ -16,7 +16,7 @@ export { default as ChevronLeftIcon } from "./ChevronLeft"
export { default as ChevronRightIcon } from "./ChevronRight"
export { default as ChevronRightSmallIcon } from "./ChevronRightSmall"
export { default as CloseIcon } from "./Close"
export { default as CloseLarge } from "./CloseLarge"
export { default as CloseLargeIcon } from "./CloseLarge"
export { default as CoffeeIcon } from "./Coffee"
export { default as ConciergeIcon } from "./Concierge"
export { default as CreditCard } from "./CreditCard"
@@ -28,7 +28,10 @@ export { default as EditIcon } from "./Edit"
export { default as ElectricBikeIcon } from "./ElectricBike"
export { default as EmailIcon } from "./Email"
export { default as ErrorCircleIcon } from "./ErrorCircle"
export { default as EyeHideIcon } from "./EyeHide"
export { default as EyeShowIcon } from "./EyeShow"
export { default as FitnessIcon } from "./Fitness"
export { default as GalleryIcon } from "./Gallery"
export { default as GiftIcon } from "./Gift"
export { default as GlobeIcon } from "./Globe"
export { default as HeartIcon } from "./Heart"

View File

@@ -6,6 +6,7 @@ const config = {
variants: {
color: {
baseButtonTertiaryOnFillNormal: styles.baseButtonTertiaryOnFillNormal,
baseButtonTextOnFillNormal: styles.baseButtonTextOnFillNormal,
baseIconLowContrast: styles.baseIconLowContrast,
black: styles.black,
blue: styles.blue,

View File

@@ -53,6 +53,7 @@ a.inverted {
a.text {
background: none;
border: none;
outline: none;
}
/* VARIANTS */

View File

@@ -0,0 +1,40 @@
.container {
display: flex;
flex-direction: column;
color: var(--text-color);
}
.container[data-selected] .checkbox {
border: none;
background: var(--UI-Input-Controls-Fill-Selected);
}
.checkboxContainer {
display: flex;
align-items: flex-start;
gap: var(--Spacing-x-one-and-half);
}
.checkbox {
width: 24px;
height: 24px;
min-width: 24px;
background-color: var(--UI-Input-Controls-Surface-Normal);
border: 2px solid var(--UI-Input-Controls-Border-Normal);
border-radius: var(--Corner-radius-Small);
transition: all 200ms;
display: flex;
align-items: center;
justify-content: center;
transition: all 200ms;
forced-color-adjust: none;
cursor: pointer;
}
.error {
align-items: center;
color: var(--Scandic-Red-60);
display: flex;
gap: var(--Spacing-x-half);
margin-top: var(--Spacing-x1);
}

View File

@@ -0,0 +1,7 @@
import { RegisterOptions } from "react-hook-form"
export interface CheckboxProps
extends React.InputHTMLAttributes<HTMLInputElement> {
name: string
registerOptions?: RegisterOptions
}

View File

@@ -0,0 +1,49 @@
import { Checkbox as AriaCheckbox } from "react-aria-components"
import { useController, useFormContext } from "react-hook-form"
import { InfoCircleIcon } from "@/components/Icons"
import CheckIcon from "@/components/Icons/Check"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { CheckboxProps } from "./checkbox"
import styles from "./checkbox.module.css"
export default function Checkbox({
name,
children,
registerOptions,
}: React.PropsWithChildren<CheckboxProps>) {
const { control } = useFormContext()
const { field, fieldState } = useController({
control,
name,
rules: registerOptions,
})
return (
<AriaCheckbox
className={styles.container}
isSelected={field.value}
onChange={field.onChange}
data-testid={name}
>
{({ isSelected }) => (
<>
<div className={styles.checkboxContainer}>
<div className={styles.checkbox}>
{isSelected && <CheckIcon color="white" />}
</div>
{children}
</div>
{children && fieldState.error ? (
<Caption className={styles.error}>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</>
)}
</AriaCheckbox>
)
}

View File

@@ -0,0 +1,39 @@
.container {
display: flex;
flex-direction: column;
color: var(--text-color);
}
.container[data-selected] .checkbox {
border: none;
background: var(--UI-Input-Controls-Fill-Selected);
}
.checkboxContainer {
display: flex;
align-items: flex-start;
gap: var(--Spacing-x-one-and-half);
}
.checkbox {
flex-grow: 1;
width: 24px;
height: 24px;
min-width: 24px;
border: 2px solid var(--UI-Input-Controls-Border-Normal);
border-radius: 4px;
transition: all 200ms;
display: flex;
align-items: center;
justify-content: center;
transition: all 200ms;
forced-color-adjust: none;
}
.error {
align-items: center;
color: var(--Scandic-Red-60);
display: flex;
gap: var(--Spacing-x-half);
margin: var(--Spacing-x1) 0 0;
}

View File

@@ -0,0 +1,7 @@
import { RegisterOptions } from "react-hook-form"
export interface CheckboxProps
extends React.InputHTMLAttributes<HTMLInputElement> {
name: string
registerOptions?: RegisterOptions
}

View File

@@ -0,0 +1,51 @@
"use client"
import { Checkbox as AriaCheckbox } from "react-aria-components"
import { useController, useFormContext } from "react-hook-form"
import { InfoCircleIcon } from "@/components/Icons"
import CheckIcon from "@/components/Icons/Check"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { CheckboxProps } from "./checkbox"
import styles from "./checkbox.module.css"
export default function Checkbox({
name,
children,
registerOptions,
}: React.PropsWithChildren<CheckboxProps>) {
const { control } = useFormContext()
const { field, fieldState } = useController({
control,
name,
rules: registerOptions,
})
return (
<AriaCheckbox
className={styles.container}
isSelected={field.value}
onChange={field.onChange}
data-testid={name}
>
{({ isSelected }) => (
<>
<div className={styles.checkboxContainer}>
<div className={styles.checkbox}>
{isSelected && <CheckIcon color="white" />}
</div>
{children}
</div>
{fieldState.error ? (
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</>
)}
</AriaCheckbox>
)
}

View File

@@ -1,5 +1,7 @@
"use client"
import { useFormContext } from "react-hook-form"
import { CheckIcon, CloseIcon, HeartIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
@@ -23,6 +25,8 @@ export default function Card({
type,
value,
}: CardProps) {
const { register } = useFormContext()
return (
<label className={styles.label} data-declined={declined}>
<Caption className={styles.title} textTransform="bold" uppercase>
@@ -68,9 +72,9 @@ export default function Card({
aria-hidden
id={id || name}
hidden
name={name}
type={type}
value={value}
{...register(name)}
/>
</label>
)

View File

@@ -68,6 +68,7 @@ export default function CountrySelect({
onSelectionChange={handleChange}
ref={field.ref}
selectedKey={field.value}
data-testid={name}
>
<div className={styles.comboBoxContainer}>
<Label

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