Merge branch 'develop' into feat/sw-222-staycard-link

This commit is contained in:
Linus Flood
2024-10-21 08:30:07 +02:00
292 changed files with 7197 additions and 1979 deletions

4
.env
View File

@@ -1,4 +0,0 @@
# See update-dotenv.mjs
AUTH_URL="REPLACE-ON-NETLIFY-BUILD"
NEXTAUTH_URL="REPLACE-ON-NETLIFY-BUILD"
PUBLIC_URL="REPLACED-ON-NETLIFY-BUILD"

View File

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

View File

@@ -0,0 +1,67 @@
"use server"
import { parsePhoneNumber } from "libphonenumber-js"
import { z } from "zod"
import { serviceServerActionProcedure } from "@/server/trpc"
import { phoneValidator } from "@/utils/phoneValidator"
const registerUserPayload = z.object({
firstName: z.string(),
lastName: z.string(),
dateOfBirth: z.string(),
address: z.object({
countryCode: z.string(),
zipCode: z.string(),
}),
email: z.string(),
phoneNumber: phoneValidator("Phone is required"),
})
export const registerUserBookingFlow = serviceServerActionProcedure
.input(registerUserPayload)
.mutation(async function ({ ctx, input }) {
const payload = {
...input,
language: ctx.lang,
phoneNumber: parsePhoneNumber(input.phoneNumber)
.formatNational()
.replace(/\s+/g, ""),
}
// TODO: Consume the API to register the user as soon as passwordless signup is enabled.
// let apiResponse
// try {
// apiResponse = await api.post(api.endpoints.v1.profile, {
// body: payload,
// 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(text)
// console.error(
// "registerUserBookingFlow 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("registerUserBookingFlow: json", json)
return { success: true, data: payload }
})

View File

@@ -4,6 +4,7 @@ import { AuthError } from "next-auth"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import { internalServerError } from "@/server/errors/next"
import { getPublicURL } from "@/server/utils"
import { signOut } from "@/auth"
@@ -11,6 +12,8 @@ export async function GET(
request: NextRequest,
context: { params: { lang: Lang } }
) {
const publicURL = getPublicURL(request)
let redirectTo: string = ""
const returnUrl = request.headers.get("x-returnurl")
@@ -39,7 +42,7 @@ export async function GET(
// Make relative URL to absolute URL
if (redirectTo.startsWith("/")) {
console.log(`[logout] make redirectTo absolute, from ${redirectTo}`)
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
redirectTo = new URL(redirectTo, publicURL).href
console.log(`[logout] make redirectTo absolute, to ${redirectTo}`)
}

View File

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

View File

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

View File

@@ -1,6 +1,4 @@
import { ArrowRightIcon } from "@/components/Icons"
import ManagePreferencesButton from "@/components/Profile/ManagePreferencesButton"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"

View File

@@ -1,3 +1,5 @@
import { env } from "@/env/server"
import Divider from "@/components/TempDesignSystem/Divider"
import type { ProfileLayoutProps } from "@/types/components/myPages/myProfile/layout"
@@ -15,7 +17,7 @@ export default function ProfileLayout({
{profile}
<Divider color="burgundy" opacity={8} />
{creditCards}
{communication}
{env.HIDE_FOR_NEXT_RELEASE ? null : communication}
</section>
</main>
)

View File

@@ -1,9 +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;
margin: 0 auto;
}
.container {
background-color: var(--Base-Background-Primary-Normal);
}

View File

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

View File

@@ -2,19 +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 SidePeek from "@/components/HotelReservation/EnterDetails/SidePeek"
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader"
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",
@@ -26,15 +29,18 @@ 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>
<SidePeek hotel={hotel.data.attributes} />
</main>
</EnterDetailsProvider>
)
}

View File

