Merge branch 'develop' into feature/tracking
This commit is contained in:
@@ -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
88
actions/registerUser.ts
Normal 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])
|
||||
})
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
.layout {
|
||||
min-height: 100dvh;
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-top: var(--Spacing-x3);
|
||||
}
|
||||
20
components/Blocks/DynamicContent/SignupFormWrapper/index.tsx
Normal file
20
components/Blocks/DynamicContent/SignupFormWrapper/index.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
32
components/Blocks/ShortcutsList/ShortcutsListItems/index.tsx
Normal file
32
components/Blocks/ShortcutsList/ShortcutsListItems/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
48
components/Blocks/ShortcutsList/index.tsx
Normal file
48
components/Blocks/ShortcutsList/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
components/Blocks/ShortcutsList/shortcutsList.module.css
Normal file
29
components/Blocks/ShortcutsList/shortcutsList.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.cardContainer {
|
||||
scroll-margin-top: var(--hotel-page-scroll-margin-top);
|
||||
}
|
||||
|
||||
.spanOne {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} m²`
|
||||
const size = `${room.roomSize.min} - ${room.roomSize.max} m²`
|
||||
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" })}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) -
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
stroke: var(--Base-Text-Disabled);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.vouchers {
|
||||
display: none;
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
.infoIcon {
|
||||
stroke: var(--Base-Text-Disabled);
|
||||
}
|
||||
|
||||
.vouchersHeader {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export default function Form({ user }: EditFormProps) {
|
||||
retypeNewPassword: "",
|
||||
},
|
||||
mode: "all",
|
||||
criteriaMode: "all",
|
||||
resolver: zodResolver(editProfileSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
49
components/Forms/Register/form.module.css
Normal file
49
components/Forms/Register/form.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
185
components/Forms/Register/index.tsx
Normal file
185
components/Forms/Register/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
components/Forms/Register/schema.ts
Normal file
35
components/Forms/Register/schema.ts
Normal 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>
|
||||
@@ -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" },
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
26
components/HotelReservation/EnterDetails/Provider/index.tsx
Normal file
26
components/HotelReservation/EnterDetails/Provider/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}>
|
||||
@@ -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;
|
||||
}
|
||||
165
components/HotelReservation/EnterDetails/Summary/index.tsx
Normal file
165
components/HotelReservation/EnterDetails/Summary/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
13
components/HotelReservation/SelectRate/Payment/schema.ts
Normal file
13
components/HotelReservation/SelectRate/Payment/schema.ts
Normal 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> {}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.arrow {
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
"use client"
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
export default function Summary() {
|
||||
return <div className={styles.wrapper}>Summary TBI</div>
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
.wrapper {
|
||||
}
|
||||
36
components/Icons/EyeHide.tsx
Normal file
36
components/Icons/EyeHide.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
components/Icons/EyeShow.tsx
Normal file
36
components/Icons/EyeShow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
components/Icons/Gallery.tsx
Normal file
36
components/Icons/Gallery.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -71,3 +71,8 @@
|
||||
.baseButtonTertiaryOnFillNormal * {
|
||||
fill: var(--Base-Button-Tertiary-On-Fill-Normal);
|
||||
}
|
||||
|
||||
.baseButtonTextOnFillNormal,
|
||||
.baseButtonTextOnFillNormal * {
|
||||
fill: var(--Base-Button-Text-On-Fill-Normal);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,6 +6,7 @@ const config = {
|
||||
variants: {
|
||||
color: {
|
||||
baseButtonTertiaryOnFillNormal: styles.baseButtonTertiaryOnFillNormal,
|
||||
baseButtonTextOnFillNormal: styles.baseButtonTextOnFillNormal,
|
||||
baseIconLowContrast: styles.baseIconLowContrast,
|
||||
black: styles.black,
|
||||
blue: styles.blue,
|
||||
|
||||
@@ -53,6 +53,7 @@ a.inverted {
|
||||
a.text {
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* VARIANTS */
|
||||
|
||||
40
components/TempDesignSystem/Checkbox/checkbox.module.css
Normal file
40
components/TempDesignSystem/Checkbox/checkbox.module.css
Normal 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);
|
||||
}
|
||||
7
components/TempDesignSystem/Checkbox/checkbox.ts
Normal file
7
components/TempDesignSystem/Checkbox/checkbox.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export interface CheckboxProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
name: string
|
||||
registerOptions?: RegisterOptions
|
||||
}
|
||||
49
components/TempDesignSystem/Checkbox/index.tsx
Normal file
49
components/TempDesignSystem/Checkbox/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
7
components/TempDesignSystem/Form/Checkbox/checkbox.ts
Normal file
7
components/TempDesignSystem/Form/Checkbox/checkbox.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export interface CheckboxProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
name: string
|
||||
registerOptions?: RegisterOptions
|
||||
}
|
||||
51
components/TempDesignSystem/Form/Checkbox/index.tsx
Normal file
51
components/TempDesignSystem/Form/Checkbox/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user