feat(SW-66, SW-348): search functionality and ui

This commit is contained in:
Simon Emanuelsson
2024-08-28 10:47:57 +02:00
parent b9dbcf7d90
commit af850c90e7
437 changed files with 7663 additions and 9881 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,47 @@
"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,
highlightedIndex,
index,
}: ClearSearchButtonProps) {
const intl = useIntl()
const classNames = buttonVariants({
variant: index === highlightedIndex ? "active" : "default",
})
function handleClick() {
// noop
// the click bubbles to handleOnSelect
// where selectedItem = "clear-search"
// which is the value for item below
}
return (
<button
{...getItemProps({
className: classNames,
id: "clear-search",
index,
item: "clear-search",
role: "button",
})}
onClick={handleClick}
tabIndex={0}
type="button"
>
<DeleteIcon color="burgundy" height={20} width={20} />
<Caption color="burgundy" textTransform="bold">
{intl.formatMessage({ id: "Clear searches" })}
</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,33 @@
.dialog {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Large);
box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
left: 0;
list-style: none;
max-height: 380px;
overflow-y: auto;
padding: var(--Spacing-x2) var(--Spacing-x3);
position: absolute;
/**
* var(--Spacing-x4) to account for padding inside
* the bookingwidget and to add the padding for the
* box itself
*/
top: calc(100% + var(--Spacing-x4));
width: 360px;
z-index: 99;
}
.default {
gap: var(--Spacing-x1);
}
.error {
gap: var(--Spacing-x-half);
}
.search {
gap: var(--Spacing-x3);
}

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}
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,174 @@
"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,
highlightedIndex,
isOpen,
locations,
search,
searchHistory,
}: SearchListProps) {
const intl = useIntl()
const [hasMounted, setHasMounted] = useState(false)
const {
clearErrors,
formState: { errors },
} = useFormContext()
const searchError = errors["search"]
useEffect(() => {
let timeoutID: ReturnType<typeof setTimeout> | null = null
if (searchError && searchError.message === "Required") {
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) {
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>
)
}
}
}
}
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}
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}
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,180 @@
"use client"
import Downshift from "downshift"
import {
ChangeEvent,
FocusEvent,
FormEvent,
useCallback,
useReducer,
} from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { 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 } from "@/types/components/form/bookingwidget"
import type { SearchProps } from "@/types/components/search"
import type { Location } from "@/types/trpc/routers/hotel/locations"
const name = "search"
export default function Search({ locations }: SearchProps) {
const [state, dispatch] = useReducer(
reducer,
{ defaultLocations: locations },
init
)
const { register, setValue, trigger } = useFormContext<BookingWidgetSchema>()
const intl = useIntl()
const value = useWatch({ name })
const handleMatchLocations = useCallback(
function (searchValue: string) {
return locations.filter((location) => {
return location.name.toLowerCase().includes(searchValue.toLowerCase())
})
},
[locations]
)
function handleOnBlur() {
if (!value && state.searchData?.name) {
setValue(name, state.searchData.name)
// Always need to manually trigger
// revalidation when setting value r-h-f
trigger()
}
}
function handleOnChange(
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
) {
const value = evt.currentTarget.value
if (value) {
dispatch({
payload: { search: value },
type: ActionType.SEARCH_LOCATIONS,
})
} else {
dispatch({ type: ActionType.CLEAR_SEARCH_LOCATIONS })
}
}
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 | "clear-search") {
if (selectedItem === "clear-search") {
localStorage.removeItem(localStorageKey)
dispatch({ type: ActionType.CLEAR_HISTORY_LOCATIONS })
} else if (selectedItem) {
const stringified = JSON.stringify(selectedItem)
setValue("location", encodeURIComponent(stringified))
sessionStorage.setItem(sessionStorageKey, stringified)
setValue(name, selectedItem.name)
trigger()
const searchHistoryMap = new Map()
searchHistoryMap.set(selectedItem.name, selectedItem)
if (state.searchHistory) {
state.searchHistory.forEach((location) => {
searchHistoryMap.set(location.name, location)
})
}
const searchHistory: Location[] = []
searchHistoryMap.forEach((location) => {
searchHistory.push(location)
})
localStorage.setItem(localStorageKey, JSON.stringify(searchHistory))
dispatch({
payload: {
location: selectedItem,
searchHistory,
},
type: ActionType.SELECT_ITEM,
})
} else {
sessionStorage.removeItem(sessionStorageKey)
}
}
return (
<Downshift
initialSelectedItem={state.searchData}
inputValue={value}
itemToString={(value) => (value ? value.name : "")}
onSelect={handleOnSelect}
>
{({
closeMenu,
getInputProps,
getItemProps,
getLabelProps,
getMenuProps,
getRootProps,
highlightedIndex,
isOpen,
openMenu,
}) => (
<div className={styles.container}>
<label {...getLabelProps({ htmlFor: name })} className={styles.label}>
<Caption color={isOpen ? "uiTextActive" : "red"}>
{intl.formatMessage({ id: "Where to" })}
</Caption>
</label>
<div {...getRootProps({}, { suppressRefError: true })}>
<Body asChild>
<input
{...getInputProps({
className: styles.input,
id: name,
onFocus(evt) {
handleOnFocus(evt)
openMenu()
},
placeholder: intl.formatMessage({
id: "Destinations & hotels",
}),
...register(name, {
onBlur: function () {
handleOnBlur()
closeMenu()
},
onChange: handleOnChange,
}),
type: "search",
})}
/>
</Body>
</div>
<SearchList
getItemProps={getItemProps}
getMenuProps={getMenuProps}
highlightedIndex={highlightedIndex}
isOpen={isOpen}
locations={state.locations}
search={state.search}
searchHistory={state.searchHistory}
/>
</div>
)}
</Downshift>
)
}