@@ -1,117 +1,69 @@
"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 HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
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,
})
const { data: userData } = trpc.user.getSafely.useQuery()
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)
}
}
let user = null
if (userData && !("error" in userData)) {
user = userData
}
return (
<section>
<HistoryStateManager />
<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={user} />
</SectionAccordion>
<SectionAccordion
header="Payment"
isCompleted={false}
isOpen={activeStep === StepEnum.payment}
step={StepEnum.payment}
label={intl.formatMessage({ id: "Select payment method" })}
path="/hotelreservation/select-bed"
>
<Payment hotel={hotel.data.attributes} />
</SectionAccordion>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import tempHotelData from "@/server/routers/hotels/tempHotelData.json"
import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection"
import getHotelReservationQueryParams from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { setLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
@@ -15,27 +16,49 @@ export default async function SelectRatePage({
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
setLang(params.lang)
// TODO: Use real endpoint.
const hotel = tempHotelData.data.attributes
const selecetRoomParams = new URLSearchParams(searchParams)
const selecetRoomParamsObject =
getHotelReservationQueryParams(selecetRoomParams)
const adults = selecetRoomParamsObject.room[0].adults // TODO: Handle multiple rooms
const children = selecetRoomParamsObject.room[0].child.length // TODO: Handle multiple rooms
const [hotelData, roomConfigurations, user] = await Promise.all([
serverClient().hotel.hotelData.get({
hotelId: searchParams.hotel,
language: params.lang,
include: ["RoomCategories"],
}),
serverClient().hotel.availability.rooms({
hotelId: parseInt(searchParams.hotel, 10),
roomStayStartDate: searchParams.fromDate,
roomStayEndDate: searchParams.toDate,
adults: adults,
children: children,
}),
getProfileSafely(),
])
const roomConfigurations = await serverClient().hotel.availability.rooms({
hotelId: parseInt(searchParams.hotel, 10),
roomStayStartDate: "2024-11-02",
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 ?? []}
user={user}
/>
</div>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { AuthError } from "next-auth"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import { internalServerError } from "@/server/errors/next"
import { getPublicURL } from "@/server/utils"
import { signIn } from "@/auth"
@@ -11,9 +12,7 @@ export async function GET(
request: NextRequest,
context: { params: { lang: Lang } }
) {
if (!env.PUBLIC_URL) {
throw internalServerError("No value for env.PUBLIC_URL")
}
const publicURL = getPublicURL(request)
let redirectHeaders: Headers | undefined = undefined
let redirectTo: string
@@ -54,7 +53,7 @@ export async function GET(
// Make relative URL to absolute URL
if (redirectTo.startsWith("/")) {
console.log(`[login] make redirectTo absolute, from ${redirectTo}`)
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
redirectTo = new URL(redirectTo, publicURL).href
console.log(`[login] make redirectTo absolute, to ${redirectTo}`)
}
@@ -131,7 +130,7 @@ export async function GET(
* because user might choose to do Email link login.
* */
// The `for_origin` param is used to make Curity email login functionality working.
for_origin: env.PUBLIC_URL,
for_origin: publicURL,
// This is new param set for differentiate between the Magic link login of New web and current web
version: "2",
}

View File

@@ -5,6 +5,7 @@ import { Lang } from "@/constants/languages"
import { login } from "@/constants/routes/handleAuth"
import { env } from "@/env/server"
import { badRequest, internalServerError } from "@/server/errors/next"
import { getPublicURL } from "@/server/utils"
import { signIn } from "@/auth"
@@ -12,9 +13,7 @@ export async function GET(
request: NextRequest,
context: { params: { lang: Lang } }
) {
if (!env.PUBLIC_URL) {
throw internalServerError("No value for env.PUBLIC_URL")
}
const publicURL = getPublicURL(request)
const loginKey = request.nextUrl.searchParams.get("loginKey")
if (!loginKey) {
@@ -44,7 +43,7 @@ export async function GET(
console.log(
`[verifymagiclink] make redirectTo absolute, from ${redirectTo}`
)
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
redirectTo = new URL(redirectTo, publicURL).href
console.log(`[verifymagiclink] make redirectTo absolute, to ${redirectTo}`)
}
@@ -69,7 +68,7 @@ export async function GET(
ui_locales: context.params.lang,
scope: ["openid", "profile"].join(" "),
loginKey: loginKey,
for_origin: env.PUBLIC_URL,
for_origin: publicURL,
acr_values: "abc",
version: "2",
}

View File

@@ -4,14 +4,17 @@ import { env } from "process"
import { Lang } from "@/constants/languages"
import { profile } from "@/constants/routes/myPages"
import { serverClient } from "@/lib/trpc/server"
import { getPublicURL } from "@/server/utils"
export async function GET(
request: NextRequest,
{ params }: { params: { lang: string } }
) {
const publicURL = getPublicURL(request)
console.log(`[add-card] callback started`)
const lang = params.lang as Lang
const returnUrl = new URL(`${env.PUBLIC_URL}/${profile[lang ?? Lang.en]}`)
const returnUrl = new URL(`${publicURL}/${profile[lang ?? Lang.en]}`)
try {
const searchParams = request.nextUrl.searchParams

View File

@@ -1,38 +0,0 @@
import { NextResponse } from "next/server"
import { env } from "@/env/server"
import type { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
const e = process.env
console.log({ process_env: process.env })
const urlVar = "PUBLIC_URL"
const nextAuthUrlVar = "NEXTAUTH_URL"
const nextAuthUrlVar2 = "AUTH_URL"
const envTestVar = "ENVTEST"
const values = {
env_url: env.PUBLIC_URL,
static_url: process.env.PUBLIC_URL,
dynamic_url: e[urlVar],
env_envtest: env.ENVTEST,
static_envtest: process.env.ENVTEST,
dynamic_envtest: e[envTestVar],
env_nextauth: env.NEXTAUTH_URL,
static_nextauth: process.env.NEXTAUTH_URL,
dynamic_nextauth: e[nextAuthUrlVar],
env_nextauth2: env.AUTH_URL,
static_nextauth2: process.env.AUTH_URL,
dynamic_nextauth2: e[nextAuthUrlVar2],
}
console.log(values)
return NextResponse.json(values)
}
export const dynamic = "force-dynamic"
export const runtime = "edge"

View File

@@ -1,39 +0,0 @@
import { config } from "dotenv"
import { NextResponse } from "next/server"
import { env } from "@/env/server"
import type { NextRequest } from "next/server"
config({ path: "./.env" })
export async function GET(request: NextRequest) {
const e = process.env
console.log({ process_env: process.env })
const urlVar = "PUBLIC_URL"
const nextAuthUrlVar = "NEXTAUTH_URL"
const nextAuthUrlVar2 = "AUTH_URL"
const envTestVar = "ENVTEST"
const values = {
env_url: env.PUBLIC_URL,
static_url: process.env.PUBLIC_URL,
dynamic_url: e[urlVar],
env_envtest: env.ENVTEST,
static_envtest: process.env.ENVTEST,
dynamic_envtest: e[envTestVar],
env_nextauth: env.NEXTAUTH_URL,
static_nextauth: process.env.NEXTAUTH_URL,
dynamic_nextauth: e[nextAuthUrlVar],
env_nextauth2: env.AUTH_URL,
static_nextauth2: process.env.AUTH_URL,
dynamic_nextauth2: e[nextAuthUrlVar2],
}
console.log(values)
return NextResponse.json(values)
}
export const dynamic = "force-dynamic"

View File

@@ -1,38 +0,0 @@
import "dotenv/config"
import { NextResponse } from "next/server"
import { env } from "@/env/server"
import type { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
const e = process.env
console.log({ process_env: process.env })
const urlVar = "PUBLC_URL"
const nextAuthUrlVar = "NEXTAUTH_URL"
const nextAuthUrlVar2 = "AUTH_URL"
const envTestVar = "ENVTEST"
const values = {
env_url: env.PUBLIC_URL,
static_url: process.env.PUBLIC_URL,
dynamic_url: e[urlVar],
env_envtest: env.ENVTEST,
static_envtest: process.env.ENVTEST,
dynamic_envtest: e[envTestVar],
env_nextauth: env.NEXTAUTH_URL,
static_nextauth: process.env.NEXTAUTH_URL,
dynamic_nextauth: e[nextAuthUrlVar],
env_nextauth2: env.AUTH_URL,
static_nextauth2: process.env.AUTH_URL,
dynamic_nextauth2: e[nextAuthUrlVar2],
}
console.log(values)
return NextResponse.json(values)
}
export const dynamic = "force-dynamic"

View File

@@ -1,39 +0,0 @@
import { config } from "dotenv"
import { NextResponse } from "next/server"
import { env } from "@/env/server"
import type { NextRequest } from "next/server"
config({ debug: true, override: true })
export async function GET(request: NextRequest) {
const e = process.env
console.log({ process_env: process.env })
const urlVar = "PUBLC_URL"
const nextAuthUrlVar = "NEXTAUTH_URL"
const nextAuthUrlVar2 = "AUTH_URL"
const envTestVar = "ENVTEST"
const values = {
env_url: env.PUBLIC_URL,
static_url: process.env.PUBLIC_URL,
dynamic_url: e[urlVar],
env_envtest: env.ENVTEST,
static_envtest: process.env.ENVTEST,
dynamic_envtest: e[envTestVar],
env_nextauth: env.NEXTAUTH_URL,
static_nextauth: process.env.NEXTAUTH_URL,
dynamic_nextauth: e[nextAuthUrlVar],
env_nextauth2: env.AUTH_URL,
static_nextauth2: process.env.AUTH_URL,
dynamic_nextauth2: e[nextAuthUrlVar2],
}
console.log(values)
return NextResponse.json(values)
}
export const dynamic = "force-dynamic"

View File

@@ -1,36 +0,0 @@
import { NextResponse } from "next/server"
import { env } from "@/env/server"
import type { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
const e = process.env
console.log({ process_env: process.env })
const urlVar = "PUBLIC_URL"
const nextAuthUrlVar = "NEXTAUTH_URL"
const nextAuthUrlVar2 = "AUTH_URL"
const envTestVar = "ENVTEST"
const values = {
env_url: env.PUBLIC_URL,
static_url: process.env.PUBLIC_URL,
dynamic_url: e[urlVar],
env_envtest: env.ENVTEST,
static_envtest: process.env.ENVTEST,
dynamic_envtest: e[envTestVar],
env_nextauth: env.NEXTAUTH_URL,
static_nextauth: process.env.NEXTAUTH_URL,
dynamic_nextauth: e[nextAuthUrlVar],
env_nextauth2: env.AUTH_URL,
static_nextauth2: process.env.AUTH_URL,
dynamic_nextauth2: e[nextAuthUrlVar2],
}
console.log(values)
return NextResponse.json(values)
}
export const dynamic = "force-dynamic"

View File

@@ -1,37 +0,0 @@
import { config } from "dotenv"
import { NextResponse } from "next/server"
import { env } from "@/env/server"
import type { NextRequest } from "next/server"
config({ path: "./.env" })
export async function GET(request: NextRequest) {
const e = process.env
console.log({ process_env: process.env })
const urlVar = "PUBLIC_URL"
const nextAuthUrlVar = "NEXTAUTH_URL"
const nextAuthUrlVar2 = "AUTH_URL"
const envTestVar = "ENVTEST"
const values = {
env_url: env.PUBLIC_URL,
static_url: process.env.PUBLIC_URL,
dynamic_url: e[urlVar],
env_envtest: env.ENVTEST,
static_envtest: process.env.ENVTEST,
dynamic_envtest: e[envTestVar],
env_nextauth: env.NEXTAUTH_URL,
static_nextauth: process.env.NEXTAUTH_URL,
dynamic_nextauth: e[nextAuthUrlVar],
env_nextauth2: env.AUTH_URL,
static_nextauth2: process.env.AUTH_URL,
dynamic_nextauth2: e[nextAuthUrlVar2],
}
console.log(values)
return NextResponse.json(values)
}

View File

@@ -1,36 +0,0 @@
import "dotenv/config"
import { NextResponse } from "next/server"
import { env } from "@/env/server"
import type { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
const e = process.env
console.log({ process_env: process.env })
const urlVar = "PUBLIC_URL"
const nextAuthUrlVar = "NEXTAUTH_URL"
const nextAuthUrlVar2 = "AUTH_URL"
const envTestVar = "ENVTEST"
const values = {
env_url: env.PUBLIC_URL,
static_url: process.env.PUBLIC_URL,
dynamic_url: e[urlVar],
env_envtest: env.ENVTEST,
static_envtest: process.env.ENVTEST,
dynamic_envtest: e[envTestVar],
env_nextauth: env.NEXTAUTH_URL,
static_nextauth: process.env.NEXTAUTH_URL,
dynamic_nextauth: e[nextAuthUrlVar],
env_nextauth2: env.AUTH_URL,
static_nextauth2: process.env.AUTH_URL,
dynamic_nextauth2: e[nextAuthUrlVar2],
}
console.log(values)
return NextResponse.json(values)
}

View File

@@ -1,37 +0,0 @@
import { config } from "dotenv"
import { NextResponse } from "next/server"
import { env } from "@/env/server"
import type { NextRequest } from "next/server"
config({ debug: true, override: true })
export async function GET(request: NextRequest) {
const e = process.env
console.log({ process_env: process.env })
const urlVar = "PUBLIC_URL"
const nextAuthUrlVar = "NEXTAUTH_URL"
const nextAuthUrlVar2 = "AUTH_URL"
const envTestVar = "ENVTEST"
const values = {
env_url: env.PUBLIC_URL,
static_url: process.env.PUBLIC_URL,
dynamic_url: e[urlVar],
env_envtest: env.ENVTEST,
static_envtest: process.env.ENVTEST,
dynamic_envtest: e[envTestVar],
env_nextauth: env.NEXTAUTH_URL,
static_nextauth: process.env.NEXTAUTH_URL,
dynamic_nextauth: e[nextAuthUrlVar],
env_nextauth2: env.AUTH_URL,
static_nextauth2: process.env.AUTH_URL,
dynamic_nextauth2: e[nextAuthUrlVar2],
}
console.log(values)
return NextResponse.json(values)
}

View File

@@ -1,34 +0,0 @@
import { NextResponse } from "next/server"
import { env } from "@/env/server"
import type { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
const e = process.env
console.log({ process_env: process.env })
const urlVar = "PUBLIC_URL"
const nextAuthUrlVar = "NEXTAUTH_URL"
const nextAuthUrlVar2 = "AUTH_URL"
const envTestVar = "ENVTEST"
const values = {
env_url: env.PUBLIC_URL,
static_url: process.env.PUBLIC_URL,
dynamic_url: e[urlVar],
env_envtest: env.ENVTEST,
static_envtest: process.env.ENVTEST,
dynamic_envtest: e[envTestVar],
env_nextauth: env.NEXTAUTH_URL,
static_nextauth: process.env.NEXTAUTH_URL,
dynamic_nextauth: e[nextAuthUrlVar],
env_nextauth2: env.AUTH_URL,
static_nextauth2: process.env.AUTH_URL,
dynamic_nextauth2: e[nextAuthUrlVar2],
}
console.log(values)
return NextResponse.json(values)
}

View File

@@ -6,20 +6,21 @@ import {
bookingConfirmation,
payment,
} from "@/constants/routes/hotelReservation"
import { getPublicURL } from "@/server/utils"
export async function GET(
request: NextRequest,
{ params }: { params: { lang: string; status: string } }
): Promise<NextResponse> {
const publicURL = getPublicURL(request)
console.log(`[payment-callback] callback started`)
const lang = params.lang as Lang
const status = params.status
const returnUrl = new URL(`${env.PUBLIC_URL}/${payment[lang]}`)
const returnUrl = new URL(`${publicURL}/${payment[lang]}`)
if (status === "success") {
const confirmationUrl = new URL(
`${env.PUBLIC_URL}/${bookingConfirmation[lang]}`
)
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation[lang]}`)
console.log(`[payment-callback] redirecting to: ${confirmationUrl}`)
return NextResponse.redirect(confirmationUrl)
}

View File

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

View File

@@ -109,6 +109,7 @@ const curityProvider = {
} satisfies OIDCConfig<User>
export const config = {
basePath: "/api/web/auth",
debug: env.NEXTAUTH_DEBUG,
providers: [curityProvider],
redirectProxyUrl: env.NEXTAUTH_REDIRECT_PROXY_URL,
@@ -233,4 +234,4 @@ export const {
auth,
signIn,
signOut,
} = NextAuth(config)
} = NextAuth(config)

View File

@@ -0,0 +1,7 @@
.accordion:not(.allVisible) :nth-child(n + 6) {
display: none;
}
.accordion:not(.allVisible) :nth-child(5) {
border: none;
}

View File

@@ -0,0 +1,53 @@
"use client"
import { useState } from "react"
import JsonToHtml from "@/components/JsonToHtml"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import Accordion from "@/components/TempDesignSystem/Accordion"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import styles from "./accordion.module.css"
import type { AccordionProps } from "@/types/components/blocks/Accordion"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
export default function AccordionSection({ accordion, title }: AccordionProps) {
const showToggleButton = accordion.length > 5
const [allAccordionsVisible, setAllAccordionsVisible] =
useState(!showToggleButton)
function toggleAccordions() {
setAllAccordionsVisible((state) => !state)
}
return (
<SectionContainer id={HotelHashValues.faq}>
{title && <SectionHeader textTransform="uppercase" title={title} />}
<Accordion
className={`${styles.accordion} ${allAccordionsVisible ? styles.allVisible : ""}`}
theme="light"
variant="card"
>
{accordion.map((acc) => (
<AccordionItem key={acc.question} title={acc.question}>
<JsonToHtml
embeds={acc.answer.embedded_itemsConnection.edges}
nodes={acc.answer.json?.children[0].children}
/>
</AccordionItem>
))}
</Accordion>
{showToggleButton ? (
<ShowMoreButton
loadMoreData={toggleAccordions}
showLess={allAccordionsVisible}
textShowMore="See all FAQ"
textShowLess="See less FAQ"
/>
) : null}
</SectionContainer>
)
}

View File

@@ -6,12 +6,29 @@ 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"
import type { StackableGridProps } from "../TempDesignSystem/Grids/Stackable/stackable"
export default function CardsGrid({
cards_grid,
firstItem = false,
}: CardsGridProps) {
let columns: StackableGridProps["columns"]
switch (cards_grid.layout) {
case CardsGridLayoutEnum.ONE_COLUMN:
columns = 1
break
case CardsGridLayoutEnum.TWO_COLUMNS:
columns = 2
break
case CardsGridLayoutEnum.THREE_COLUMNS:
columns = 3
break
default:
columns = 3
}
return (
<SectionContainer>
<SectionHeader
@@ -19,7 +36,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 +60,7 @@ export default function CardsGrid({
primaryButton={card.primaryButton}
secondaryButton={card.secondaryButton}
sidePeekButton={card.sidePeekButton}
sidePeekContent={card.sidePeekContent}
image={card.image}
/>
)

View File

@@ -1,3 +1,5 @@
import { env } from "@/env/server"
import HowItWorks from "@/components/Blocks/DynamicContent/HowItWorks"
import LoyaltyLevels from "@/components/Blocks/DynamicContent/LoyaltyLevels"
import Overview from "@/components/Blocks/DynamicContent/Overview"
@@ -26,7 +28,9 @@ export default async function DynamicContent({
case DynamicContentEnum.Blocks.components.earn_and_burn:
return <EarnAndBurn {...dynamic_content} />
case DynamicContentEnum.Blocks.components.expiring_points:
return <ExpiringPoints {...dynamic_content} />
return env.HIDE_FOR_NEXT_RELEASE ? null : (
<ExpiringPoints {...dynamic_content} />
)
case DynamicContentEnum.Blocks.components.how_it_works:
return (
<HowItWorks dynamic_content={dynamic_content} firstItem={firstItem} />

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
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 && shortcuts.length > 1
? {
section: styles.twoColumnSection,
leftColumn: styles.leftColumn,
rightColumn: styles.rightColumn,
}
: {
section: styles.oneColumnSection,
leftColumn:
shortcuts.length === 1
? styles.leftColumnBorderBottomNone
: styles.leftColumnBorderBottom,
}
return (
<SectionContainer>
<SectionHeader preamble={subtitle} title={title} topTitle={firstItem} />
<section className={classNames.section}>
<ShortcutsListItems
shortcutsListItems={leftColumn}
className={classNames.leftColumn}
/>
<ShortcutsListItems
shortcutsListItems={rightColumn}
className={classNames.rightColumn}
/>
</section>
</SectionContainer>
)
}

View File

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

View File

@@ -1,9 +1,7 @@
import Link from "@/components/TempDesignSystem/Link"
import { removeMultipleSlashes } from "@/utils/url"
import styles from "./uspgrid.module.css"
import { EmbedEnum } from "@/types/requests/utils/embeds"
import type { EmbedByUid } from "@/types/transitionTypes/jsontohtml"
import { RTEItemTypeEnum, RTETypeEnum } from "@/types/transitionTypes/rte/enums"
import type {

View File

@@ -1,10 +1,11 @@
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"
import AccordionSection from "./Accordion"
import Table from "./Table"
import type { BlocksProps } from "@/types/components/blocks"
@@ -14,6 +15,14 @@ export default function Blocks({ blocks }: BlocksProps) {
return blocks.map((block, idx) => {
const firstItem = idx === 0
switch (block.typename) {
case BlocksEnums.block.Accordion:
return (
<AccordionSection
accordion={block.accordion.accordions}
title={block.accordion.title}
key={`${block.typename}-${idx}`}
/>
)
case BlocksEnums.block.CardsGrid:
return (
<CardsGrid
@@ -41,12 +50,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:
@@ -64,6 +74,7 @@ export default function Blocks({ blocks }: BlocksProps) {
)
case BlocksEnums.block.UspGrid:
return <UspGrid usp_grid={block.usp_grid} />
default:
return null
}

View File

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

View File

@@ -42,7 +42,7 @@
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
position: sticky;
top: 0;
z-index: 9;
z-index: 10;
background-color: var(--Base-Surface-Primary-light-Normal);
}

View File

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

View File

@@ -48,7 +48,7 @@ export default async function Breadcrumbs() {
return (
<li key={breadcrumb.uid} className={styles.listItem}>
<Footnote color="burgundy" textTransform="bold">
<Footnote color="burgundy" type="bold">
{breadcrumb.title}
</Footnote>
</li>

View File

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

View File

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

View File

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

View File

@@ -11,10 +11,10 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { IntroSectionProps } from "./types"
import styles from "./introSection.module.css"
import type { IntroSectionProps } from "./types"
export default async function IntroSection({
hotelName,
hotelDescription,

View File

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

View File

@@ -1,5 +1,4 @@
"use client"
import { useIntl } from "react-intl"
import useHotelPageStore from "@/stores/hotel-page"
@@ -21,7 +20,6 @@ export default function MobileMapToggle() {
onClick={closeDynamicMap}
>
<HouseIcon
className={styles.icon}
color={!isDynamicMapOpen ? "white" : "red"}
height={24}
width={24}
@@ -34,7 +32,6 @@ export default function MobileMapToggle() {
onClick={openDynamicMap}
>
<MapIcon
className={styles.icon}
color={isDynamicMapOpen ? "white" : "red"}
height={24}
width={24}

View File

@@ -1,9 +1,8 @@
.mobileToggle {
position: fixed;
position: sticky;
bottom: var(--Spacing-x5);
left: 50%;
transform: translateX(-50%);
z-index: 1;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--Spacing-x-half);

View File

@@ -10,7 +10,7 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./roomCard.module.css"
import type { RoomCardProps } from "@/types/components/hotelPage/roomCard"
import type { RoomCardProps } from "@/types/components/hotelPage/room"
export function RoomCard({
badgeTextTransKey,

View File

@@ -3,95 +3,88 @@
import { useRef, useState } from "react"
import { useIntl } from "react-intl"
import { ChevronDownIcon } from "@/components/Icons"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import Button from "@/components/TempDesignSystem/Button"
import Grids from "@/components/TempDesignSystem/Grids"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import { RoomCard } from "./RoomCard"
import styles from "./rooms.module.css"
import type { RoomsProps } from "./types"
import type { RoomsProps } from "@/types/components/hotelPage/room"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
export function Rooms({ rooms }: RoomsProps) {
const intl = useIntl()
const [allRoomsVisible, setAllRoomsVisible] = useState(false)
const showToggleButton = rooms.length > 3
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
const scrollRef = useRef<HTMLDivElement>(null)
const mappedRooms = rooms
.map((room) => {
const size = `${room.attributes.roomSize.min} - ${room.attributes.roomSize.max}`
const size = `${room.roomSize.min} - ${room.roomSize.max}`
const personLabel =
room.attributes.occupancy.total === 1
room.occupancy.total === 1
? intl.formatMessage({ id: "hotelPages.rooms.roomCard.person" })
: intl.formatMessage({ id: "hotelPages.rooms.roomCard.persons" })
const subtitle = `${size} (${room.attributes.occupancy.total} ${personLabel})`
const subtitle = `${size} (${room.occupancy.total} ${personLabel})`
return {
id: room.id,
images: room.attributes.content.images,
title: room.attributes.name,
images: room.images,
title: room.name,
subtitle: subtitle,
sortOrder: room.attributes.sortOrder,
sortOrder: room.sortOrder,
popularChoice: null,
}
})
.sort((a, b) => a.sortOrder - b.sortOrder)
function handleToggleShowMore() {
function handleShowMore() {
if (scrollRef.current && allRoomsVisible) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
setAllRoomsVisible((previousState) => !previousState)
setAllRoomsVisible((state) => !state)
}
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" })}
preamble={null}
/>
<Grids.Stackable>
{mappedRooms.map(
({ id, images, title, subtitle, popularChoice }, index) => (
<div
key={id}
className={
!allRoomsVisible && index > 2 ? styles.hiddenRoomCard : ""
}
>
<RoomCard
id={id}
images={images}
title={title}
subtitle={subtitle}
badgeTextTransKey={popularChoice ? "Popular choice" : null}
/>
</div>
)
)}
<Grids.Stackable
className={`${styles.grid} ${allRoomsVisible ? styles.allVisible : ""}`}
>
{mappedRooms.map(({ id, images, title, subtitle, popularChoice }) => (
<div key={id}>
<RoomCard
id={id}
images={images}
title={title}
subtitle={subtitle}
badgeTextTransKey={popularChoice ? "Popular choice" : null}
/>
</div>
))}
</Grids.Stackable>
<div className={styles.ctaContainer}>
<Button
onClick={handleToggleShowMore}
theme="base"
intent="text"
variant="icon"
className={`${styles.showMoreButton} ${allRoomsVisible ? styles.showLess : ""}`}
>
<ChevronDownIcon className={styles.chevron} />
{intl.formatMessage({
id: allRoomsVisible ? "Show less" : "Show more",
})}
</Button>
</div>
{showToggleButton ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={allRoomsVisible}
textShowMore="Show more rooms"
textShowLess="Show less rooms"
/>
) : null}
</SectionContainer>
)
}

View File

@@ -1,12 +1,22 @@
.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;
}
.hiddenRoomCard {
display: none;
}
.showMoreButton.showLess .chevron {
transform: rotate(180deg);
}
.grid:not(.allVisible) :nth-child(n + 4) {
display: none;
}

View File

@@ -1,5 +0,0 @@
import { RoomData } from "@/types/hotel"
export type RoomsProps = {
rooms: RoomData[]
}

View File

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

View File

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

View File

@@ -1,10 +1,17 @@
.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);
z-index: 0;
}
.hotelImages {
@@ -24,8 +31,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 +62,7 @@
.mapWithCard {
position: sticky;
top: 0;
top: var(--booking-widget-desktop-height);
min-height: 500px; /* Fixed min to not cover the marker with the card */
height: calc(
100vh - var(--main-menu-desktop-height) -

View File

@@ -2,6 +2,7 @@ import hotelPageParams from "@/constants/routes/hotelPageParams"
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server"
import AccordionSection from "@/components/Blocks/Accordion"
import SidePeekProvider from "@/components/SidePeekProvider"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import { getIntl } from "@/i18n"
@@ -21,6 +22,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()
@@ -45,6 +48,7 @@ export default async function HotelPage() {
activitiesCard,
pointsOfInterest,
facilities,
faq,
} = hotelData
const topThreePois = pointsOfInterest.slice(0, 3)
@@ -61,9 +65,11 @@ export default async function HotelPage() {
</div>
<TabNavigation
restaurantTitle={getRestaurantHeading(hotelDetailedFacilities)}
hasActivities={!!activitiesCard}
hasFAQ={!!faq}
/>
<main className={styles.mainSection}>
<div className={styles.introContainer}>
<div id={HotelHashValues.overview} className={styles.introContainer}>
<IntroSection
hotelName={hotelName}
hotelDescription={hotelDescription}
@@ -71,55 +77,14 @@ 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} />
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
{faq && (
<AccordionSection accordion={faq.accordions} title={faq.title} />
)}
</main>
{googleMapsApiKey ? (
<>
@@ -139,6 +104,51 @@ export default async function HotelPage() {
/>
</>
) : null}
<SidePeekProvider>
{/* eslint-disable import/no-named-as-default-member */}
<SidePeek
contentKey={hotelPageParams.amenities[lang]}
title={intl.formatMessage({ id: "Amenities" })}
>
{/* TODO: Render amenities as per the design. */}
Read more about the amenities here
</SidePeek>
<SidePeek
contentKey={hotelPageParams.about[lang]}
title={intl.formatMessage({ id: "Read more about the hotel" })}
>
Some additional information about the hotel
</SidePeek>
<SidePeek
contentKey={hotelPageParams.restaurantAndBar[lang]}
title={intl.formatMessage({ id: "Restaurant & Bar" })}
>
{/* TODO */}
Restaurant & Bar
</SidePeek>
<SidePeek
contentKey={hotelPageParams.wellnessAndExercise[lang]}
title={intl.formatMessage({ id: "Wellness & Exercise" })}
>
{/* TODO */}
Wellness & Exercise
</SidePeek>
<SidePeek
contentKey={hotelPageParams.activities[lang]}
title={intl.formatMessage({ id: "Activities" })}
>
{/* TODO */}
Activities
</SidePeek>
<SidePeek
contentKey={hotelPageParams.meetingsAndConferences[lang]}
title={intl.formatMessage({ id: "Meetings & Conferences" })}
>
{/* TODO */}
Meetings & Conferences
</SidePeek>
{/* eslint-enable import/no-named-as-default-member */}
</SidePeekProvider>
</div>
)
}

View File

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

View File

@@ -61,9 +61,9 @@ export default function DatePickerDesktop({
locale={locale}
mode="range"
numberOfMonths={2}
onSelect={handleOnSelect}
onDayClick={handleOnSelect}
pagedNavigation
required
required={false}
selected={selectedDate}
startMonth={currentDate}
weekStartsOn={1}
@@ -82,7 +82,7 @@ export default function DatePickerDesktop({
size="small"
theme="base"
>
<Caption color="white" textTransform="bold">
<Caption color="white" type="bold">
{intl.formatMessage({ id: "Select dates" })}
</Caption>
</Button>

View File

@@ -6,7 +6,7 @@ import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import { CloseLarge } from "@/components/Icons"
import { CloseLargeIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -78,7 +78,7 @@ export default function DatePickerMobile({
mode="range"
/** Showing full year or what's left of it */
numberOfMonths={12}
onSelect={handleOnSelect}
onDayClick={handleOnSelect}
required
selected={selectedDate}
startMonth={startMonth}
@@ -127,7 +127,7 @@ export default function DatePickerMobile({
))}
</select>
<button className={styles.close} onClick={close} type="button">
<CloseLarge />
<CloseLargeIcon />
</button>
</header>
{children}

View File

@@ -47,8 +47,8 @@ td.rangeStart[aria-selected="true"] button.dayButton:hover {
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.rangeStart[aria-selected="true"]:not([data-outside="true"])
button.dayButton {
td.rangeStart[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.day[aria-selected="true"] button.dayButton {
background: var(--Primary-Light-On-Surface-Accent);
border: none;
color: var(--Base-Button-Inverted-Fill-Normal);
@@ -75,6 +75,7 @@ td.rangeMiddle[aria-selected="true"] button.dayButton {
background: var(--Base-Background-Primary-Normal);
border: none;
border-radius: 0;
color: var(--UI-Text-High-contrast);
}
td.day[data-disabled="true"],

View File

@@ -113,8 +113,8 @@ td.rangeStart[aria-selected="true"] button.dayButton:hover {
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.rangeStart[aria-selected="true"]:not([data-outside="true"])
button.dayButton {
td.rangeStart[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.day[aria-selected="true"] button.dayButton {
background: var(--Primary-Light-On-Surface-Accent);
border: none;
color: var(--Base-Button-Inverted-Fill-Normal);
@@ -141,6 +141,7 @@ td.rangeMiddle[aria-selected="true"] button.dayButton {
background: var(--Base-Background-Primary-Normal);
border: none;
border-radius: 0;
color: var(--UI-Text-High-contrast);
}
td.day[data-disabled="true"],

View File

@@ -22,6 +22,11 @@
.hideWrapper {
background-color: var(--Main-Grey-White);
display: none;
}
.container[data-isopen="true"] .hideWrapper {
display: block;
}
@media screen and (max-width: 1366px) {

View File

@@ -14,8 +14,6 @@ import DatePickerMobile from "./Screen/Mobile"
import styles from "./date-picker.module.css"
import type { DateRange } from "react-day-picker"
import type { DatePickerFormProps } from "@/types/components/datepicker"
const locales = {
@@ -33,6 +31,8 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
const { register, setValue } = useFormContext()
const ref = useRef<HTMLDivElement | null>(null)
const [isSelectingFrom, setIsSelectingFrom] = useState(true)
function close() {
setIsOpen(false)
}
@@ -41,11 +41,29 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
setIsOpen((prevIsOpen) => !prevIsOpen)
}
function handleSelectDate(selected: DateRange) {
setValue(name, {
from: dt(selected.from).format("YYYY-MM-DD"),
to: dt(selected.to).format("YYYY-MM-DD"),
})
function handleSelectDate(selected: Date) {
if (isSelectingFrom) {
setValue(name, {
from: dt(selected).format("YYYY-MM-DD"),
to: undefined,
})
setIsSelectingFrom(false)
} else {
const fromDate = dt(selectedDate.from)
const toDate = dt(selected)
if (toDate.isAfter(fromDate)) {
setValue(name, {
from: selectedDate.from,
to: toDate.format("YYYY-MM-DD"),
})
} else {
setValue(name, {
from: toDate.format("YYYY-MM-DD"),
to: selectedDate.from,
})
}
setIsSelectingFrom(true)
}
}
useEffect(() => {
@@ -64,7 +82,9 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
const selectedFromDate = dt(selectedDate.from)
.locale(lang)
.format("ddd D MMM")
const selectedToDate = dt(selectedDate.to).locale(lang).format("ddd D MMM")
const selectedToDate = !!selectedDate.to
? dt(selectedDate.to).locale(lang).format("ddd D MMM")
: ""
return (
<div className={styles.container} data-isopen={isOpen} ref={ref}>

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ export default function ClearSearchButton({
type="button"
>
<DeleteIcon color="burgundy" height={20} width={20} />
<Caption color="burgundy" textTransform="bold">
<Caption color="burgundy" type="bold">
{intl.formatMessage({ id: "Clear searches" })}
</Caption>
</button>

View File

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

View File

@@ -10,7 +10,6 @@ import {
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Input from "../Input"
@@ -49,15 +48,6 @@ export default function Search({ locations }: SearchProps) {
dispatch({ type: ActionType.CLEAR_HISTORY_LOCATIONS })
}
function handleOnBlur() {
if (!value && state.searchData?.name) {
setValue(name, state.searchData.name)
// Always need to manually trigger
// revalidation when setting value r-h-f
trigger()
}
}
function handleOnChange(
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
) {
@@ -139,7 +129,9 @@ export default function Search({ locations }: SearchProps) {
<div className={styles.container}>
<label {...getLabelProps({ htmlFor: name })} className={styles.label}>
<Caption color={isOpen ? "uiTextActive" : "red"}>
{intl.formatMessage({ id: "Where to" })}
{state.searchData?.type === "hotels"
? state.searchData?.relationships?.city?.name
: intl.formatMessage({ id: "Where to" })}
</Caption>
</label>
<div {...getRootProps({}, { suppressRefError: true })}>
@@ -155,7 +147,6 @@ export default function Search({ locations }: SearchProps) {
}),
...register(name, {
onBlur: function () {
handleOnBlur()
closeMenu()
},
onChange: handleOnChange,

View File

@@ -4,6 +4,7 @@
border-width: 1px;
border-radius: var(--Corner-radius-Small);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
position: relative;
}
.container:hover,
@@ -23,8 +24,3 @@
p {
color: var(--UI-Text-Active);
}
.container:hover:has(input:not(:active, :focus, :focus-within))
input::-webkit-search-cancel-button {
display: none;
}

View File

@@ -1,7 +1,7 @@
"use client"
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { Tooltip } from "@/components/TempDesignSystem/Tooltip"
@@ -34,7 +34,7 @@ export default function Voucher() {
>
<div className={styles.vouchers}>
<label>
<Caption color="disabled" textTransform="bold">
<Caption color="disabled" type="bold">
{vouchers}
</Caption>
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
@@ -50,17 +50,17 @@ export default function Voucher() {
>
<div className={styles.options}>
<label className={`${styles.option} ${styles.checkboxVoucher}`}>
<input type="checkbox" disabled className={styles.checkbox} />
<Checkbox name="useVouchers" registerOptions={{ disabled: true }} />
<Caption color="disabled">{useVouchers}</Caption>
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
</label>
<label className={styles.option}>
<input type="checkbox" disabled className={styles.checkbox} />
<Checkbox name="useBonus" registerOptions={{ disabled: true }} />
<Caption color="disabled">{bonus}</Caption>
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
</label>
<label className={styles.option}>
<input type="checkbox" disabled className={styles.checkbox} />
<Checkbox name="useReward" registerOptions={{ disabled: true }} />
<Caption color="disabled">{reward}</Caption>
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
</label>

View File

@@ -32,6 +32,10 @@
display: none;
}
.infoIcon {
stroke: var(--Base-Text-Disabled);
}
@media screen and (min-width: 768px) {
.vouchers {
display: none;
@@ -64,7 +68,7 @@
.options {
flex-direction: column;
max-width: 190px;
gap: 0;
gap: var(--Spacing-x-half);
}
.vouchers:hover,
.option:hover {
@@ -72,6 +76,7 @@
}
.optionsContainer {
flex-direction: row;
align-items: center;
}
.checkboxVoucher {
display: none;

View File

@@ -1,7 +1,3 @@
.infoIcon {
stroke: var(--Base-Text-Disabled);
}
.vouchersHeader {
display: flex;
gap: var(--Spacing-x-one-and-half);
@@ -79,8 +75,10 @@
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
border-radius: var(--Corner-radius-Small);
}
.when:hover,
.rooms:hover,
.when:has([data-isopen="true"]),
.rooms:has(.input:active, .input:focus, .input:focus-within) {
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}

View File

@@ -1,15 +1,16 @@
"use client"
import { useState } from "react"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import DatePicker from "@/components/DatePicker"
import GuestsRoomsPickerForm from "@/components/GuestsRoomsPicker"
import { SearchIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Input from "./Input"
import Search from "./Search"
import Voucher from "./Voucher"
@@ -20,7 +21,6 @@ import type { BookingWidgetFormContentProps } from "@/types/components/form/book
export default function FormContent({
locations,
formId,
formState,
}: BookingWidgetFormContentProps) {
const intl = useIntl()
const selectedDate = useWatch({ name: "date" })
@@ -37,21 +37,21 @@ export default function FormContent({
<Search locations={locations} />
</div>
<div className={styles.when}>
<Caption color="red" textTransform="bold">
<Caption color="red" type="bold">
{intl.formatMessage(
{ id: "booking.nights" },
{ totalNights: nights }
{ totalNights: nights > 0 ? nights : 0 }
)}
</Caption>
<DatePicker />
</div>
<div className={styles.rooms}>
<label>
<Caption color="red" textTransform="bold">
<Caption color="red" type="bold">
{rooms}
</Caption>
</label>
<Input type="text" placeholder={rooms} />
<GuestsRoomsPickerForm />
</div>
</div>
<div className={styles.voucherContainer}>
@@ -60,17 +60,12 @@ export default function FormContent({
<div className={styles.buttonContainer}>
<Button
className={styles.button}
disabled={!formState.isValid}
form={formId}
intent="primary"
theme="base"
type="submit"
>
<Caption
color="white"
textTransform="bold"
className={styles.buttonText}
>
<Caption color="white" type="bold" className={styles.buttonText}>
{intl.formatMessage({ id: "Search" })}
</Caption>
<div className={styles.icon}>

View File

@@ -22,6 +22,7 @@
@media screen and (min-width: 768px) {
.section {
display: flex;
width: 100%;
}
.default {
@@ -35,6 +36,13 @@
var(--Spacing-x-one-and-half) var(--Spacing-x1);
}
.section {
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.full {
padding: var(--Spacing-x1) 0;
}

View File

@@ -2,6 +2,10 @@
import { useRouter } from "next/navigation"
import { useFormContext } from "react-hook-form"
import { selectHotel, selectRate } from "@/constants/routes/hotelReservation"
import useLang from "@/hooks/useLang"
import FormContent from "./FormContent"
import { bookingWidgetVariants } from "./variants"
@@ -9,11 +13,13 @@ import styles from "./form.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
import { Location } from "@/types/trpc/routers/hotel/locations"
const formId = "booking-widget"
export default function Form({ locations, type }: BookingWidgetFormProps) {
const router = useRouter()
const lang = useLang()
const classNames = bookingWidgetVariants({
type,
@@ -23,11 +29,32 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
useFormContext<BookingWidgetSchema>()
function onSubmit(data: BookingWidgetSchema) {
data.location = JSON.parse(decodeURIComponent(data.location))
console.log(data)
// TODO: Parse data and route accordignly to Select hotel or select room-rate page
console.log("to be routing")
router.push("/en/hotelreservation/select-hotel")
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
const bookingFlowPage =
locationData.type == "cities" ? selectHotel[lang] : selectRate[lang]
const bookingWidgetParams = new URLSearchParams(data.date)
if (locationData.type == "cities")
bookingWidgetParams.set("city", locationData.name)
else bookingWidgetParams.set("hotel", locationData.operaId || "")
data.rooms.forEach((room, index) => {
bookingWidgetParams.set(`room[${index}].adults`, room.adults.toString())
room.children.forEach((child, childIndex) => {
bookingWidgetParams.set(
`room[${index}].child[${childIndex}].age`,
child.age.toString()
)
bookingWidgetParams.set(
`room[${index}].child[${childIndex}].bed`,
child.bed.toString()
)
})
})
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
}
return (

View File

@@ -2,6 +2,18 @@ import { z } from "zod"
import type { Location } from "@/types/trpc/routers/hotel/locations"
export const guestRoomSchema = z.object({
adults: z.number().default(1),
children: z.array(
z.object({
age: z.number().nonnegative(),
bed: z.number(),
})
),
})
export const guestRoomsSchema = z.array(guestRoomSchema)
export const bookingWidgetSchema = z.object({
bookingCode: z.string(), // Update this as required when working with booking codes component
date: z.object({
@@ -25,18 +37,7 @@ export const bookingWidgetSchema = z.object({
{ message: "Required" }
),
redemption: z.boolean().default(false),
rooms: z.array(
// This will be updated when working in guests component
z.object({
adults: z.number().default(1),
children: z.array(
z.object({
age: z.number(),
bed: z.number(),
})
),
})
),
rooms: guestRoomsSchema,
search: z.string({ coerce: true }).min(1, "Required"),
voucher: z.boolean().default(false),
})

View File

@@ -24,7 +24,7 @@ export default function FormContent() {
const email = `${intl.formatMessage({ id: "Email" })} ${intl.formatMessage({ id: "Address" }).toLowerCase()}`
const street = intl.formatMessage({ id: "Address" })
const phoneNumber = intl.formatMessage({ id: "Phone number" })
const password = intl.formatMessage({ id: "Current password" })
const currentPassword = intl.formatMessage({ id: "Current password" })
const retypeNewPassword = intl.formatMessage({ id: "Retype new password" })
const zipCode = intl.formatMessage({ id: "Zip code" })
@@ -72,8 +72,10 @@ export default function FormContent() {
{intl.formatMessage({ id: "Password" })}
</Body>
</header>
<Input label={password} name="password" type="password" />
<NewPassword />
<Input label={currentPassword} name="password" type="password" />
{/* visibilityToggleable set to false as feature is done for signup first */}
{/* likely we can remove the prop altogether once signup launches */}
<NewPassword visibilityToggleable={false} />
<Input
label={retypeNewPassword}
name="retypeNewPassword"

View File

@@ -26,7 +26,7 @@ export const editProfileSchema = z
),
password: z.string().optional(),
newPassword: passwordValidator(),
newPassword: z.literal("").optional().or(passwordValidator()),
retypeNewPassword: z.string().optional(),
})
.superRefine((data, ctx) => {

View File

@@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { signupTerms } from "@/constants/routes/signup"
import { privacyPolicy } from "@/constants/currentWebHrefs"
import { registerUser } from "@/actions/registerUser"
import Button from "@/components/TempDesignSystem/Button"
@@ -107,7 +107,7 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
</div>
<div className={styles.dateField}>
<header>
<Caption textTransform="bold">
<Caption type="bold">
{intl.formatMessage({ id: "Birth date" })}
</Caption>
</header>
@@ -163,7 +163,7 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
variant="underscored"
color="peach80"
target="_blank"
href={signupTerms[lang]}
href={privacyPolicy[lang]}
>
{intl.formatMessage({ id: "Scandic's Privacy Policy." })}
</Link>

View File

@@ -0,0 +1,5 @@
.container {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@@ -0,0 +1,75 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Counter from "../Counter"
import styles from "./adult-selector.module.css"
import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
import {
AdultSelectorProps,
Child,
} from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) {
const intl = useIntl()
const adultsLabel = intl.formatMessage({ id: "Adults" })
const { setValue } = useFormContext()
const { adults, children, childrenInAdultsBed } = useGuestsRoomsStore(
(state) => state.rooms[roomIndex]
)
const increaseAdults = useGuestsRoomsStore((state) => state.increaseAdults)
const decreaseAdults = useGuestsRoomsStore((state) => state.decreaseAdults)
function increaseAdultsCount(roomIndex: number) {
if (adults < 6) {
increaseAdults(roomIndex)
setValue(`rooms.${roomIndex}.adults`, adults + 1)
}
}
function decreaseAdultsCount(roomIndex: number) {
if (adults > 1) {
decreaseAdults(roomIndex)
setValue(`rooms.${roomIndex}.adults`, adults - 1)
if (childrenInAdultsBed > adults) {
const toUpdateIndex = children.findIndex(
(child: Child) => child.bed == BedTypeEnum.IN_ADULTS_BED
)
if (toUpdateIndex != -1) {
setValue(
`rooms.${roomIndex}.children.${toUpdateIndex}.bed`,
children[toUpdateIndex].age < 3
? BedTypeEnum.IN_CRIB
: BedTypeEnum.IN_EXTRA_BED
)
}
}
}
}
return (
<section className={styles.container}>
<Caption color="uiTextHighContrast" type="bold">
{adultsLabel}
</Caption>
<Counter
count={adults}
handleOnDecrease={() => {
decreaseAdultsCount(roomIndex)
}}
handleOnIncrease={() => {
increaseAdultsCount(roomIndex)
}}
disableDecrease={adults == 1}
disableIncrease={adults == 6}
/>
</section>
)
}

View File

@@ -0,0 +1,140 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import { ErrorCircleIcon } from "@/components/Icons"
import Select from "@/components/TempDesignSystem/Select"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./child-selector.module.css"
import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
import {
ChildBed,
ChildInfoSelectorProps,
} from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function ChildInfoSelector({
child = { age: -1, bed: -1 },
index = 0,
roomIndex = 0,
}: ChildInfoSelectorProps) {
const intl = useIntl()
const ageLabel = intl.formatMessage({ id: "Age" })
const ageReqdErrMsg = intl.formatMessage({ id: "Child age is required" })
const bedLabel = intl.formatMessage({ id: "Bed" })
const { setValue, trigger } = useFormContext()
const { adults, childrenInAdultsBed } = useGuestsRoomsStore(
(state) => state.rooms[roomIndex]
)
const {
isValidated,
updateChildAge,
updateChildBed,
increaseChildInAdultsBed,
decreaseChildInAdultsBed,
} = useGuestsRoomsStore((state) => ({
isValidated: state.isValidated,
updateChildAge: state.updateChildAge,
updateChildBed: state.updateChildBed,
increaseChildInAdultsBed: state.increaseChildInAdultsBed,
decreaseChildInAdultsBed: state.decreaseChildInAdultsBed,
}))
const ageList = Array.from(Array(13).keys()).map((age) => ({
label: `${age}`,
value: age,
}))
function updateSelectedAge(age: number) {
updateChildAge(age, roomIndex, index)
setValue(`rooms.${roomIndex}.children.${index}.age`, age)
const availableBedTypes = getAvailableBeds(age)
updateSelectedBed(availableBedTypes[0].value)
trigger("rooms")
}
function updateSelectedBed(bed: number) {
if (bed == BedTypeEnum.IN_ADULTS_BED) {
increaseChildInAdultsBed(roomIndex)
} else if (child.bed == BedTypeEnum.IN_ADULTS_BED) {
decreaseChildInAdultsBed(roomIndex)
}
updateChildBed(bed, roomIndex, index)
setValue(`rooms.${roomIndex}.children.${index}.bed`, bed)
}
const allBedTypes: ChildBed[] = [
{
label: intl.formatMessage({ id: "In adults bed" }),
value: BedTypeEnum.IN_ADULTS_BED,
},
{
label: intl.formatMessage({ id: "In crib" }),
value: BedTypeEnum.IN_CRIB,
},
{
label: intl.formatMessage({ id: "In extra bed" }),
value: BedTypeEnum.IN_EXTRA_BED,
},
]
function getAvailableBeds(age: number) {
let availableBedTypes: ChildBed[] = []
if (age <= 5 && (adults > childrenInAdultsBed || child.bed === 0)) {
availableBedTypes.push(allBedTypes[0])
}
if (age < 3) {
availableBedTypes.push(allBedTypes[1])
}
if (age > 2) {
availableBedTypes.push(allBedTypes[2])
}
return availableBedTypes
}
return (
<>
<div key={index} className={styles.childInfoContainer}>
<div>
<Select
required={true}
items={ageList}
label={ageLabel}
aria-label={ageLabel}
value={child.age}
onSelect={(key) => {
updateSelectedAge(key as number)
}}
name={`rooms.${roomIndex}.children.${index}.age`}
placeholder={ageLabel}
/>
</div>
<div>
{child.age !== -1 ? (
<Select
items={getAvailableBeds(child.age)}
label={bedLabel}
aria-label={bedLabel}
value={child.bed}
onSelect={(key) => {
updateSelectedBed(key as number)
}}
name={`rooms.${roomIndex}.children.${index}.age`}
placeholder={bedLabel}
/>
) : null}
</div>
</div>
{isValidated && child.age < 0 ? (
<Caption color="red" className={styles.error}>
<ErrorCircleIcon color="red" />
{ageReqdErrMsg}
</Caption>
) : null}
</>
)
}

View File

@@ -0,0 +1,21 @@
.container {
display: flex;
justify-content: space-between;
align-items: center;
}
.captionBold {
font-weight: 600;
}
.childInfoContainer {
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: 1fr 2fr;
}
.error {
display: flex;
align-items: center;
gap: var(--Spacing-x1);
}

View File

@@ -0,0 +1,78 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Counter from "../Counter"
import ChildInfoSelector from "./ChildInfoSelector"
import styles from "./child-selector.module.css"
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
import { ChildSelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function ChildSelector({ roomIndex = 0 }: ChildSelectorProps) {
const intl = useIntl()
const childrenLabel = intl.formatMessage({ id: "Children" })
const { setValue, trigger } = useFormContext<BookingWidgetSchema>()
const children = useGuestsRoomsStore(
(state) => state.rooms[roomIndex].children
)
const increaseChildren = useGuestsRoomsStore(
(state) => state.increaseChildren
)
const decreaseChildren = useGuestsRoomsStore(
(state) => state.decreaseChildren
)
function increaseChildrenCount(roomIndex: number) {
if (children.length < 5) {
increaseChildren(roomIndex)
setValue(`rooms.${roomIndex}.children.${children.length}`, {
age: -1,
bed: -1,
})
trigger("rooms")
}
}
function decreaseChildrenCount(roomIndex: number) {
if (children.length > 0) {
const newChildrenList = decreaseChildren(roomIndex)
setValue(`rooms.${roomIndex}.children`, newChildrenList)
trigger("rooms")
}
}
return (
<>
<section className={styles.container}>
<Caption color="uiTextHighContrast" type="bold">
{childrenLabel}
</Caption>
<Counter
count={children.length}
handleOnDecrease={() => {
decreaseChildrenCount(roomIndex)
}}
handleOnIncrease={() => {
increaseChildrenCount(roomIndex)
}}
disableDecrease={children.length == 0}
disableIncrease={children.length == 5}
/>
</section>
{children.map((child, index) => (
<ChildInfoSelector
roomIndex={roomIndex}
index={index}
child={child}
key={"child_" + index}
/>
))}
</>
)
}

View File

@@ -0,0 +1,13 @@
.counterContainer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 20px;
}
.counterBtn {
width: 40px;
height: 40px;
}
.counterBtn:not([disabled]) {
box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, 0.1);
}

View File

@@ -0,0 +1,49 @@
"use client"
import { MinusIcon, PlusIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./counter.module.css"
import { CounterProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function Counter({
count,
handleOnIncrease,
handleOnDecrease,
disableIncrease,
disableDecrease,
}: CounterProps) {
return (
<div className={styles.counterContainer}>
<Button
className={styles.counterBtn}
intent="inverted"
onClick={handleOnDecrease}
size="small"
theme="base"
variant="icon"
wrapping={true}
disabled={disableDecrease}
>
<MinusIcon color="burgundy" />
</Button>
<Body color="textHighContrast" textAlign="center">
{count}
</Body>
<Button
className={styles.counterBtn}
onClick={handleOnIncrease}
intent="inverted"
variant="icon"
theme="base"
wrapping={true}
size="small"
disabled={disableIncrease}
>
<PlusIcon color="burgundy" />
</Button>
</div>
)
}

View File

@@ -0,0 +1,136 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import { CloseLargeIcon, PlusCircleIcon, PlusIcon } from "../Icons"
import Button from "../TempDesignSystem/Button"
import Divider from "../TempDesignSystem/Divider"
import Subtitle from "../TempDesignSystem/Text/Subtitle"
import { Tooltip } from "../TempDesignSystem/Tooltip"
import AdultSelector from "./AdultSelector"
import ChildSelector from "./ChildSelector"
import styles from "./guests-rooms-picker.module.css"
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
import { GuestsRoomsPickerProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function GuestsRoomsPicker({
closePicker,
}: GuestsRoomsPickerProps) {
const intl = useIntl()
const doneLabel = intl.formatMessage({ id: "Done" })
const roomLabel = intl.formatMessage({ id: "Room" })
const disabledBookingOptionsHeader = intl.formatMessage({
id: "Disabled booking options header",
})
const disabledBookingOptionsText = intl.formatMessage({
id: "Disabled booking options text",
})
const addRoomLabel = intl.formatMessage({ id: "Add Room" })
const { getFieldState } = useFormContext<BookingWidgetSchema>()
const rooms = useGuestsRoomsStore((state) => state.rooms)
// Not in MVP
// const increaseRoom = useGuestsRoomsStore.use.increaseRoom()
// const decreaseRoom = useGuestsRoomsStore.use.decreaseRoom()
return (
<div className={styles.pickerContainer}>
<header className={styles.header}>
<button type="button" className={styles.close} onClick={closePicker}>
<CloseLargeIcon />
</button>
</header>
<div className={styles.contentContainer}>
{rooms.map((room, index) => (
<div className={styles.roomContainer} key={index}>
<section className={styles.roomDetailsContainer}>
<Subtitle type="two" className={styles.roomHeading}>
{roomLabel} {index + 1}
</Subtitle>
<AdultSelector roomIndex={index} />
<ChildSelector roomIndex={index} />
</section>
{/* Not in MVP
{index > 0 ? (
<Button intent="text" onClick={() => decreaseRoom(index)}>
Remove Room
</Button>
) : null} */}
<Divider color="primaryLightSubtle" />
</div>
))}
<div className={styles.addRoomMobileContainer}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="top"
arrow="left"
>
{rooms.length < 4 ? (
<Button
intent="text"
variant="icon"
wrapping
disabled
theme="base"
fullWidth
>
<PlusIcon />
{addRoomLabel}
</Button>
) : null}
</Tooltip>
</div>
</div>
<footer className={styles.footer}>
<div className={styles.hideOnMobile}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="top"
arrow="left"
>
{rooms.length < 4 ? (
<Button
intent="text"
variant="icon"
wrapping
disabled
theme="base"
>
<PlusCircleIcon />
{addRoomLabel}
</Button>
) : null}
</Tooltip>
</div>
<Button
onClick={closePicker}
disabled={getFieldState("rooms").invalid}
className={styles.hideOnMobile}
intent="tertiary"
theme="base"
size="small"
>
{doneLabel}
</Button>
<Button
onClick={closePicker}
disabled={getFieldState("rooms").invalid}
className={styles.hideOnDesktop}
intent="tertiary"
theme="base"
size="large"
>
{doneLabel}
</Button>
</footer>
</div>
)
}

View File

@@ -0,0 +1,148 @@
.container {
overflow: hidden;
position: relative;
&[data-isopen="true"] {
overflow: visible;
}
}
.roomContainer {
display: grid;
gap: var(--Spacing-x2);
}
.roomDetailsContainer {
display: grid;
gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x1);
}
.hideWrapper {
background-color: var(--Main-Grey-White);
}
.roomHeading {
margin-bottom: var(--Spacing-x1);
}
.btn {
background: none;
border: none;
cursor: pointer;
outline: none;
padding: 0;
width: 100%;
}
.body {
opacity: 0.8;
}
.footer {
display: grid;
gap: var(--Spacing-x1);
grid-template-columns: auto;
margin-top: var(--Spacing-x2);
}
@media screen and (max-width: 1366px) {
.hideWrapper {
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 100%;
transition: top 300ms ease;
z-index: 10002;
overflow: hidden;
}
.container[data-isopen="true"] .hideWrapper {
top: 20px;
}
.pickerContainer {
--header-height: 72px;
--sticky-button-height: 140px;
display: grid;
grid-template-areas:
"header"
"content";
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
position: relative;
}
.contentContainer {
grid-area: content;
overflow-y: scroll;
scroll-snap-type: y mandatory;
}
.header {
background-color: var(--Main-Grey-White);
display: grid;
grid-area: header;
padding: var(--Spacing-x3) var(--Spacing-x2);
}
.close {
background: none;
border: none;
cursor: pointer;
display: flex;
justify-self: flex-end;
padding: 0;
}
.roomContainer {
padding: 0 var(--Spacing-x2);
}
.roomContainer:last-of-type {
padding-bottom: calc(var(--sticky-button-height) + 20px);
}
.footer {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 7.5%,
#ffffff 82.5%
);
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x7);
position: sticky;
bottom: 0;
width: 100%;
z-index: 10;
}
.footer .hideOnMobile {
display: none;
}
.addRoomMobileContainer {
display: grid;
width: 150px;
margin: 0 auto;
padding-bottom: calc(var(--sticky-button-height) + 20px);
}
}
@media screen and (min-width: 1367px) {
.hideWrapper {
border-radius: var(--Corner-radius-Large);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
left: calc((var(--Spacing-x1) + var(--Spacing-x2)) * -1);
max-width: calc(100vw - 20px);
padding: var(--Spacing-x2) var(--Spacing-x3);
position: absolute;
top: calc(100% + var(--Spacing-x2) + 1px + var(--Spacing-x4));
width: 360px;
max-height: calc(100dvh - 77px - var(--Spacing-x6));
overflow-y: auto;
}
.header {
display: none;
}
.footer {
grid-template-columns: auto auto;
}
.footer .hideOnDesktop,
.addRoomMobileContainer {
display: none;
}
}

View File

@@ -0,0 +1,80 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import { guestRoomsSchema } from "@/components/Forms/BookingWidget/schema"
import Body from "@/components/TempDesignSystem/Text/Body"
import GuestsRoomsPicker from "./GuestsRoomsPicker"
import styles from "./guests-rooms-picker.module.css"
export default function GuestsRoomsPickerForm() {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
const { rooms, adultCount, childCount, setIsValidated } = useGuestsRoomsStore(
(state) => ({
rooms: state.rooms,
adultCount: state.adultCount,
childCount: state.childCount,
setIsValidated: state.setIsValidated,
})
)
const ref = useRef<HTMLDivElement | null>(null)
function handleOnClick() {
setIsOpen((prevIsOpen) => !prevIsOpen)
}
const closePicker = useCallback(() => {
const guestRoomsValidData = guestRoomsSchema.safeParse(rooms)
if (guestRoomsValidData.success) {
setIsOpen(false)
setIsValidated(false)
} else {
setIsValidated(true)
}
}, [rooms, setIsValidated, setIsOpen])
useEffect(() => {
function handleClickOutside(evt: Event) {
const target = evt.target as HTMLElement
if (ref.current && target && !ref.current.contains(target)) {
closePicker()
}
}
document.addEventListener("click", handleClickOutside)
return () => {
document.removeEventListener("click", handleClickOutside)
}
}, [closePicker])
return (
<div className={styles.container} data-isopen={isOpen} ref={ref}>
<button className={styles.btn} onClick={handleOnClick} type="button">
<Body className={styles.body}>
{intl.formatMessage(
{ id: "booking.rooms" },
{ totalRooms: rooms.length }
)}
{", "}
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: adultCount }
)}
{childCount > 0
? ", " +
intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: childCount }
)
: null}
</Body>
</button>
<div aria-modal className={styles.hideWrapper} role="dialog">
<GuestsRoomsPicker closePicker={closePicker} />
</div>
</div>
)
}

View File

@@ -32,8 +32,8 @@
}
.ecoLabel {
display: grid;
grid-template-columns: auto 1fr;
display: flex;
align-items: center;
column-gap: var(--Spacing-x-one-and-half);
grid-column: 2 / 3;
grid-row: 3 / 4;

View File

@@ -24,20 +24,26 @@ export default function Contact({ hotel }: ContactProps) {
<span className={styles.heading}>
{intl.formatMessage({ id: "Address" })}
</span>
<span>{hotel.address.streetAddress}</span>
<span>{hotel.address.city}</span>
<span>
{`${hotel.address.streetAddress}, ${hotel.address.city}`}
</span>
</li>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Driving directions" })}
</span>
<Link href="#">{intl.formatMessage({ id: "Google Maps" })}</Link>
<Link href="#" color="peach80">
Google Maps
</Link>
</li>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Email" })}
</span>
<Link href={`mailto:${hotel.contactInformation.email}`}>
<Link
href={`mailto:${hotel.contactInformation.email}`}
color="peach80"
>
{hotel.contactInformation.email}
</Link>
</li>
@@ -45,7 +51,10 @@ export default function Contact({ hotel }: ContactProps) {
<span className={styles.heading}>
{intl.formatMessage({ id: "Contact us" })}
</span>
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
<Link
href={`tel:${hotel.contactInformation.phoneNumber}`}
color="peach80"
>
{hotel.contactInformation.phoneNumber}
</Link>
</li>

View File

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

View File

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

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