Merged in monorepo-step-1 (pull request #1080)
Migrate to a monorepo setup - step 1 * Move web to subfolder /apps/scandic-web * Yarn + transitive deps - Move to yarn - design-system package removed for now since yarn doesn't support the parameter for token (ie project currently broken) - Add missing transitive dependencies as Yarn otherwise prevents these imports - VS Code doesn't pick up TS path aliases unless you open /apps/scandic-web instead of root (will be fixed with monorepo) * Pin framer-motion to temporarily fix typing issue https://github.com/adobe/react-spectrum/issues/7494 * Pin zod to avoid typ error There seems to have been a breaking change in the types returned by zod where error is now returned as undefined instead of missing in the type. We should just handle this but to avoid merge conflicts just pin the dependency for now. * Pin react-intl version Pin version of react-intl to avoid tiny type issue where formatMessage does not accept a generic any more. This will be fixed in a future commit, but to avoid merge conflicts just pin for now. * Pin typescript version Temporarily pin version as newer versions as stricter and results in a type error. Will be fixed in future commit after merge. * Setup workspaces * Add design-system as a monorepo package * Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN * Fix husky for monorepo setup * Update netlify.toml * Add lint script to root package.json * Add stub readme * Fix react-intl formatMessage types * Test netlify.toml in root * Remove root toml * Update netlify.toml publish path * Remove package-lock.json * Update build for branch/preview builds Approved-by: Linus Flood
This commit is contained in:
committed by
Linus Flood
parent
667cab6fb6
commit
80100e7631
@@ -0,0 +1,24 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
|
||||
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
|
||||
export default function TabletCodeInput({
|
||||
updateValue,
|
||||
}: {
|
||||
updateValue: (val: string) => void
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const { register } = useFormContext<BookingWidgetSchema>()
|
||||
return (
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "Add code" })}
|
||||
{...register("bookingCode.value", {
|
||||
onChange: (e) => updateValue(e.target.value),
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bookingCodeLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.bookingCodeRemember,
|
||||
.bookingCodeRememberVisible {
|
||||
display: none;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.bookingCodeRememberVisible {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: calc(100% + 16px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.hideOnMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.bookingCodeRememberVisible {
|
||||
align-items: center;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
justify-content: space-between;
|
||||
border-radius: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) and (max-width: 1367px) {
|
||||
.container {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
.codePopover {
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Spacing-x-one-and-half);
|
||||
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
||||
padding: var(--Spacing-x2);
|
||||
width: 320px;
|
||||
}
|
||||
.popover {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
.bookingCodeRememberVisible {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.bookingCodeRememberVisible {
|
||||
padding: var(--Spacing-x2);
|
||||
width: 320px;
|
||||
top: calc(100% + 24px);
|
||||
left: calc(0% - var(--Spacing-x-one-and-half));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Dialog, DialogTrigger, Popover } from "react-aria-components"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { ErrorCircleIcon, InfoCircleIcon } from "@/components/Icons"
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Switch from "@/components/TempDesignSystem/Form/Switch"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Input from "../Input"
|
||||
import TabletCodeInput from "./TabletCodeInput"
|
||||
|
||||
import styles from "./booking-code.module.css"
|
||||
|
||||
import type {
|
||||
BookingCodeSchema,
|
||||
BookingWidgetSchema,
|
||||
} from "@/types/components/bookingWidget"
|
||||
|
||||
export default function BookingCode() {
|
||||
const intl = useIntl()
|
||||
const checkIsTablet = useMediaQuery(
|
||||
"(min-width: 768px) and (max-width: 1367px)"
|
||||
)
|
||||
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
const [isDesktop, setIsDesktop] = useState(false)
|
||||
const {
|
||||
setValue,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
register,
|
||||
} = useFormContext<BookingWidgetSchema>()
|
||||
|
||||
const bookingCode: BookingCodeSchema = getValues("bookingCode")
|
||||
const [isOpen, setIsOpen] = useState(!!bookingCode?.value)
|
||||
const [showRemember, setShowRemember] = useState(false)
|
||||
const [showRememberMobile, setShowRememberMobile] = useState(false)
|
||||
const codeError = errors["bookingCode"]?.value
|
||||
const codeVoucher = intl.formatMessage({ id: "Code / Voucher" })
|
||||
const addCode = intl.formatMessage({ id: "Add code" })
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
function updateBookingCodeFormValue(value: string) {
|
||||
setValue("bookingCode.value", value, { shouldValidate: true })
|
||||
}
|
||||
|
||||
function toggleModal(isOpen: boolean) {
|
||||
if (!isOpen && !bookingCode?.value) {
|
||||
setValue("bookingCode.flag", false)
|
||||
setIsOpen(isOpen)
|
||||
} else if (!codeError || isOpen) {
|
||||
setIsOpen(isOpen)
|
||||
if (isOpen || bookingCode?.value) {
|
||||
setValue("bookingCode.flag", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
const closeIfOutside = useCallback(
|
||||
(target: HTMLElement) => {
|
||||
if (ref.current && target && !ref.current.contains(target)) {
|
||||
setShowRemember(false)
|
||||
}
|
||||
},
|
||||
[setShowRemember, ref]
|
||||
)
|
||||
|
||||
function showRememberCheck() {
|
||||
setShowRemember(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsTablet(checkIsTablet)
|
||||
}, [checkIsTablet])
|
||||
|
||||
useEffect(() => {
|
||||
setIsDesktop(checkIsDesktop)
|
||||
}, [checkIsDesktop])
|
||||
|
||||
const isRememberMobileVisible =
|
||||
!isDesktop && (showRemember || !!bookingCode?.remember)
|
||||
useEffect(() => {
|
||||
setShowRememberMobile(isRememberMobileVisible)
|
||||
}, [isRememberMobileVisible])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(evt: Event) {
|
||||
if (showRemember) {
|
||||
const target = evt.target as HTMLElement
|
||||
closeIfOutside(target)
|
||||
}
|
||||
}
|
||||
document.body.addEventListener("click", handleClickOutside)
|
||||
return () => {
|
||||
document.body.removeEventListener("click", handleClickOutside)
|
||||
}
|
||||
}, [closeIfOutside, showRemember])
|
||||
|
||||
return isTablet ? (
|
||||
<div className={styles.container}>
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={toggleModal}>
|
||||
<Button type="button" intent="text">
|
||||
<Checkbox
|
||||
checked={!!bookingCode?.value}
|
||||
{...register("bookingCode.flag", {
|
||||
onChange: function () {
|
||||
if (bookingCode?.value || isOpen) {
|
||||
setValue("bookingCode.flag", true)
|
||||
}
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Caption color="red" type="bold" asChild>
|
||||
<span>{codeVoucher}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
</Button>
|
||||
<Popover
|
||||
className={styles.codePopover}
|
||||
placement="bottom start"
|
||||
offset={36}
|
||||
>
|
||||
<Dialog>
|
||||
{({ close }) => (
|
||||
<div className={styles.popover}>
|
||||
<TabletCodeInput updateValue={updateBookingCodeFormValue} />
|
||||
<div className={styles.bookingCodeRememberVisible}>
|
||||
<CodeRemember
|
||||
bookingCodeValue={bookingCode?.value}
|
||||
onApplyClick={close}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
<CodeRulesModal />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={styles.container}
|
||||
ref={ref}
|
||||
onFocus={showRememberCheck}
|
||||
onBlur={(e) => closeIfOutside(e.nativeEvent.relatedTarget as HTMLElement)}
|
||||
>
|
||||
<div className={styles.bookingCodeLabel}>
|
||||
<Caption
|
||||
color={showRemember ? "uiTextActive" : "red"}
|
||||
type="bold"
|
||||
asChild
|
||||
>
|
||||
<span>{codeVoucher}</span>
|
||||
</Caption>
|
||||
<CodeRulesModal />
|
||||
</div>
|
||||
<Input
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder={addCode}
|
||||
name="bookingCode.value"
|
||||
id="booking-code"
|
||||
onChange={(event) => updateBookingCodeFormValue(event.target.value)}
|
||||
defaultValue={bookingCode?.value}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{codeError?.message ? (
|
||||
<Caption color="red" className={styles.error}>
|
||||
<ErrorCircleIcon color="red" className={styles.errorIcon} />
|
||||
{intl.formatMessage({ id: codeError.message })}
|
||||
</Caption>
|
||||
) : null}
|
||||
{isDesktop ? (
|
||||
<div
|
||||
className={
|
||||
showRemember
|
||||
? styles.bookingCodeRememberVisible
|
||||
: styles.bookingCodeRemember
|
||||
}
|
||||
>
|
||||
<CodeRemember
|
||||
bookingCodeValue={bookingCode?.value}
|
||||
onApplyClick={() => setShowRemember(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
showRememberMobile
|
||||
? styles.bookingCodeRememberVisible
|
||||
: styles.bookingCodeRemember
|
||||
}
|
||||
>
|
||||
<Switch name="bookingCode.remember" className="mobile-switch">
|
||||
<Caption asChild>
|
||||
<span>{intl.formatMessage({ id: "Remember code" })}</span>
|
||||
</Caption>
|
||||
</Switch>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CodeRememberProps = {
|
||||
bookingCodeValue: string | undefined
|
||||
onApplyClick: () => void
|
||||
}
|
||||
|
||||
function CodeRulesModal() {
|
||||
const intl = useIntl()
|
||||
const codeVoucher = intl.formatMessage({ id: "Code / Voucher" })
|
||||
const bookingCodeTooltipText = intl.formatMessage({
|
||||
id: "If you're booking a promotional offer or a Corporate negotiated rate you'll need a special booking code. Don't use any special characters such as (.) (,) (-) (:). If you would like to make a booking with code VOF, please call us +46 8 517 517 20.Save your booking code for the next time you visit the page by ticking the box “Remember”. Don't tick the box if you're using a public computer to avoid unauthorized access to your booking code.",
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<InfoCircleIcon color="uiTextPlaceholder" height={20} width={20} />
|
||||
</Button>
|
||||
}
|
||||
title={codeVoucher}
|
||||
>
|
||||
<Body color="uiTextHighContrast">{bookingCodeTooltipText}</Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeRemember({ bookingCodeValue, onApplyClick }: CodeRememberProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Checkbox name="bookingCode.remember">
|
||||
<Caption asChild>
|
||||
<span>{intl.formatMessage({ id: "Remember code" })}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{bookingCodeValue ? (
|
||||
<Button
|
||||
size="small"
|
||||
className={styles.hideOnMobile}
|
||||
intent="secondary"
|
||||
type="button"
|
||||
onClick={onApplyClick}
|
||||
>
|
||||
{intl.formatMessage({ id: "Apply" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React, { forwardRef, type InputHTMLAttributes } from "react"
|
||||
import { Input as InputRAC } from "react-aria-components"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./input.module.css"
|
||||
|
||||
const Input = forwardRef<
|
||||
HTMLInputElement,
|
||||
InputHTMLAttributes<HTMLInputElement>
|
||||
>(function InputComponent(props, ref) {
|
||||
return (
|
||||
<Body asChild color="uiTextHighContrast">
|
||||
<InputRAC {...props} ref={ref} className={styles.input} />
|
||||
</Body>
|
||||
)
|
||||
})
|
||||
|
||||
export default Input
|
||||
@@ -0,0 +1,22 @@
|
||||
.input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
height: 24px;
|
||||
outline: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("/_static/icons/close.svg");
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.input:disabled,
|
||||
.input:disabled::placeholder {
|
||||
color: var(--Base-Text-Disabled);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.button {
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
outline: none;
|
||||
padding: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.default {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.active,
|
||||
.button:focus {
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { DeleteIcon } from "@/components/Icons"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import { buttonVariants } from "./variants"
|
||||
|
||||
import type { ClearSearchButtonProps } from "@/types/components/search"
|
||||
|
||||
export default function ClearSearchButton({
|
||||
getItemProps,
|
||||
handleClearSearchHistory,
|
||||
highlightedIndex,
|
||||
index,
|
||||
}: ClearSearchButtonProps) {
|
||||
const intl = useIntl()
|
||||
const classNames = buttonVariants({
|
||||
variant: index === highlightedIndex ? "active" : "default",
|
||||
})
|
||||
|
||||
return (
|
||||
<button
|
||||
{...getItemProps({
|
||||
className: classNames,
|
||||
id: "clear-search",
|
||||
index,
|
||||
item: "clear-search",
|
||||
role: "button",
|
||||
})}
|
||||
onClick={handleClearSearchHistory}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
<DeleteIcon color="burgundy" height={20} width={20} />
|
||||
<Caption color="burgundy" type="bold" asChild>
|
||||
<span>{intl.formatMessage({ id: "Clear searches" })}</span>
|
||||
</Caption>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./button.module.css"
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
variant: {
|
||||
active: styles.active,
|
||||
default: styles.default,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
} as const
|
||||
|
||||
export const buttonVariants = cva(styles.button, config)
|
||||
@@ -0,0 +1,45 @@
|
||||
.dialog {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
left: 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
position: fixed;
|
||||
top: 170px;
|
||||
width: 100%;
|
||||
height: calc(100% - 200px);
|
||||
z-index: 10010;
|
||||
}
|
||||
|
||||
.default {
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.error {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { dialogVariants } from "./variants"
|
||||
|
||||
import type { DialogProps } from "@/types/components/search"
|
||||
|
||||
export default function Dialog({
|
||||
children,
|
||||
className,
|
||||
getMenuProps,
|
||||
variant,
|
||||
}: DialogProps) {
|
||||
const classNames = dialogVariants({ className, variant })
|
||||
return <div {...getMenuProps({ className: classNames })}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./dialog.module.css"
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
variant: {
|
||||
default: styles.default,
|
||||
error: styles.error,
|
||||
search: styles.search,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
} as const
|
||||
|
||||
export const dialogVariants = cva(styles.dialog, config)
|
||||
@@ -0,0 +1,13 @@
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import styles from "./list.module.css"
|
||||
|
||||
export default function Label({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<li className={styles.label}>
|
||||
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
|
||||
{children}
|
||||
</Footnote>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { listItemVariants } from "./variants"
|
||||
|
||||
import type { ListItemProps } from "@/types/components/search"
|
||||
|
||||
export default function ListItem({
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
index,
|
||||
location,
|
||||
}: ListItemProps) {
|
||||
const classNames = listItemVariants({
|
||||
variant: index === highlightedIndex ? "active" : "default",
|
||||
})
|
||||
|
||||
const isCity = location.type === "cities"
|
||||
const isHotelLocation = location.type === "hotels"
|
||||
return (
|
||||
<li
|
||||
{...getItemProps({
|
||||
className: classNames,
|
||||
index,
|
||||
item: location,
|
||||
})}
|
||||
>
|
||||
<Body color="black" textTransform="bold">
|
||||
{location.name}
|
||||
</Body>
|
||||
{isCity && location?.country ? (
|
||||
<Body color="uiTextPlaceholder">{location.country}</Body>
|
||||
) : null}
|
||||
{isHotelLocation && location.relationships.city?.name ? (
|
||||
<Body color="uiTextPlaceholder">
|
||||
{location.relationships.city.name}
|
||||
</Body>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.listItem {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
cursor: pointer;
|
||||
padding: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.default {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./listItem.module.css"
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
variant: {
|
||||
active: styles.active,
|
||||
default: styles.default,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
} as const
|
||||
|
||||
export const listItemVariants = cva(styles.listItem, config)
|
||||
@@ -0,0 +1,32 @@
|
||||
import Label from "./Label"
|
||||
import ListItem from "./ListItem"
|
||||
|
||||
import styles from "./list.module.css"
|
||||
|
||||
import type { ListProps } from "@/types/components/search"
|
||||
|
||||
export default function List({
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
initialIndex = 0,
|
||||
label,
|
||||
locations,
|
||||
}: ListProps) {
|
||||
if (!locations.length) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{label ? <Label>{label}</Label> : null}
|
||||
{locations.map((location, index) => (
|
||||
<ListItem
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
index={initialIndex + index}
|
||||
key={location.id + index}
|
||||
location={location}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 0 var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import ClearSearchButton from "./ClearSearchButton"
|
||||
import Dialog from "./Dialog"
|
||||
import List from "./List"
|
||||
|
||||
import styles from "./searchList.module.css"
|
||||
|
||||
import type { SearchListProps } from "@/types/components/search"
|
||||
|
||||
export default function SearchList({
|
||||
getItemProps,
|
||||
getMenuProps,
|
||||
handleClearSearchHistory,
|
||||
highlightedIndex,
|
||||
isOpen,
|
||||
locations,
|
||||
search,
|
||||
searchHistory,
|
||||
}: SearchListProps) {
|
||||
const intl = useIntl()
|
||||
const [hasMounted, setHasMounted] = useState(false)
|
||||
const {
|
||||
clearErrors,
|
||||
formState: { errors, isSubmitted },
|
||||
} = useFormContext()
|
||||
const searchError = errors["search"]
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
||||
if (searchError) {
|
||||
timeoutID = setTimeout(() => {
|
||||
clearErrors("search")
|
||||
// magic number originates from animation
|
||||
// 5000ms delay + 120ms exectuion
|
||||
}, 5120)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutID) {
|
||||
clearTimeout(timeoutID)
|
||||
}
|
||||
}
|
||||
}, [clearErrors, searchError])
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true)
|
||||
}, [setHasMounted])
|
||||
|
||||
if (!hasMounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (searchError && isSubmitted) {
|
||||
if (typeof searchError.message === "string") {
|
||||
if (!isOpen) {
|
||||
if (searchError.message === "Required") {
|
||||
return (
|
||||
<Dialog
|
||||
className={styles.fadeOut}
|
||||
getMenuProps={getMenuProps}
|
||||
variant="error"
|
||||
>
|
||||
<Caption className={styles.heading} color="red">
|
||||
<ErrorCircleIcon color="red" />
|
||||
{intl.formatMessage({ id: "Enter destination or hotel" })}
|
||||
</Caption>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "A destination or hotel name is needed to be able to search for a hotel room.",
|
||||
})}
|
||||
</Body>
|
||||
</Dialog>
|
||||
)
|
||||
} else if (searchError.type === "custom") {
|
||||
return (
|
||||
<Dialog
|
||||
className={styles.fadeOut}
|
||||
getMenuProps={getMenuProps}
|
||||
variant="error"
|
||||
>
|
||||
<Caption className={styles.heading} color="red">
|
||||
<ErrorCircleIcon color="red" />
|
||||
{intl.formatMessage({ id: "No results" })}
|
||||
</Caption>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "We couldn't find a matching location for your search.",
|
||||
})}
|
||||
</Body>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (locations.length) {
|
||||
const cities = locations.filter((location) => location.type === "cities")
|
||||
const hotels = locations.filter((location) => location.type === "hotels")
|
||||
return (
|
||||
<Dialog getMenuProps={getMenuProps} variant="search">
|
||||
<List
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
label={intl.formatMessage({ id: "Cities" })}
|
||||
locations={cities}
|
||||
/>
|
||||
<List
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
initialIndex={cities.length}
|
||||
label={intl.formatMessage({ id: "Hotels" })}
|
||||
locations={hotels}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (!search && searchHistory?.length) {
|
||||
return (
|
||||
<Dialog getMenuProps={getMenuProps}>
|
||||
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
|
||||
{intl.formatMessage({ id: "Latest searches" })}
|
||||
</Footnote>
|
||||
<List
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
locations={searchHistory}
|
||||
/>
|
||||
<Divider className={styles.divider} color="beige" />
|
||||
<ClearSearchButton
|
||||
getItemProps={getItemProps}
|
||||
handleClearSearchHistory={handleClearSearchHistory}
|
||||
highlightedIndex={highlightedIndex}
|
||||
index={searchHistory.length}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (search) {
|
||||
return (
|
||||
<Dialog getMenuProps={getMenuProps} variant="error">
|
||||
<Body className={styles.text} textTransform="bold">
|
||||
{intl.formatMessage({ id: "No results" })}
|
||||
</Body>
|
||||
<Body className={styles.text} color="uiTextPlaceholder">
|
||||
{intl.formatMessage({
|
||||
id: "We couldn't find a matching location for your search.",
|
||||
})}
|
||||
</Body>
|
||||
{searchHistory ? (
|
||||
<>
|
||||
<Divider className={styles.noResultsDivider} color="beige" />
|
||||
<Footnote
|
||||
className={styles.text}
|
||||
color="uiTextPlaceholder"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({ id: "Latest searches" })}
|
||||
</Footnote>
|
||||
<List
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
locations={searchHistory}
|
||||
/>
|
||||
<Divider className={styles.divider} color="beige" />
|
||||
<ClearSearchButton
|
||||
getItemProps={getItemProps}
|
||||
handleClearSearchHistory={handleClearSearchHistory}
|
||||
highlightedIndex={highlightedIndex}
|
||||
index={searchHistory.length}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fadeOut {
|
||||
animation: fade-out 120ms ease-out 5000ms forwards;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: var(--Spacing-x2) var(--Spacing-x0) var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.noResultsDivider {
|
||||
margin: var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 0 var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client"
|
||||
import Downshift from "downshift"
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FocusEvent,
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useReducer,
|
||||
} from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import isValidJson from "@/utils/isValidJson"
|
||||
|
||||
import Input from "../Input"
|
||||
import { init, localStorageKey, reducer, sessionStorageKey } from "./reducer"
|
||||
import SearchList from "./SearchList"
|
||||
|
||||
import styles from "./search.module.css"
|
||||
|
||||
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
import {
|
||||
ActionType,
|
||||
type SetStorageData,
|
||||
} from "@/types/components/form/bookingwidget"
|
||||
import type { SearchHistoryItem, SearchProps } from "@/types/components/search"
|
||||
import type { Location, Locations } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
const name = "search"
|
||||
|
||||
export default function Search({ locations, handlePressEnter }: SearchProps) {
|
||||
const { register, setValue, unregister, getValues } =
|
||||
useFormContext<BookingWidgetSchema>()
|
||||
const intl = useIntl()
|
||||
const value = useWatch({ name })
|
||||
const locationString = getValues("location")
|
||||
const location =
|
||||
locationString && isValidJson(decodeURIComponent(locationString))
|
||||
? JSON.parse(decodeURIComponent(locationString))
|
||||
: null
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer,
|
||||
{ defaultLocations: locations, initialValue: value },
|
||||
init
|
||||
)
|
||||
const handleMatchLocations = useCallback(
|
||||
function (searchValue: string) {
|
||||
return locations.filter((location) => {
|
||||
return location.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
})
|
||||
},
|
||||
[locations]
|
||||
)
|
||||
|
||||
function handleClearSearchHistory() {
|
||||
localStorage.removeItem(localStorageKey)
|
||||
dispatch({ type: ActionType.CLEAR_HISTORY_LOCATIONS })
|
||||
}
|
||||
|
||||
function dispatchInputValue(inputValue: string) {
|
||||
if (inputValue) {
|
||||
dispatch({
|
||||
payload: { search: inputValue },
|
||||
type: ActionType.SEARCH_LOCATIONS,
|
||||
})
|
||||
} else {
|
||||
dispatch({ type: ActionType.CLEAR_SEARCH_LOCATIONS })
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnChange(
|
||||
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
const newValue = evt.currentTarget.value
|
||||
setValue(name, newValue)
|
||||
dispatchInputValue(value)
|
||||
}
|
||||
|
||||
function handleOnFocus(evt: FocusEvent<HTMLInputElement>) {
|
||||
const searchValue = evt.currentTarget.value
|
||||
if (searchValue) {
|
||||
const matchingLocations = handleMatchLocations(searchValue)
|
||||
if (matchingLocations.length) {
|
||||
dispatch({
|
||||
payload: { search: searchValue },
|
||||
type: ActionType.SEARCH_LOCATIONS,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnSelect(selectedItem: Location | null) {
|
||||
if (selectedItem) {
|
||||
const stringified = JSON.stringify(selectedItem)
|
||||
setValue("location", encodeURIComponent(stringified))
|
||||
sessionStorage.setItem(sessionStorageKey, stringified)
|
||||
setValue(name, selectedItem.name)
|
||||
const newHistoryItem: SearchHistoryItem = {
|
||||
type: selectedItem.type,
|
||||
id: selectedItem.id,
|
||||
}
|
||||
const oldSearchHistoryWithoutTheNew =
|
||||
state.searchHistory?.filter(
|
||||
(h) => h.type !== newHistoryItem.type || h.id !== newHistoryItem.id
|
||||
) ?? []
|
||||
const oldHistoryItems = oldSearchHistoryWithoutTheNew.map((h) => ({
|
||||
id: h.id,
|
||||
type: h.type,
|
||||
}))
|
||||
|
||||
const searchHistory = [newHistoryItem, ...oldHistoryItems]
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(searchHistory))
|
||||
|
||||
const enhancedSearchHistory: Locations = [
|
||||
...getEnhancedSearchHistory([newHistoryItem], locations),
|
||||
...oldSearchHistoryWithoutTheNew,
|
||||
]
|
||||
dispatch({
|
||||
payload: {
|
||||
location: selectedItem,
|
||||
searchHistory: enhancedSearchHistory,
|
||||
},
|
||||
type: ActionType.SELECT_ITEM,
|
||||
})
|
||||
} else {
|
||||
sessionStorage.removeItem(sessionStorageKey)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const searchData = sessionStorage.getItem(sessionStorageKey)
|
||||
const searchHistory = localStorage.getItem(localStorageKey)
|
||||
const payload: SetStorageData["payload"] = {}
|
||||
if (searchData) {
|
||||
payload.searchData = JSON.parse(searchData)
|
||||
}
|
||||
if (searchHistory) {
|
||||
payload.searchHistory = getEnhancedSearchHistory(
|
||||
JSON.parse(searchHistory),
|
||||
locations
|
||||
)
|
||||
}
|
||||
dispatch({
|
||||
payload,
|
||||
type: ActionType.SET_STORAGE_DATA,
|
||||
})
|
||||
}, [dispatch, locations])
|
||||
|
||||
const stayType = state.searchData?.type === "cities" ? "city" : "hotel"
|
||||
const stayValue =
|
||||
(value === state.searchData?.name &&
|
||||
((state.searchData?.type === "cities" && state.searchData?.name) ||
|
||||
state.searchData?.id)) ||
|
||||
""
|
||||
|
||||
useEffect(() => {
|
||||
if (stayType === "city") {
|
||||
unregister("hotel")
|
||||
setValue(stayType, stayValue, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
} else {
|
||||
unregister("city")
|
||||
setValue(stayType, Number(stayValue), {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
}, [stayType, stayValue, unregister, setValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (location) {
|
||||
sessionStorage.setItem(sessionStorageKey, JSON.stringify(location))
|
||||
}
|
||||
}, [location])
|
||||
|
||||
function getLocationLabel(): string {
|
||||
const fallbackLabel = intl.formatMessage({ id: "Where to?" })
|
||||
if (location?.type === "hotels") {
|
||||
return location?.relationships?.city?.name || fallbackLabel
|
||||
}
|
||||
if (state.searchData?.type === "hotels") {
|
||||
return state.searchData?.relationships?.city?.name || fallbackLabel
|
||||
}
|
||||
return fallbackLabel
|
||||
}
|
||||
|
||||
return (
|
||||
<Downshift
|
||||
initialSelectedItem={state.searchData}
|
||||
inputValue={value}
|
||||
itemToString={(value) => (value ? value.name : "")}
|
||||
onSelect={handleOnSelect}
|
||||
onInputValueChange={(inputValue) => dispatchInputValue(inputValue)}
|
||||
defaultHighlightedIndex={0}
|
||||
>
|
||||
{({
|
||||
getInputProps,
|
||||
getItemProps,
|
||||
getLabelProps,
|
||||
getMenuProps,
|
||||
getRootProps,
|
||||
highlightedIndex,
|
||||
isOpen,
|
||||
openMenu,
|
||||
}) => (
|
||||
<div className={styles.container}>
|
||||
{value ? (
|
||||
// Adding hidden input to define hotel or city based on destination selection for basic form submit.
|
||||
<input type="hidden" {...register(stayType)} />
|
||||
) : null}
|
||||
<label {...getLabelProps({ htmlFor: name })} className={styles.label}>
|
||||
<Caption
|
||||
type="bold"
|
||||
color={isOpen ? "uiTextActive" : "red"}
|
||||
asChild
|
||||
>
|
||||
<span>{getLocationLabel()}</span>
|
||||
</Caption>
|
||||
</label>
|
||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||
<label className={styles.searchInput}>
|
||||
<Input
|
||||
{...getInputProps({
|
||||
id: name,
|
||||
onFocus(evt) {
|
||||
handleOnFocus(evt)
|
||||
openMenu()
|
||||
},
|
||||
placeholder: intl.formatMessage({
|
||||
id: "Hotels & Destinations",
|
||||
}),
|
||||
...register(name, {
|
||||
onChange: handleOnChange,
|
||||
}),
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === "Enter" && !isOpen) {
|
||||
handlePressEnter()
|
||||
}
|
||||
},
|
||||
type: "search",
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<SearchList
|
||||
getItemProps={getItemProps}
|
||||
getMenuProps={getMenuProps}
|
||||
handleClearSearchHistory={handleClearSearchHistory}
|
||||
highlightedIndex={highlightedIndex}
|
||||
isOpen={isOpen}
|
||||
locations={state.locations}
|
||||
search={state.search}
|
||||
searchHistory={state.searchHistory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Downshift>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchSkeleton() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.label}>
|
||||
<Caption type="bold" color="red" asChild>
|
||||
<span>{intl.formatMessage({ id: "Where to?" })}</span>
|
||||
</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonShimmer width={"100%"} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a stored search history and returns the same history, but with the same
|
||||
* data and the same format as the complete location objects
|
||||
*/
|
||||
function getEnhancedSearchHistory(
|
||||
searchHistory: SearchHistoryItem[],
|
||||
locations: Locations
|
||||
): Locations {
|
||||
return searchHistory
|
||||
.map((historyItem) =>
|
||||
locations.find(
|
||||
(location) =>
|
||||
location.type === historyItem.type && location.id === historyItem.id
|
||||
)
|
||||
)
|
||||
.filter((r) => !!r) as Locations
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
type Action,
|
||||
ActionType,
|
||||
type InitState,
|
||||
type State,
|
||||
} from "@/types/components/form/bookingwidget"
|
||||
import type { Locations } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export const localStorageKey = "searchHistory"
|
||||
export const sessionStorageKey = "searchData"
|
||||
|
||||
export function init(initState: InitState): State {
|
||||
const locations = []
|
||||
if (initState.initialValue) {
|
||||
const location = initState.defaultLocations.find(
|
||||
(loc) => loc.name.toLowerCase() === initState.initialValue!.toLowerCase()
|
||||
)
|
||||
if (location) {
|
||||
locations.push(location)
|
||||
}
|
||||
}
|
||||
return {
|
||||
defaultLocations: initState.defaultLocations,
|
||||
locations,
|
||||
search: locations.length ? locations[0].name : "",
|
||||
searchData: locations.length ? locations[0] : undefined,
|
||||
searchHistory: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function reducer(state: State, action: Action) {
|
||||
const type = action.type
|
||||
switch (type) {
|
||||
case ActionType.CLEAR_HISTORY_LOCATIONS: {
|
||||
return {
|
||||
...state,
|
||||
locations: [],
|
||||
search: "",
|
||||
searchHistory: null,
|
||||
}
|
||||
}
|
||||
case ActionType.CLEAR_SEARCH_LOCATIONS:
|
||||
return {
|
||||
...state,
|
||||
locations: [],
|
||||
search: "",
|
||||
}
|
||||
case ActionType.SEARCH_LOCATIONS: {
|
||||
const matchesMap = new Map()
|
||||
const search = action.payload.search.toLowerCase()
|
||||
state.defaultLocations.forEach((location) => {
|
||||
const locationName = location.name.toLowerCase()
|
||||
const keyWords = location.keyWords?.flatMap((l) =>
|
||||
l.toLowerCase().split(" ")
|
||||
)
|
||||
if (locationName.includes(search.trim())) {
|
||||
matchesMap.set(location.name, location)
|
||||
}
|
||||
if (keyWords?.find((keyWord) => keyWord.startsWith(search.trim()))) {
|
||||
matchesMap.set(location.name, location)
|
||||
}
|
||||
})
|
||||
|
||||
const matches: Locations = []
|
||||
matchesMap.forEach((value) => {
|
||||
matches.push(value)
|
||||
})
|
||||
|
||||
return {
|
||||
...state,
|
||||
locations: matches,
|
||||
search: action.payload.search,
|
||||
}
|
||||
}
|
||||
case ActionType.SELECT_ITEM: {
|
||||
return {
|
||||
...state,
|
||||
searchData: action.payload.location,
|
||||
searchHistory: action.payload.searchHistory,
|
||||
}
|
||||
}
|
||||
case ActionType.SET_STORAGE_DATA: {
|
||||
return {
|
||||
...state,
|
||||
searchData: action.payload.searchData
|
||||
? action.payload.searchData
|
||||
: state.searchData,
|
||||
searchHistory: action.payload.searchHistory
|
||||
? action.payload.searchHistory
|
||||
: state.searchHistory,
|
||||
}
|
||||
}
|
||||
default:
|
||||
const unhandledActionType: never = type
|
||||
console.info(`Unhandled type: ${unhandledActionType}`)
|
||||
return state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.container {
|
||||
border-color: transparent;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
position: relative;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.container:hover,
|
||||
.container:has(input:active, input:focus, input:focus-within) {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
}
|
||||
|
||||
.container:has(input:active, input:focus, input:focus-within) {
|
||||
border-color: 1px solid var(--UI-Input-Controls-Border-Focus);
|
||||
}
|
||||
|
||||
.label:has(
|
||||
~ .inputContainer input:active,
|
||||
~ .inputContainer input:focus,
|
||||
~ .inputContainer input:focus-within
|
||||
)
|
||||
p {
|
||||
color: var(--UI-Text-Active);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x-one-and-half) var(--Spacing-x-half);
|
||||
align-items: center;
|
||||
display: grid;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { Tooltip } from "@/components/TempDesignSystem/Tooltip"
|
||||
|
||||
import BookingCode from "../BookingCode"
|
||||
|
||||
import styles from "./voucher.module.css"
|
||||
|
||||
export default function Voucher() {
|
||||
const intl = useIntl()
|
||||
|
||||
const bonus = intl.formatMessage({ id: "Use Bonus Cheque" })
|
||||
const reward = intl.formatMessage({ id: "Book Reward Night" })
|
||||
|
||||
// ToDo: Remove this when all three options are enabled
|
||||
const disabledBookingOptionsHeader = intl.formatMessage({
|
||||
id: "We're sorry",
|
||||
})
|
||||
const disabledBookingOptionsText = intl.formatMessage({
|
||||
id: "Codes, cheques and reward nights aren't available on the new website yet.",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={styles.optionsContainer}>
|
||||
<div className={styles.vouchers}>
|
||||
<BookingCode />
|
||||
</div>
|
||||
<div className={styles.options}>
|
||||
<div className={styles.option}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{bonus}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VoucherSkeleton() {
|
||||
const intl = useIntl()
|
||||
|
||||
const vouchers = intl.formatMessage({ id: "Code / Voucher" })
|
||||
const bonus = intl.formatMessage({ id: "Use Bonus Cheque" })
|
||||
const reward = intl.formatMessage({ id: "Book Reward Night" })
|
||||
|
||||
const form = useForm()
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<div className={styles.optionsContainer}>
|
||||
<div className={styles.vouchers}>
|
||||
<label>
|
||||
<Caption type="bold" color="red" asChild>
|
||||
<span>{vouchers}</span>
|
||||
</Caption>
|
||||
</label>
|
||||
<SkeletonShimmer width={"100%"} />
|
||||
</div>
|
||||
<div className={styles.options}>
|
||||
<div className={styles.option}>
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{bonus}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-top: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
}
|
||||
.vouchers {
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
}
|
||||
|
||||
.optionsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.vouchers {
|
||||
margin-bottom: var(--Spacing-x5);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.options {
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
.option {
|
||||
margin-top: 0;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
.optionsContainer {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
column-gap: var(--Spacing-x2);
|
||||
}
|
||||
.vouchers:hover,
|
||||
.vouchers:focus-within,
|
||||
.vouchers:has([data-focused="true"], [data-pressed="true"]) {
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.vouchers {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.vouchers {
|
||||
display: block;
|
||||
max-width: 200px;
|
||||
}
|
||||
.options {
|
||||
flex-direction: column;
|
||||
max-width: 190px;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
.option:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
.vouchersHeader {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.showOnTablet {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.voucherContainer {
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x4);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1367px) {
|
||||
.inputContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.vouchers,
|
||||
.when,
|
||||
.where {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.vouchers,
|
||||
.when {
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.button {
|
||||
align-self: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rooms {
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.inputContainer {
|
||||
display: flex;
|
||||
flex: 2;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
.voucherContainer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when,
|
||||
.where {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputContainer input[type="text"] {
|
||||
border: none;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when {
|
||||
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([data-focused="true"], [data-pressed="true"]) {
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
.where {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.button {
|
||||
justify-content: center;
|
||||
width: 118px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) and (max-width: 1366px) {
|
||||
.input {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.inputContainer {
|
||||
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x2)
|
||||
var(--Layout-Tablet-Margin-Margin-min);
|
||||
flex-basis: 80%;
|
||||
}
|
||||
.buttonContainer {
|
||||
padding-right: var(--Layout-Tablet-Margin-Margin-min);
|
||||
}
|
||||
.input .buttonContainer .button {
|
||||
padding: var(--Spacing-x1);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.buttonText {
|
||||
display: none;
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.voucherRow {
|
||||
display: flex;
|
||||
background: var(--Base-Surface-Primary-light-Hover);
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding: var(--Spacing-x2) var(--Layout-Tablet-Margin-Margin-min);
|
||||
}
|
||||
|
||||
.showOnTablet {
|
||||
display: flex;
|
||||
}
|
||||
.hideOnTablet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
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 SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Search, { SearchSkeleton } from "./Search"
|
||||
import Voucher, { VoucherSkeleton } from "./Voucher"
|
||||
|
||||
import styles from "./formContent.module.css"
|
||||
|
||||
import type { BookingWidgetFormContentProps } from "@/types/components/form/bookingwidget"
|
||||
|
||||
export default function FormContent({
|
||||
locations,
|
||||
formId,
|
||||
onSubmit,
|
||||
isSearching,
|
||||
}: BookingWidgetFormContentProps) {
|
||||
const intl = useIntl()
|
||||
const selectedDate = useWatch({ name: "date" })
|
||||
|
||||
const roomsLabel = intl.formatMessage({ id: "Rooms & Guests" })
|
||||
|
||||
const nights = dt(selectedDate.toDate).diff(dt(selectedDate.fromDate), "days")
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.input}>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.where}>
|
||||
<Search locations={locations} handlePressEnter={onSubmit} />
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Caption color="red" type="bold">
|
||||
{nights > 0
|
||||
? intl.formatMessage(
|
||||
{
|
||||
id: "{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights: nights }
|
||||
)
|
||||
: intl.formatMessage({
|
||||
id: "Check in",
|
||||
})}
|
||||
</Caption>
|
||||
<DatePicker />
|
||||
</div>
|
||||
<div className={styles.rooms}>
|
||||
<label>
|
||||
<Caption color="red" type="bold" asChild>
|
||||
<span>{roomsLabel}</span>
|
||||
</Caption>
|
||||
</label>
|
||||
<GuestsRoomsPickerForm />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${styles.buttonContainer} ${styles.showOnTablet}`}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
form={formId}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
<span className={styles.icon}>
|
||||
<SearchIcon color="white" width={28} height={28} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`${styles.voucherContainer} ${styles.voucherRow}`}>
|
||||
<Voucher />
|
||||
</div>
|
||||
<div className={`${styles.buttonContainer} ${styles.hideOnTablet}`}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
form={formId}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
type="submit"
|
||||
disabled={isSearching}
|
||||
>
|
||||
<Caption
|
||||
color="white"
|
||||
type="bold"
|
||||
className={styles.buttonText}
|
||||
asChild
|
||||
>
|
||||
<span>{intl.formatMessage({ id: "Search" })}</span>
|
||||
</Caption>
|
||||
<span className={styles.icon}>
|
||||
<SearchIcon color="white" width={28} height={28} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookingWidgetFormContentSkeleton() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.input}>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.where}>
|
||||
<SearchSkeleton />
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Caption color="red" type="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||
{ totalNights: 0 }
|
||||
)}
|
||||
</Caption>
|
||||
<SkeletonShimmer width={"100%"} />
|
||||
</div>
|
||||
<div className={styles.rooms}>
|
||||
<Caption color="red" type="bold" asChild>
|
||||
<span>{intl.formatMessage({ id: "Rooms & Guests" })}</span>
|
||||
</Caption>
|
||||
<SkeletonShimmer width={"100%"} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.voucherContainer}>
|
||||
<VoucherSkeleton />
|
||||
</div>
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
type="submit"
|
||||
disabled
|
||||
>
|
||||
<Caption
|
||||
color="white"
|
||||
type="bold"
|
||||
className={styles.buttonText}
|
||||
asChild
|
||||
>
|
||||
<span>{intl.formatMessage({ id: "Search" })}</span>
|
||||
</Caption>
|
||||
<span className={styles.icon}>
|
||||
<SearchIcon color="white" width={28} height={28} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.section {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
margin: 0 auto;
|
||||
width: 100dvw;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.section {
|
||||
max-width: var(--max-width-page);
|
||||
}
|
||||
|
||||
.form {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.default {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.default {
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2)
|
||||
var(--Spacing-x-one-and-half) var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.full {
|
||||
padding: var(--Spacing-x1) 0;
|
||||
}
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: var(--max-width-page);
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
97
apps/scandic-web/components/Forms/BookingWidget/index.tsx
Normal file
97
apps/scandic-web/components/Forms/BookingWidget/index.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
import { Form as FormRAC } from "react-aria-components"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { selectHotel, selectRate } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { convertObjToSearchParams } from "@/utils/url"
|
||||
|
||||
import FormContent, { BookingWidgetFormContentSkeleton } from "./FormContent"
|
||||
import { bookingWidgetVariants } from "./variants"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
const formId = "booking-widget"
|
||||
|
||||
export default function Form({
|
||||
locations,
|
||||
type,
|
||||
onClose,
|
||||
}: BookingWidgetFormProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const classNames = bookingWidgetVariants({
|
||||
type,
|
||||
})
|
||||
|
||||
const { handleSubmit, register, setValue } =
|
||||
useFormContext<BookingWidgetSchema>()
|
||||
|
||||
function onSubmit(data: BookingWidgetSchema) {
|
||||
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
|
||||
const bookingFlowPage =
|
||||
locationData.type == "cities" ? selectHotel(lang) : selectRate(lang)
|
||||
const bookingWidgetParams = convertObjToSearchParams({
|
||||
rooms: data.rooms,
|
||||
...data.date,
|
||||
...(locationData.type == "cities"
|
||||
? { city: locationData.name }
|
||||
: { hotel: locationData.operaId || "" }),
|
||||
...(data.bookingCode?.value
|
||||
? { bookingCode: data.bookingCode.value }
|
||||
: {}),
|
||||
})
|
||||
|
||||
onClose()
|
||||
startTransition(() => {
|
||||
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
|
||||
})
|
||||
if (!data.bookingCode?.value) {
|
||||
setValue("bookingCode.remember", false)
|
||||
localStorage.removeItem("bookingCode")
|
||||
} else if (data.bookingCode?.remember) {
|
||||
localStorage.setItem("bookingCode", JSON.stringify(data.bookingCode))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={classNames}>
|
||||
<FormRAC
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={styles.form}
|
||||
id={formId}
|
||||
>
|
||||
<input {...register("location")} type="hidden" />
|
||||
<FormContent
|
||||
locations={locations}
|
||||
formId={formId}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
isSearching={isPending}
|
||||
/>
|
||||
</FormRAC>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookingWidgetFormSkeleton() {
|
||||
const classNames = bookingWidgetVariants({
|
||||
type: "full",
|
||||
})
|
||||
|
||||
return (
|
||||
<section className={classNames}>
|
||||
<form className={styles.form}>
|
||||
<BookingWidgetFormContentSkeleton />
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
121
apps/scandic-web/components/Forms/BookingWidget/schema.ts
Normal file
121
apps/scandic-web/components/Forms/BookingWidget/schema.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export const guestRoomSchema = z
|
||||
.object({
|
||||
adults: z.number().default(1),
|
||||
childrenInRoom: z
|
||||
.array(
|
||||
z.object({
|
||||
age: z.number().min(0, "Age is required"),
|
||||
bed: z.number().min(0, "Bed choice is required"),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const childrenInAdultsBed = value.childrenInRoom.filter(
|
||||
(c) => c.bed === ChildBedMapEnum.IN_ADULTS_BED
|
||||
)
|
||||
if (value.adults < childrenInAdultsBed.length) {
|
||||
const lastAdultBedIndex = value.childrenInRoom
|
||||
.map((c) => c.bed)
|
||||
.lastIndexOf(ChildBedMapEnum.IN_ADULTS_BED)
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"You cannot have more children in adults bed than adults in the room",
|
||||
path: ["childrenInRoom", lastAdultBedIndex],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const guestRoomsSchema = z.array(guestRoomSchema)
|
||||
|
||||
export const bookingCodeSchema = z
|
||||
.object({
|
||||
value: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => {
|
||||
if (
|
||||
!value ||
|
||||
/(^D\d*$)|(^DSH[0-9a-z]*$)|(^L\d*$)|(^LH[0-9a-z]*$)|(^B[a-z]{3}\d{6})|(^VO[0-9a-z]*$)|^[0-9a-z]*$/i.test(
|
||||
value
|
||||
)
|
||||
) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ message: "Invalid booking code" }
|
||||
)
|
||||
.default(""),
|
||||
remember: z.boolean().default(false),
|
||||
flag: z.boolean().default(false),
|
||||
})
|
||||
.optional()
|
||||
|
||||
export const bookingWidgetSchema = z
|
||||
.object({
|
||||
bookingCode: bookingCodeSchema,
|
||||
date: z.object({
|
||||
// Update this as required once started working with Date picker in Nights component
|
||||
fromDate: z.string(),
|
||||
toDate: z.string(),
|
||||
}),
|
||||
location: z.string().refine(
|
||||
(value) => {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const parsedValue: Location = JSON.parse(decodeURIComponent(value))
|
||||
switch (parsedValue?.type) {
|
||||
case "cities":
|
||||
case "hotels":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ message: "Required" }
|
||||
),
|
||||
redemption: z.boolean().default(false),
|
||||
rooms: guestRoomsSchema,
|
||||
search: z.string({ coerce: true }).min(1, "Required"),
|
||||
voucher: z.boolean().default(false),
|
||||
hotel: z.number().optional(),
|
||||
city: z.string().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.hotel && !value.city) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Destination required",
|
||||
path: ["search"],
|
||||
})
|
||||
}
|
||||
if (
|
||||
value.rooms.length > 1 &&
|
||||
value.bookingCode?.value.toLowerCase().startsWith("vo")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Multiroom with voucher error",
|
||||
path: ["bookingCode.value"],
|
||||
})
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Multiroom with voucher error",
|
||||
path: ["rooms"],
|
||||
})
|
||||
}
|
||||
})
|
||||
15
apps/scandic-web/components/Forms/BookingWidget/variants.ts
Normal file
15
apps/scandic-web/components/Forms/BookingWidget/variants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
export const bookingWidgetVariants = cva(styles.section, {
|
||||
variants: {
|
||||
type: {
|
||||
default: styles.default,
|
||||
full: styles.full,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
type: "full",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
.password,
|
||||
.user {
|
||||
align-self: flex-start;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
container-name: addressContainer;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: minmax(100px, 164px) 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container addressContainer (max-width: 350px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
// import { useFormStatus } from "react-dom"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { languageSelect } from "@/constants/languages"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
import DateSelect from "@/components/TempDesignSystem/Form/Date"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import NewPassword from "@/components/TempDesignSystem/Form/NewPassword"
|
||||
import Phone from "@/components/TempDesignSystem/Form/Phone"
|
||||
import Select from "@/components/TempDesignSystem/Form/Select"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./formContent.module.css"
|
||||
|
||||
export default function FormContent() {
|
||||
const intl = useIntl()
|
||||
// const { pending } = useFormStatus()
|
||||
|
||||
const city = intl.formatMessage({ id: "City" })
|
||||
const country = intl.formatMessage({ id: "Country" })
|
||||
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 currentPassword = intl.formatMessage({ id: "Current password" })
|
||||
const retypeNewPassword = intl.formatMessage({ id: "Retype new password" })
|
||||
const zipCode = intl.formatMessage({ id: "Zip code" })
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={styles.user}>
|
||||
<header>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "User information" })}
|
||||
</Body>
|
||||
</header>
|
||||
<DateSelect name="dateOfBirth" registerOptions={{ required: true }} />
|
||||
<Input
|
||||
data-hj-suppress
|
||||
label={`${street} 1`}
|
||||
name="address.streetAddress"
|
||||
/>
|
||||
<Input data-hj-suppress label={city} name="address.city" />
|
||||
<div className={styles.container}>
|
||||
<Input
|
||||
data-hj-suppress
|
||||
label={zipCode}
|
||||
name="address.zipCode"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<CountrySelect
|
||||
label={country}
|
||||
name="address.countryCode"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label={email}
|
||||
name="email"
|
||||
registerOptions={{ required: true }}
|
||||
type="email"
|
||||
data-hj-suppress
|
||||
/>
|
||||
<Phone label={phoneNumber} name="phoneNumber" data-hj-suppress />
|
||||
<Select
|
||||
items={languageSelect}
|
||||
label={intl.formatMessage({ id: "Language" })}
|
||||
name="language"
|
||||
/>
|
||||
</section>
|
||||
<Divider className={styles.divider} color="subtle" />
|
||||
<section className={styles.password}>
|
||||
<header>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Password" })}
|
||||
</Body>
|
||||
</header>
|
||||
<Input
|
||||
data-hj-suppress
|
||||
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 data-hj-suppress visibilityToggleable={false} />
|
||||
<Input
|
||||
data-hj-suppress
|
||||
label={retypeNewPassword}
|
||||
name="retypeNewPassword"
|
||||
type="password"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
grid-template-areas:
|
||||
"title"
|
||||
"form"
|
||||
"buttons";
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x5);
|
||||
grid-area: form;
|
||||
}
|
||||
|
||||
.btnContainer {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: var(--Spacing-x1);
|
||||
grid-area: buttons;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
grid-template-areas:
|
||||
"title buttons"
|
||||
"form form";
|
||||
}
|
||||
|
||||
.form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.btnContainer {
|
||||
align-self: center;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x2);
|
||||
justify-self: flex-end;
|
||||
}
|
||||
}
|
||||
178
apps/scandic-web/components/Forms/Edit/Profile/index.tsx
Normal file
178
apps/scandic-web/components/Forms/Edit/Profile/index.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { usePhoneInput } from "react-international-phone"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { type Lang, langToApiLang } from "@/constants/languages"
|
||||
import { logout } from "@/constants/routes/handleAuth"
|
||||
import { profile } from "@/constants/routes/myPages"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import { editProfile } from "@/actions/editProfile"
|
||||
import Dialog from "@/components/Dialog"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
|
||||
import FormContent from "./FormContent"
|
||||
import { type EditProfileSchema, editProfileSchema } from "./schema"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
import {
|
||||
type EditFormProps,
|
||||
Status,
|
||||
} from "@/types/components/myPages/myProfile/edit"
|
||||
|
||||
const formId = "edit-profile"
|
||||
|
||||
export default function Form({ user }: EditFormProps) {
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const lang = params.lang as Lang
|
||||
const utils = trpc.useUtils()
|
||||
/**
|
||||
* RHF isValid defaults to false and never
|
||||
* changes when JS is disabled, therefore
|
||||
* we need to keep it insync ourselves
|
||||
*/
|
||||
const [isValid, setIsValid] = useState(true)
|
||||
|
||||
const { inputValue: phoneInput } = usePhoneInput({
|
||||
defaultCountry:
|
||||
user.phoneNumber && isValidPhoneNumber(user.phoneNumber)
|
||||
? parsePhoneNumber(user.phoneNumber).country?.toLowerCase()
|
||||
: "se",
|
||||
forceDialCode: true,
|
||||
value: user.phoneNumber,
|
||||
})
|
||||
|
||||
const methods = useForm<EditProfileSchema>({
|
||||
defaultValues: {
|
||||
address: {
|
||||
city: user.address?.city ?? "",
|
||||
countryCode: user.address?.countryCode ?? "",
|
||||
streetAddress: user.address?.streetAddress ?? "",
|
||||
zipCode: user.address?.zipCode ?? "",
|
||||
},
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
email: user.email,
|
||||
language: user.language ?? langToApiLang[lang],
|
||||
phoneNumber: phoneInput,
|
||||
password: "",
|
||||
newPassword: "",
|
||||
retypeNewPassword: "",
|
||||
},
|
||||
mode: "all",
|
||||
criteriaMode: "all",
|
||||
resolver: zodResolver(editProfileSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
const trigger = methods.trigger
|
||||
|
||||
async function handleSubmit(data: EditProfileSchema) {
|
||||
const isPasswordChanged = !!data.newPassword
|
||||
const response = await editProfile(data)
|
||||
switch (response.status) {
|
||||
case Status.error:
|
||||
if (response.issues?.length) {
|
||||
response.issues.forEach((issue) => {
|
||||
console.error(issue)
|
||||
})
|
||||
}
|
||||
toast.error(response.message)
|
||||
break
|
||||
case Status.success:
|
||||
toast.success(response.message)
|
||||
utils.user.get.invalidate()
|
||||
if (isPasswordChanged) {
|
||||
// Kept logout out of Next router forcing browser to navigate on logout url
|
||||
window.location.href = logout[lang]
|
||||
} else {
|
||||
const myStayReturnRoute = localStorage.getItem("myStayReturnRoute")
|
||||
if (myStayReturnRoute) {
|
||||
const returnRoute = JSON.parse(myStayReturnRoute)
|
||||
localStorage.removeItem("myStayReturnRoute")
|
||||
router.push(returnRoute.path)
|
||||
} else {
|
||||
router.push(profile[lang])
|
||||
}
|
||||
router.refresh() // Can be removed on NextJs 15
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsValid(methods.formState.isValid)
|
||||
}, [setIsValid, methods.formState.isValid])
|
||||
|
||||
useEffect(() => {
|
||||
trigger()
|
||||
}, [trigger])
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.title}>
|
||||
<Title as="h4" color="red" level="h1" textTransform="capitalize">
|
||||
{intl.formatMessage({ id: "Welcome" })}
|
||||
</Title>
|
||||
<Title
|
||||
data-hj-suppress
|
||||
as="h4"
|
||||
color="burgundy"
|
||||
level="h2"
|
||||
textTransform="capitalize"
|
||||
>
|
||||
{user.name}
|
||||
</Title>
|
||||
</div>
|
||||
<div className={styles.btnContainer}>
|
||||
<Dialog
|
||||
bodyText={intl.formatMessage({
|
||||
id: "Any changes you've made will be lost.",
|
||||
})}
|
||||
cancelButtonText={intl.formatMessage({ id: "Go back to edit" })}
|
||||
proceedHref={profile[lang]}
|
||||
proceedText={intl.formatMessage({ id: "Yes, discard changes" })}
|
||||
titleText={intl.formatMessage({ id: "Discard unsaved changes?" })}
|
||||
trigger={
|
||||
<Button intent="secondary" size="small" theme="base">
|
||||
{intl.formatMessage({ id: "Discard changes" })}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
disabled={!isValid || methods.formState.isSubmitting}
|
||||
form={formId}
|
||||
intent="primary"
|
||||
size="small"
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
{intl.formatMessage({ id: "Save" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
/**
|
||||
* Ignoring since ts doesn't recognize that tRPC
|
||||
* parses FormData before reaching the route
|
||||
* @ts-ignore */
|
||||
action={editProfile}
|
||||
className={styles.form}
|
||||
id={formId}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<FormContent />
|
||||
</FormProvider>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
75
apps/scandic-web/components/Forms/Edit/Profile/schema.ts
Normal file
75
apps/scandic-web/components/Forms/Edit/Profile/schema.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { passwordValidator } from "@/utils/zod/passwordValidator"
|
||||
import { phoneValidator } from "@/utils/zod/phoneValidator"
|
||||
|
||||
const countryRequiredMsg = "Country is required"
|
||||
export const editProfileSchema = z
|
||||
.object({
|
||||
address: z.object({
|
||||
city: z.string().optional(),
|
||||
countryCode: z
|
||||
.string({
|
||||
required_error: countryRequiredMsg,
|
||||
invalid_type_error: countryRequiredMsg,
|
||||
})
|
||||
.min(1, countryRequiredMsg),
|
||||
streetAddress: z.string().optional(),
|
||||
zipCode: z.string().min(1, "Zip code is required"),
|
||||
}),
|
||||
dateOfBirth: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
language: z.string(),
|
||||
phoneNumber: phoneValidator(
|
||||
"Phone is required",
|
||||
"Please enter a valid phone number"
|
||||
),
|
||||
|
||||
password: z.string().optional(),
|
||||
newPassword: z.literal("").optional().or(passwordValidator()),
|
||||
retypeNewPassword: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.password) {
|
||||
if (!data.newPassword) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "New password is required",
|
||||
path: ["newPassword"],
|
||||
})
|
||||
}
|
||||
if (!data.retypeNewPassword) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Retype new password is required",
|
||||
path: ["retypeNewPassword"],
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (data.newPassword || data.retypeNewPassword) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Current password is required",
|
||||
path: ["password"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (data.newPassword && !data.retypeNewPassword) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Retype new password is required",
|
||||
path: ["retypeNewPassword"],
|
||||
})
|
||||
}
|
||||
|
||||
if (data.retypeNewPassword !== data.newPassword) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Retype new password does not match new password",
|
||||
path: ["retypeNewPassword"],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type EditProfileSchema = z.infer<typeof editProfileSchema>
|
||||
53
apps/scandic-web/components/Forms/Signup/form.module.css
Normal file
53
apps/scandic-web/components/Forms/Signup/form.module.css
Normal file
@@ -0,0 +1,53 @@
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x5);
|
||||
grid-area: form;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.formWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.userInfo,
|
||||
.password,
|
||||
.terms {
|
||||
align-self: flex-start;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.nameInputs {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.dateField {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.formWrapper {
|
||||
gap: var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.nameInputs {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.signUpButton {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
229
apps/scandic-web/components/Forms/Signup/index.tsx
Normal file
229
apps/scandic-web/components/Forms/Signup/index.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
membershipTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
import DateSelect from "@/components/TempDesignSystem/Form/Date"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import NewPassword from "@/components/TempDesignSystem/Form/NewPassword"
|
||||
import Phone from "@/components/TempDesignSystem/Form/Phone"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { type SignUpSchema, signUpSchema } from "./schema"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
import type { SignUpFormProps } from "@/types/components/form/signupForm"
|
||||
|
||||
export default function SignupForm({ title }: SignUpFormProps) {
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const country = intl.formatMessage({ id: "Country" })
|
||||
const email = intl.formatMessage({ id: "Email address" })
|
||||
const phoneNumber = intl.formatMessage({ id: "Phone number" })
|
||||
const zipCode = intl.formatMessage({ id: "Zip code" })
|
||||
const signupButtonText = intl.formatMessage({
|
||||
id: "Join now",
|
||||
})
|
||||
|
||||
const signup = trpc.user.signup.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.success && data.redirectUrl) {
|
||||
router.push(data.redirectUrl)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
|
||||
console.error("Component Signup error:", error)
|
||||
},
|
||||
})
|
||||
|
||||
const methods = useForm<SignUpSchema>({
|
||||
defaultValues: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phoneNumber: "",
|
||||
dateOfBirth: "",
|
||||
address: {
|
||||
countryCode: "",
|
||||
zipCode: "",
|
||||
},
|
||||
password: "",
|
||||
termsAccepted: false,
|
||||
},
|
||||
mode: "all",
|
||||
criteriaMode: "all",
|
||||
resolver: zodResolver(signUpSchema),
|
||||
reValidateMode: "onChange",
|
||||
shouldFocusError: true,
|
||||
})
|
||||
|
||||
async function onSubmit(data: SignUpSchema) {
|
||||
signup.mutate({ ...data, language: lang })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.formWrapper}>
|
||||
<Title as="h3">{title}</Title>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.form}
|
||||
id="register"
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
>
|
||||
<section className={styles.userInfo}>
|
||||
<div className={styles.container}>
|
||||
<header>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage({ id: "Contact information" })}
|
||||
</Subtitle>
|
||||
</header>
|
||||
<div className={styles.nameInputs}>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "First name" })}
|
||||
name="firstName"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "Last name" })}
|
||||
name="lastName"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.dateField}>
|
||||
<header>
|
||||
<Caption type="bold">
|
||||
{intl.formatMessage({ id: "Birth date" })}
|
||||
</Caption>
|
||||
</header>
|
||||
<DateSelect
|
||||
name="dateOfBirth"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<Input
|
||||
label={zipCode}
|
||||
name="address.zipCode"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<CountrySelect
|
||||
label={country}
|
||||
name="address.countryCode"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label={email}
|
||||
name="email"
|
||||
registerOptions={{ required: true }}
|
||||
type="email"
|
||||
/>
|
||||
<Phone label={phoneNumber} name="phoneNumber" />
|
||||
</section>
|
||||
<section className={styles.password}>
|
||||
<header>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage({ id: "Password" })}
|
||||
</Subtitle>
|
||||
</header>
|
||||
<NewPassword
|
||||
name="password"
|
||||
label={intl.formatMessage({ id: "Password" })}
|
||||
/>
|
||||
</section>
|
||||
<section className={styles.terms}>
|
||||
<header>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage({ id: "Terms and conditions" })}
|
||||
</Subtitle>
|
||||
</header>
|
||||
<Checkbox name="termsAccepted" registerOptions={{ required: true }}>
|
||||
{intl.formatMessage({ id: "I accept" })}
|
||||
</Checkbox>
|
||||
{/* TODO: Update copy once ready */}
|
||||
<Body>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
variant="underscored"
|
||||
color="peach80"
|
||||
target="_blank"
|
||||
href={membershipTermsAndConditions[lang]}
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyPolicy: (str) => (
|
||||
<Link
|
||||
variant="underscored"
|
||||
color="peach80"
|
||||
target="_blank"
|
||||
href={privacyPolicy[lang]}
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
This is a manual validation trigger workaround:
|
||||
- The Controller component (which Input uses) doesn't re-render on submit,
|
||||
which prevents automatic error display.
|
||||
- Future fix requires Input component refactoring (out of scope for now).
|
||||
*/}
|
||||
{!methods.formState.isValid ? (
|
||||
<Button
|
||||
className={styles.signUpButton}
|
||||
type="submit"
|
||||
theme="base"
|
||||
intent="primary"
|
||||
onClick={() => methods.trigger()}
|
||||
data-testid="trigger-validation"
|
||||
>
|
||||
{signupButtonText}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={styles.signUpButton}
|
||||
type="submit"
|
||||
theme="base"
|
||||
intent="primary"
|
||||
disabled={methods.formState.isSubmitting || signup.isPending}
|
||||
data-testid="submit"
|
||||
>
|
||||
{signupButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
37
apps/scandic-web/components/Forms/Signup/schema.ts
Normal file
37
apps/scandic-web/components/Forms/Signup/schema.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { passwordValidator } from "@/utils/zod/passwordValidator"
|
||||
import { phoneValidator } from "@/utils/zod/phoneValidator"
|
||||
|
||||
const countryRequiredMsg = "Country is required"
|
||||
export const signUpSchema = z.object({
|
||||
firstName: z.string().max(250).trim().min(1, {
|
||||
message: "First name is required",
|
||||
}),
|
||||
lastName: z.string().max(250).trim().min(1, {
|
||||
message: "Last name is required",
|
||||
}),
|
||||
email: z.string().max(250).email(),
|
||||
phoneNumber: phoneValidator(
|
||||
"Phone is required",
|
||||
"Please enter a valid phone number"
|
||||
),
|
||||
dateOfBirth: z.string().min(1, {
|
||||
message: "Date of birth is required",
|
||||
}),
|
||||
address: z.object({
|
||||
countryCode: z
|
||||
.string({
|
||||
required_error: countryRequiredMsg,
|
||||
invalid_type_error: countryRequiredMsg,
|
||||
})
|
||||
.min(1, countryRequiredMsg),
|
||||
zipCode: z.string().min(1),
|
||||
}),
|
||||
password: passwordValidator("Password is required"),
|
||||
termsAccepted: z.boolean().refine((value) => value === true, {
|
||||
message: "You must accept the terms and conditions",
|
||||
}),
|
||||
})
|
||||
|
||||
export type SignUpSchema = z.infer<typeof signUpSchema>
|
||||
Reference in New Issue
Block a user