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,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);
}

View File

@@ -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>
)
}

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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>
}

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -0,0 +1,9 @@
.list {
display: flex;
flex-direction: column;
list-style: none;
}
.label {
padding: 0 var(--Spacing-x1);
}

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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;
}