View File

@@ -0,0 +1,104 @@
import {
type Action,
ActionType,
type InitState,
type State,
} from "@/types/components/form/bookingwidget"
import type { Location, Locations } from "@/types/trpc/routers/hotel/locations"
export const localStorageKey = "searchHistory"
export function getSearchHistoryFromLocalStorage() {
if (typeof window !== "undefined") {
const storageSearchHistory = window.localStorage.getItem(localStorageKey)
if (storageSearchHistory) {
const parsedStorageSearchHistory: Locations =
JSON.parse(storageSearchHistory)
if (parsedStorageSearchHistory?.length) {
return parsedStorageSearchHistory
}
}
}
return null
}
export const sessionStorageKey = "searchData"
export function getSearchDataFromSessionStorage() {
if (typeof window !== "undefined") {
const storageSearchData = window.sessionStorage.getItem(sessionStorageKey)
if (storageSearchData) {
const parsedStorageSearchData: Location = JSON.parse(storageSearchData)
if (parsedStorageSearchData) {
return parsedStorageSearchData
}
}
}
return undefined
}
export function init(initState: InitState): State {
const searchHistory = getSearchHistoryFromLocalStorage()
const searchData = getSearchDataFromSessionStorage()
return {
defaultLocations: initState.defaultLocations,
locations: [],
search: "",
searchData,
searchHistory,
}
}
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?.map((l) => l.toLowerCase())
if (locationName.includes(search.trim())) {
matchesMap.set(location.name, location)
}
if (keyWords?.find((keyWord) => keyWord.includes(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,
}
}
default:
const unhandledActionType: never = type
console.info(`Unhandled type: ${unhandledActionType}`)
return state
}
}

View File

@@ -0,0 +1,47 @@
.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);
}
.container:hover,
.container:has(.input:active, .input:focus, .input:focus-within) {
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
.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);
}
.input {
background-color: transparent;
border: none;
height: 24px;
outline: none;
position: relative;
z-index: 2;
}
.input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
background-image: url("/_static/icons/close.svg");
height: 20px;
width: 20px;
}
.container:hover:has(.input:not(:active, :focus, :focus-within))
.input::-webkit-search-cancel-button {
display: none;
}

View File

@@ -5,26 +5,31 @@
.input input[type="text"] {
border: none;
padding: var(--Spacing-x1) var(--Spacing-x0);
}
.where {
width: 100%;
max-width: 280px;
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
height: 24px;
}
.rooms,
.vouchers,
.when,
.rooms {
width: 100%;
max-width: 240px;
.where {
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
width: 100%;
}
.rooms,
.when {
max-width: 240px;
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
}
.vouchers {
width: 100%;
max-width: 200px;
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x1) 0;
}
.where {
max-width: 280px;
position: relative;
}
.options {

View File

@@ -3,23 +3,25 @@ import { useIntl } from "react-intl"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Search from "./Search"
import styles from "./formContent.module.css"
export default function FormContent() {
const intl = useIntl()
import type { BookingWidgetFormContentProps } from "@/types/components/form/bookingwidget"
const where = intl.formatMessage({ id: "Where to" })
export default function FormContent({
locations,
}: BookingWidgetFormContentProps) {
const intl = useIntl()
const when = intl.formatMessage({ id: "When" })
const rooms = intl.formatMessage({ id: "Rooms & Guests" })
const vouchers = intl.formatMessage({ id: "Booking codes and vouchers" })
const bonus = intl.formatMessage({ id: "Use bonus cheque" })
const reward = intl.formatMessage({ id: "Book reward night" })
return (
<div className={styles.input}>
<div className={styles.where}>
<Caption color="red">{where}</Caption>
<input type="text" placeholder={where} />
<Search locations={locations} />
</div>
<div className={styles.when}>
<Caption color="red" textTransform="bold">