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:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

View File

@@ -0,0 +1,113 @@
"use client"
import { useIntl } from "react-intl"
import { privacyPolicy } from "@/constants/currentWebHrefs"
import { CheckIcon } from "@/components/Icons"
import LoginButton from "@/components/LoginButton"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./joinScandicFriendsCard.module.css"
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
export default function JoinScandicFriendsCard({
name,
memberPrice,
}: JoinScandicFriendsCardProps) {
const lang = useLang()
const intl = useIntl()
const list = [
{ title: intl.formatMessage({ id: "Friendly room rates" }) },
{ title: intl.formatMessage({ id: "Earn & spend points" }) },
{ title: intl.formatMessage({ id: "Join for free" }) },
]
const saveOnJoiningLabel = intl.formatMessage(
{
id: "Get the member price: {amount}",
},
{
amount: formatPrice(
intl,
memberPrice?.price ?? 0,
memberPrice?.currency ?? "SEK"
),
}
)
return (
<div className={styles.cardContainer}>
<Checkbox name={name} className={styles.checkBox}>
<div>
{memberPrice ? (
<Caption type="label" textTransform="uppercase" color="red">
{saveOnJoiningLabel}
</Caption>
) : null}
<Caption
type="label"
textTransform="uppercase"
color="uiTextHighContrast"
>
{intl.formatMessage({ id: "Join Scandic Friends" })}
</Caption>
</div>
</Checkbox>
<Footnote color="uiTextHighContrast" className={styles.login}>
{intl.formatMessage({ id: "Already a friend?" })}{" "}
<LoginButton
color="burgundy"
position="enter details"
trackingId="join-scandic-friends-enter-details"
variant="breadcrumb"
>
{intl.formatMessage({ id: "Log in" })}
</LoginButton>
</Footnote>
<div className={styles.list}>
{list.map((item) => (
<Caption
key={item.title}
color="uiTextPlaceholder"
className={styles.listItem}
>
<CheckIcon color="uiTextPlaceholder" height="20" /> {item.title}
</Caption>
))}
</div>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
{intl.formatMessage(
{
id: "By signing up you accept the Scandic Friends <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandic's customer service",
},
{
termsAndConditionsLink: (str) => (
<Link
variant="default"
textDecoration="underline"
size="tiny"
target="_blank"
color="uiTextPlaceholder"
href={privacyPolicy[lang]}
>
{str}
</Link>
),
}
)}
</Footnote>
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
.cardContainer {
align-self: flex-start;
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Large);
display: grid;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
grid-template-areas:
"checkbox"
"list"
"login"
"terms";
width: min(100%, 600px);
}
.login {
grid-area: login;
}
.checkBox {
align-self: center;
grid-area: checkbox;
}
.list {
display: grid;
grid-area: list;
gap: var(--Spacing-x1);
}
.listItem {
display: flex;
}
.terms {
border-top: 1px solid var(--Base-Border-Normal);
grid-area: terms;
padding-top: var(--Spacing-x1);
}
@media screen and (min-width: 768px) {
.cardContainer {
grid-template-columns: 1fr auto;
gap: var(--Spacing-x2);
grid-template-areas:
"checkbox login"
"list list"
"terms terms";
}
.list {
display: flex;
gap: var(--Spacing-x1);
}
}

View File

@@ -0,0 +1,67 @@
"use client"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRoom } from "@/stores/enter-details/helpers"
import { MagicWandIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./modal.module.css"
import type { Dispatch, SetStateAction } from "react"
export default function MemberPriceModal({
isOpen,
setIsOpen,
}: {
isOpen: boolean
setIsOpen: Dispatch<SetStateAction<boolean>>
}) {
const room = useEnterDetailsStore(selectRoom)
const memberRate = room.roomRate.memberRate
const intl = useIntl()
const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice
return (
<Modal isOpen={isOpen} onToggle={setIsOpen}>
<div className={styles.modalContent}>
<div className={styles.innerModalContent}>
<MagicWandIcon width="265px" />
<Title as="h3" level="h1" textTransform="regular">
{intl.formatMessage({
id: "Member price activated",
})}
</Title>
{memberPrice && (
<span className={styles.newPrice}>
<Body>
{intl.formatMessage({
id: "The new price is",
})}
</Body>
<Subtitle type="two" color="red">
{formatPrice(
intl,
memberPrice.pricePerStay,
memberPrice.currency
)}
</Subtitle>
</span>
)}
</div>
<Button intent="primary" theme="base" onClick={() => setIsOpen(false)}>
{intl.formatMessage({ id: "OK" })}
</Button>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,24 @@
.modalContent {
display: grid;
gap: var(--Spacing-x3);
width: 100%;
}
.innerModalContent {
display: grid;
gap: var(--Spacing-x2);
align-items: center;
justify-items: center;
}
.newPrice {
display: flex;
gap: var(--Spacing-x1);
align-items: center;
}
@media screen and (min-width: 768px) {
.modalContent {
width: 352px;
}
}

View File

@@ -0,0 +1,51 @@
"use client"
import { useEffect, useState } from "react"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import DateSelect from "@/components/TempDesignSystem/Form/Date"
import Input from "@/components/TempDesignSystem/Form/Input"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./signup.module.css"
export default function Signup({ name }: { name: string }) {
const intl = useIntl()
const [isJoinChecked, setIsJoinChecked] = useState(false)
const joinValue = useWatch({ name })
useEffect(() => {
// In order to avoid hydration errors the state needs to be set as side effect,
// since the join value can come from search params
setIsJoinChecked(joinValue)
}, [joinValue])
return isJoinChecked ? (
<div className={styles.additionalFormData}>
<Input
name="zipCode"
label={intl.formatMessage({ id: "Zip code" })}
registerOptions={{ required: true }}
/>
<div className={styles.dateField}>
<header>
<Caption type="bold">
<span className={styles.required}>
{intl.formatMessage({ id: "Birth date" })}
</span>
</Caption>
</header>
<DateSelect name="dateOfBirth" registerOptions={{ required: true }} />
</div>
</div>
) : (
<Input
label={intl.formatMessage({ id: "Membership no" })}
name="membershipNo"
type="tel"
/>
)
}

View File

@@ -0,0 +1,19 @@
.container {
display: grid;
grid-column: 1/-1;
gap: var(--Spacing-x3);
}
.additionalFormData {
display: grid;
gap: var(--Spacing-x4);
}
.dateField {
display: grid;
gap: var(--Spacing-x1);
}
.required:after {
content: " *";
}

View File

@@ -0,0 +1,84 @@
import { useState } from "react"
import { useIntl } from "react-intl"
import { ChevronDownIcon } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import TextArea from "@/components/TempDesignSystem/Form/TextArea"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./specialRequests.module.css"
export default function SpecialRequests() {
const [isOpen, setIsOpen] = useState(false)
const intl = useIntl()
function toggleRequests() {
setIsOpen((prevVal) => !prevVal)
}
return (
<div className={styles.requests} data-requests-open={isOpen}>
<button className={styles.toggle} onClick={toggleRequests} type="button">
<Footnote
color="uiTextHighContrast"
textTransform="uppercase"
type="label"
className={styles.header}
textAlign="left"
>
{intl.formatMessage({ id: "Special requests" })}
</Footnote>
<ChevronDownIcon className={styles.chevron} />
<Divider className={styles.divider} color="subtle" />
</button>
<div className={styles.content}>
<div className={styles.contentWrapper}>
{/*
TODO: Hiding because API is not ready for this yet (https://scandichotels.atlassian.net/browse/SW-1497). Add back in when API is ready.
<Select
label={intl.formatMessage({ id: "Floor preference" })}
name="specialRequests.floorPreference"
items={[
noPreferenceItem,
{
value: FloorPreference.HIGH,
label: intl.formatMessage({ id: "High floor" }),
},
{
value: FloorPreference.LOW,
label: intl.formatMessage({ id: "Low floor" }),
},
]}
/>
<Select
label={intl.formatMessage({ id: "Elevator preference" })}
name="specialRequests.elevatorPreference"
items={[
noPreferenceItem,
{
value: ElevatorPreference.AWAY_FROM_ELEVATOR,
label: intl.formatMessage({
id: "Away from elevator",
}),
},
{
value: ElevatorPreference.NEAR_ELEVATOR,
label: intl.formatMessage({
id: "Near elevator",
}),
},
]}
/> */}
<TextArea
label={intl.formatMessage({
id: "Is there anything else you would like us to know before your arrival?",
})}
name="specialRequests.comments"
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
.requests {
--header-height: 50px;
display: grid;
grid-template-rows: var(--header-height) 0fr;
transition: 0.3s ease-out;
grid-column: 1 / -1;
}
.toggle {
display: grid;
grid-template-areas:
"header chevron"
"divider divider";
grid-template-columns: 1fr auto;
background-color: transparent;
gap: var(--Spacing-x1);
border: none;
width: 100%;
cursor: pointer;
margin: var(--Spacing-x2) 0;
}
.header {
grid-area: header;
align-self: flex-start;
}
.chevron {
grid-area: chevron;
}
.divider {
grid-area: divider;
border-top: 1px solid var(--Color-gray-300);
}
.requests[data-requests-open="true"] .chevron {
transform: rotate(180deg);
}
.content {
overflow: hidden;
}
.contentWrapper {
padding-top: var(--Spacing-x3);
display: grid;
gap: var(--Spacing-x2);
width: 100%;
}
.requests[data-requests-open="true"] {
grid-template-rows: var(--header-height) 1fr;
}

View File

@@ -0,0 +1,30 @@
.form {
display: grid;
gap: var(--Spacing-x3);
}
.container {
display: grid;
gap: var(--Spacing-x2);
width: min(100%, 600px);
}
.fullWidth {
grid-column: 1/-1;
}
.footer {
margin-top: var(--Spacing-x1);
}
@media screen and (min-width: 768px) {
.form {
gap: var(--Spacing-x3);
}
.container {
gap: var(--Spacing-x2);
grid-template-columns: 1fr 1fr;
width: min(100%, 600px);
}
}

View File

@@ -0,0 +1,170 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import {
selectBookingProgress,
selectRoom,
} from "@/stores/enter-details/helpers"
import Button from "@/components/TempDesignSystem/Button"
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
import Input from "@/components/TempDesignSystem/Form/Input"
import Phone from "@/components/TempDesignSystem/Form/Phone"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
import MemberPriceModal from "./MemberPriceModal"
import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
import Signup from "./Signup"
import SpecialRequests from "./SpecialRequests"
import styles from "./details.module.css"
import type {
DetailsProps,
DetailsSchema,
} from "@/types/components/hotelReservation/enterDetails/details"
const formID = "enter-details"
export default function Details({
user,
memberPrice,
roomIndex,
}: DetailsProps & { roomIndex: number }) {
const intl = useIntl()
const [isMemberPriceModalOpen, setIsMemberPriceModalOpen] = useState(false)
const { currentRoomIndex, canProceedToPayment, roomStatuses } =
useEnterDetailsStore(selectBookingProgress)
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
const initialData = room.guest
const updateDetails = useEnterDetailsStore(
(state) => state.actions.updateDetails
)
const isPaymentNext = currentRoomIndex === roomStatuses.length - 1
const methods = useForm<DetailsSchema>({
criteriaMode: "all",
mode: "all",
resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema),
reValidateMode: "onChange",
values: {
countryCode: user?.address?.countryCode ?? initialData.countryCode,
dateOfBirth: initialData.dateOfBirth,
email: user?.email ?? initialData.email,
firstName: user?.firstName ?? initialData.firstName,
join: initialData.join,
lastName: user?.lastName ?? initialData.lastName,
membershipNo: initialData.membershipNo,
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
zipCode: initialData.zipCode,
specialRequests: initialData.specialRequests,
},
})
const onSubmit = useCallback(
(values: DetailsSchema) => {
if ((values.join || values.membershipNo) && memberPrice && !user) {
setIsMemberPriceModalOpen(true)
}
updateDetails(values)
},
[updateDetails, setIsMemberPriceModalOpen, memberPrice, user]
)
return (
<FormProvider {...methods}>
<form
className={styles.form}
id={`${formID}-room-${roomIndex + 1}`}
onSubmit={methods.handleSubmit(onSubmit)}
>
{user ? null : (
<JoinScandicFriendsCard name="join" memberPrice={memberPrice} />
)}
<div className={styles.container}>
<Footnote
color="uiTextHighContrast"
textTransform="uppercase"
type="label"
className={styles.fullWidth}
>
{intl.formatMessage({ id: "Guest information" })}
</Footnote>
<Input
label={intl.formatMessage({ id: "First name" })}
maxLength={30}
name="firstName"
readOnly={!!user}
registerOptions={{ required: true }}
/>
<Input
label={intl.formatMessage({ id: "Last name" })}
maxLength={30}
name="lastName"
readOnly={!!user}
registerOptions={{ required: true }}
/>
<CountrySelect
className={styles.fullWidth}
label={intl.formatMessage({ id: "Country" })}
name="countryCode"
readOnly={!!user}
registerOptions={{ required: true }}
/>
<Input
className={styles.fullWidth}
label={intl.formatMessage({ id: "Email address" })}
name="email"
readOnly={!!user}
registerOptions={{ required: true }}
/>
<Phone
className={styles.fullWidth}
label={intl.formatMessage({ id: "Phone number" })}
name="phoneNumber"
readOnly={!!user}
registerOptions={{ required: true }}
/>
{user ? null : (
<div className={styles.fullWidth}>
<Signup name="join" />
</div>
)}
<SpecialRequests />
</div>
<footer className={styles.footer}>
<Button
disabled={
!(
methods.formState.isValid ||
(isPaymentNext && canProceedToPayment)
)
}
intent="secondary"
size="small"
theme="base"
type="submit"
>
{isPaymentNext
? intl.formatMessage({ id: "Proceed to payment method" })
: intl.formatMessage(
{ id: "Continue to room {nextRoomNumber}" },
{ nextRoomNumber: currentRoomIndex + 2 }
)}
</Button>
</footer>
<MemberPriceModal
isOpen={isMemberPriceModalOpen}
setIsOpen={setIsMemberPriceModalOpen}
/>
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,120 @@
import { z } from "zod"
import { dt } from "@/lib/dt"
import { phoneValidator } from "@/utils/zod/phoneValidator"
// stringMatcher regex is copied from current web as specified by requirements.
const stringMatcher =
/^[A-Za-z¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ0-9-\s]*$/
const isValidString = (key: string) => stringMatcher.test(key)
export enum FloorPreference {
LOW = "Low floor",
HIGH = "High floor",
}
export enum ElevatorPreference {
AWAY_FROM_ELEVATOR = "Away from elevator",
NEAR_ELEVATOR = "Near elevator",
}
const specialRequestsSchema = z
.object({
floorPreference: z
.nativeEnum(FloorPreference)
.or(z.literal("").transform((_) => undefined))
.optional(),
elevatorPreference: z
.nativeEnum(ElevatorPreference)
.or(z.literal("").transform((_) => undefined))
.optional(),
comments: z.string().default(""),
})
.optional()
export const baseDetailsSchema = z.object({
countryCode: z.string().min(1, { message: "Country is required" }),
email: z.string().email({ message: "Email address is required" }),
firstName: z
.string()
.min(1, { message: "First name is required" })
.refine(isValidString, {
message: "First name can't contain any special characters",
}),
lastName: z
.string()
.min(1, { message: "Last name is required" })
.refine(isValidString, {
message: "Last name can't contain any special characters",
}),
phoneNumber: phoneValidator(),
specialRequests: specialRequestsSchema,
})
export const notJoinDetailsSchema = baseDetailsSchema.merge(
z.object({
join: z.literal<boolean>(false),
zipCode: z.string().optional(),
dateOfBirth: z.string().optional(),
membershipNo: z
.string()
.optional()
.refine((val) => {
if (val) {
return !val.match(/[^0-9]/g)
}
return true
}, "Only digits are allowed")
.refine((num) => {
if (num) {
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
}
return true
}, "Invalid membership number format"),
})
)
export const joinDetailsSchema = baseDetailsSchema.merge(
z.object({
join: z.literal<boolean>(true),
zipCode: z.string().min(1, { message: "Zip code is required" }),
dateOfBirth: z
.string()
.min(1, { message: "Date of birth is required" })
.refine(
(date) => {
const today = dt()
const dob = dt(date)
const age = today.diff(dob, "year")
return age >= 18
},
{ message: "Must be at least 18 years of age to continue" }
),
membershipNo: z.string().default(""),
})
)
export const guestDetailsSchema = z.discriminatedUnion("join", [
notJoinDetailsSchema,
joinDetailsSchema,
])
// For signed in users we accept partial or invalid data. Users cannot
// change their info in this flow, so we don't want to validate it.
export const signedInDetailsSchema = z.object({
countryCode: z.string().default(""),
email: z.string().default(""),
firstName: z.string().default(""),
lastName: z.string().default(""),
membershipNo: z.string().default(""),
phoneNumber: z.string().default(""),
join: z
.boolean()
.optional()
.transform((_) => false),
dateOfBirth: z.string().default(""),
zipCode: z.string().default(""),
specialRequests: specialRequestsSchema,
})