Merged in fix/SW-2253-consolidate-autocomplete-search (pull request #1795)
Consolidate autocomplete search SW-2253 SW-2338 * use fuse.js for fuzzy search * Handle weird behaviour when search field loses focus on destinationPage * Add error logging for JumpTo when no URL was provided * Switch to use <Typography /> over <Caption /> * fix: bookingWidget search label should always be red * fix: searchHistory can no longer add invalid items * fix: list more hits when searching * fix: issue when searchField value was undefined * fix: don't show searchHistory label if no searchHistory items * simplify skeleton for listitems in search Approved-by: Linus Flood
This commit is contained in:
@@ -1,114 +0,0 @@
|
|||||||
.label {
|
|
||||||
color: var(--Base-Text-Accent);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
background: var(--Surface-Primary-Default);
|
|
||||||
padding: var(--Space-x15) var(--Space-x15) var(--Space-x15) var(--Space-x3);
|
|
||||||
border-radius: var(--Corner-radius-Rounded);
|
|
||||||
border: solid 1px var(--Border-Default);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form:focus-within {
|
|
||||||
border-color: var(--UI-Input-Controls-Border-Focus);
|
|
||||||
|
|
||||||
& label {
|
|
||||||
color: var(--UI-Text-Active);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchField:focus-within + .results {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fields {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clearButton {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 0;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
color: var(--Text-Heading);
|
|
||||||
padding: var(--Space-x15); /* search field vertical padding */
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
|
|
||||||
&::-webkit-search-cancel-button,
|
|
||||||
&::-webkit-search-decoration {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: var(--UI-Text-Placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.results {
|
|
||||||
position: relative;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchButton {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--Space-x05);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menuContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--Space-x2);
|
|
||||||
background: var(--Base-Surface-Primary-light-Normal);
|
|
||||||
border-radius: var(--Corner-radius-Large);
|
|
||||||
padding: var(--Space-x2);
|
|
||||||
width: 360px;
|
|
||||||
max-height: 400px;
|
|
||||||
box-sizing: content-box;
|
|
||||||
box-shadow: var(--BoxShadow-Level-4);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: var(--Space-x2);
|
|
||||||
z-index: 50;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
transition: opacity 0.2s 0.2s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.pending > div {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
|
||||||
.autocomplete {
|
|
||||||
max-width: 680px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { cx } from "class-variance-authority"
|
|
||||||
import { memo, useTransition } from "react"
|
|
||||||
import {
|
|
||||||
Autocomplete,
|
|
||||||
Button as ButtonRAC,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
SearchField,
|
|
||||||
} from "react-aria-components"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
import { useIsMounted } from "usehooks-ts"
|
|
||||||
|
|
||||||
import { Button } from "@scandic-hotels/design-system/Button"
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
|
||||||
|
|
||||||
import { Results } from "../Results"
|
|
||||||
|
|
||||||
import styles from "./clientInline.module.css"
|
|
||||||
|
|
||||||
import type { ClientProps } from "@/types/components/destinationOverviewPage/jumpTo/client"
|
|
||||||
|
|
||||||
const ResultsMemo = memo(Results)
|
|
||||||
|
|
||||||
export function ClientInline({
|
|
||||||
results,
|
|
||||||
latest,
|
|
||||||
setFilterString,
|
|
||||||
onAction,
|
|
||||||
}: ClientProps) {
|
|
||||||
const intl = useIntl()
|
|
||||||
const [isPending, startTransition] = useTransition()
|
|
||||||
const isMounted = useIsMounted()
|
|
||||||
|
|
||||||
const showResults = !!results
|
|
||||||
const showHistory =
|
|
||||||
latest.length > 0 && isMounted() && (!results || results.length === 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Autocomplete>
|
|
||||||
<div className={styles.autocomplete}>
|
|
||||||
<SearchField
|
|
||||||
className={styles.searchField}
|
|
||||||
onClear={() => {
|
|
||||||
startTransition(() => {
|
|
||||||
setFilterString(null)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ state }) => (
|
|
||||||
<form
|
|
||||||
className={styles.form}
|
|
||||||
onSubmit={(evt) => {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.fields}>
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
|
||||||
<Label className={styles.label}>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Where to?",
|
|
||||||
})}
|
|
||||||
</Label>
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<Input
|
|
||||||
className={styles.input}
|
|
||||||
placeholder={intl.formatMessage({
|
|
||||||
defaultMessage: "Hotels & Destinations",
|
|
||||||
})}
|
|
||||||
onChange={(evt) => {
|
|
||||||
startTransition(() => {
|
|
||||||
if (evt.currentTarget.value) {
|
|
||||||
setFilterString(evt.currentTarget.value)
|
|
||||||
} else {
|
|
||||||
setFilterString(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{state.value !== "" && (
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
|
||||||
<ButtonRAC className={styles.clearButton}>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Clear",
|
|
||||||
})}
|
|
||||||
</ButtonRAC>
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
|
||||||
<Button
|
|
||||||
className={styles.searchButton}
|
|
||||||
variant="Primary"
|
|
||||||
size="Small"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
<MaterialIcon icon="search" color="CurrentColor" />
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Search",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
</Typography>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</SearchField>
|
|
||||||
{showResults || showHistory ? (
|
|
||||||
<div className={styles.results}>
|
|
||||||
<div
|
|
||||||
className={cx({
|
|
||||||
[styles.menuContainer]: true,
|
|
||||||
[styles.pending]: isPending,
|
|
||||||
})}
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{showResults ? (
|
|
||||||
<ResultsMemo
|
|
||||||
aria-label={intl.formatMessage({
|
|
||||||
defaultMessage: "Results",
|
|
||||||
})}
|
|
||||||
results={results}
|
|
||||||
onAction={onAction}
|
|
||||||
renderEmptyState={true}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{showHistory ? (
|
|
||||||
<ResultsMemo
|
|
||||||
aria-label={intl.formatMessage({
|
|
||||||
defaultMessage: "Latest searches",
|
|
||||||
})}
|
|
||||||
results={latest}
|
|
||||||
onAction={onAction}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Autocomplete>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
.label {
|
|
||||||
color: var(--Base-Text-Accent);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
color: var(--UI-Text-Placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchField {
|
|
||||||
background: var(--Base-Background-Primary-Normal);
|
|
||||||
padding: var(--Space-x1) var(--Space-x15);
|
|
||||||
border-radius: var(--Corner-radius-Medium);
|
|
||||||
border: solid 1px transparent;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchField:focus-within {
|
|
||||||
border-color: var(--UI-Input-Controls-Border-Focus);
|
|
||||||
|
|
||||||
& label {
|
|
||||||
color: var(--UI-Text-Active);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto 1fr auto;
|
|
||||||
gap: var(--Space-x4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clearButton {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 0;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
color: var(--Text-Heading);
|
|
||||||
padding: var(--Space-x15); /* search field vertical padding */
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
|
|
||||||
&::-webkit-search-cancel-button,
|
|
||||||
&::-webkit-search-decoration {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: var(--UI-Text-Placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.results {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menuContainer {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--Space-x2);
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
transition: opacity 0.2s 0.2s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.pending > div {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger {
|
|
||||||
background: var(--Base-Surface-Primary-light-Normal);
|
|
||||||
padding: var(--Space-x15) var(--Space-x15) var(--Space-x15) var(--Space-x3);
|
|
||||||
border: solid 1px var(--Border-Intense);
|
|
||||||
border-radius: var(--Corner-radius-Rounded);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
& span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .icon {
|
|
||||||
background: var(--Base-Button-Primary-Fill-Normal);
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: var(--Corner-radius-Rounded);
|
|
||||||
color: var(--Base-Text-Inverted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalOverlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: var(--visual-viewport-height);
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
|
|
||||||
&[data-entering] {
|
|
||||||
animation: overlay-fade 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-exiting] {
|
|
||||||
animation: overlay-fade 150ms reverse ease-in;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
--padding-x: 16px; /* Not a design token */
|
|
||||||
--height: 660px; /* Not a design token */
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: var(--height);
|
|
||||||
max-height: 95vh;
|
|
||||||
padding: var(--Space-x3) var(--padding-x);
|
|
||||||
background: var(--UI-Input-Controls-Surface-Normal);
|
|
||||||
z-index: 100;
|
|
||||||
border-top-left-radius: var(--Corner-radius-Large);
|
|
||||||
border-top-right-radius: var(--Corner-radius-Large);
|
|
||||||
|
|
||||||
&[data-entering] {
|
|
||||||
animation: modal-anim 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-exiting] {
|
|
||||||
animation: modal-anim 150ms reverse ease-in;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto 1fr;
|
|
||||||
height: 100%;
|
|
||||||
gap: var(--Space-x3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.closeButton {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--UI-Text-High-contrast);
|
|
||||||
border: 0;
|
|
||||||
justify-self: end;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes overlay-fade {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modal-anim {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { cx } from "class-variance-authority"
|
|
||||||
import { memo, useTransition } from "react"
|
|
||||||
import {
|
|
||||||
Autocomplete,
|
|
||||||
Button as ButtonRAC,
|
|
||||||
Dialog,
|
|
||||||
DialogTrigger,
|
|
||||||
Heading,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
Modal,
|
|
||||||
ModalOverlay,
|
|
||||||
SearchField,
|
|
||||||
} from "react-aria-components"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
|
||||||
|
|
||||||
import { Results } from "../Results"
|
|
||||||
|
|
||||||
import styles from "./clientModal.module.css"
|
|
||||||
|
|
||||||
import type { ClientProps } from "@/types/components/destinationOverviewPage/jumpTo/client"
|
|
||||||
|
|
||||||
const ResultsMemo = memo(Results)
|
|
||||||
|
|
||||||
export function ClientModal({
|
|
||||||
results,
|
|
||||||
latest,
|
|
||||||
setFilterString,
|
|
||||||
onAction,
|
|
||||||
}: ClientProps) {
|
|
||||||
const intl = useIntl()
|
|
||||||
const [isPending, startTransition] = useTransition()
|
|
||||||
|
|
||||||
const showResults = !!results
|
|
||||||
const showHistory = latest.length > 0 && (!results || results.length === 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogTrigger>
|
|
||||||
<ButtonRAC className={styles.trigger}>
|
|
||||||
<span>
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
|
||||||
<span className={styles.label}>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Where to?",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<span className={styles.placeholder}>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Hotels & Destinations",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
</span>
|
|
||||||
<span className={styles.icon}>
|
|
||||||
<MaterialIcon icon="search" color="CurrentColor" />
|
|
||||||
</span>
|
|
||||||
</ButtonRAC>
|
|
||||||
<ModalOverlay className={styles.modalOverlay} isDismissable={true}>
|
|
||||||
<Modal className={styles.modal}>
|
|
||||||
<Dialog className={styles.dialog}>
|
|
||||||
<Heading level={2} className="sr-only">
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Find a location",
|
|
||||||
})}
|
|
||||||
</Heading>
|
|
||||||
<ButtonRAC
|
|
||||||
className={styles.closeButton}
|
|
||||||
slot="close"
|
|
||||||
aria-label={intl.formatMessage({
|
|
||||||
defaultMessage: "Close",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<MaterialIcon icon="close" color="CurrentColor" />
|
|
||||||
</ButtonRAC>
|
|
||||||
<Autocomplete>
|
|
||||||
<div className={styles.autocomplete}>
|
|
||||||
<SearchField
|
|
||||||
autoFocus
|
|
||||||
className={styles.searchField}
|
|
||||||
onClear={() => {
|
|
||||||
startTransition(() => {
|
|
||||||
setFilterString(null)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ state }) => (
|
|
||||||
<form
|
|
||||||
onSubmit={(evt) => {
|
|
||||||
evt.preventDefault()
|
|
||||||
evt.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
|
||||||
<Label className={styles.label}>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Where to?",
|
|
||||||
})}
|
|
||||||
</Label>
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<Input
|
|
||||||
className={styles.input}
|
|
||||||
placeholder={intl.formatMessage({
|
|
||||||
defaultMessage: "Hotels & Destinations",
|
|
||||||
})}
|
|
||||||
onChange={(evt) => {
|
|
||||||
startTransition(() => {
|
|
||||||
if (evt.currentTarget.value) {
|
|
||||||
setFilterString(evt.currentTarget.value)
|
|
||||||
} else {
|
|
||||||
setFilterString(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{state.value !== "" && (
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
|
||||||
<ButtonRAC className={styles.clearButton}>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Clear",
|
|
||||||
})}
|
|
||||||
</ButtonRAC>
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</SearchField>
|
|
||||||
<div className={styles.results}>
|
|
||||||
<div
|
|
||||||
className={cx({
|
|
||||||
[styles.menuContainer]: true,
|
|
||||||
[styles.pending]: isPending,
|
|
||||||
})}
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{showResults ? (
|
|
||||||
<ResultsMemo
|
|
||||||
aria-label={intl.formatMessage({
|
|
||||||
defaultMessage: "Results",
|
|
||||||
})}
|
|
||||||
results={results}
|
|
||||||
onAction={onAction}
|
|
||||||
renderEmptyState={true}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{showHistory ? (
|
|
||||||
<ResultsMemo
|
|
||||||
aria-label={intl.formatMessage({
|
|
||||||
defaultMessage: "Latest searches",
|
|
||||||
})}
|
|
||||||
results={latest}
|
|
||||||
onAction={onAction}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Autocomplete>
|
|
||||||
</Dialog>
|
|
||||||
</Modal>
|
|
||||||
</ModalOverlay>
|
|
||||||
</DialogTrigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
|
||||||
|
|
||||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
|
||||||
|
|
||||||
import styles from "./results.module.css"
|
|
||||||
|
|
||||||
export function ResultsSkeleton() {
|
|
||||||
const intl = useIntl()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={styles.menu}>
|
|
||||||
<Typography variant="Title/Overline/sm">
|
|
||||||
<header className={styles.sectionHeader}>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Loading results",
|
|
||||||
})}
|
|
||||||
</header>
|
|
||||||
</Typography>
|
|
||||||
<div>
|
|
||||||
<div className={styles.item}>
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<SkeletonShimmer width="50%" display="inline-block" />
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<div className={styles.itemDescription}>
|
|
||||||
<SkeletonShimmer width="38%" display="inline-block" />
|
|
||||||
</div>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<div className={styles.item}>
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<SkeletonShimmer width="40%" display="inline-block" />
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<div className={styles.itemDescription}>
|
|
||||||
<SkeletonShimmer width="23%" display="inline-block" />
|
|
||||||
</div>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<div className={styles.item}>
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<SkeletonShimmer width="55%" display="inline-block" />
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<div className={styles.itemDescription}>
|
|
||||||
<SkeletonShimmer width="40%" display="inline-block" />
|
|
||||||
</div>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<div className={styles.item}>
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<SkeletonShimmer width="27%" display="inline-block" />
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<div className={styles.itemDescription}>
|
|
||||||
<SkeletonShimmer width="33%" display="inline-block" />
|
|
||||||
</div>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<div className={styles.item}>
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<SkeletonShimmer width="45%" display="inline-block" />
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<div className={styles.itemDescription}>
|
|
||||||
<SkeletonShimmer width="37%" display="inline-block" />
|
|
||||||
</div>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Collection,
|
|
||||||
Header,
|
|
||||||
ListLayout,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
MenuSection,
|
|
||||||
Text,
|
|
||||||
Virtualizer,
|
|
||||||
} from "react-aria-components"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
|
||||||
|
|
||||||
import styles from "./results.module.css"
|
|
||||||
|
|
||||||
import type { ResultsProps } from "@/types/components/destinationOverviewPage/jumpTo/results"
|
|
||||||
|
|
||||||
export function Results({
|
|
||||||
"aria-label": ariaLabel,
|
|
||||||
results,
|
|
||||||
onAction,
|
|
||||||
renderEmptyState = false,
|
|
||||||
}: ResultsProps) {
|
|
||||||
const intl = useIntl()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Virtualizer
|
|
||||||
layout={ListLayout}
|
|
||||||
layoutOptions={{
|
|
||||||
estimatedRowHeight: 64,
|
|
||||||
estimatedHeadingHeight: 41,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
onAction={onAction}
|
|
||||||
className={styles.menu}
|
|
||||||
items={results}
|
|
||||||
renderEmptyState={() => {
|
|
||||||
if (renderEmptyState) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<Header className={styles.noResultsLabel}>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "No results",
|
|
||||||
})}
|
|
||||||
</Header>
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<span className={styles.noResultsDescription}>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage:
|
|
||||||
"We couldn't find a matching location for your search.",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(section) => {
|
|
||||||
if (section.id === "actions") {
|
|
||||||
return (
|
|
||||||
<MenuSection key={section.id} className={styles.actionsSection}>
|
|
||||||
<Header className={styles.menuDivider}>
|
|
||||||
<span className="sr-only">{section.name}</span>
|
|
||||||
</Header>
|
|
||||||
<Collection items={section.children}>
|
|
||||||
{(item) => (
|
|
||||||
<MenuItem
|
|
||||||
key={item.id}
|
|
||||||
href={item.url}
|
|
||||||
className={styles.item}
|
|
||||||
textValue={item.displayName}
|
|
||||||
// @ts-expect-error: pending https://github.com/adobe/react-spectrum/pull/8035
|
|
||||||
closeOnSelect={item.closesModal}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
{item.icon ? item.icon : null}
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
|
||||||
<Text slot="label">{item.displayName}</Text>
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
</Collection>
|
|
||||||
</MenuSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MenuSection key={section.id}>
|
|
||||||
<Typography variant="Title/Overline/sm">
|
|
||||||
<Header className={styles.sectionHeader}>{section.name}</Header>
|
|
||||||
</Typography>
|
|
||||||
<Collection items={section.children}>
|
|
||||||
{(item) => (
|
|
||||||
<MenuItem
|
|
||||||
key={item.id}
|
|
||||||
href={item.url}
|
|
||||||
className={styles.item}
|
|
||||||
textValue={item.displayName}
|
|
||||||
>
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<Text slot="label" className={styles.itemLabel}>
|
|
||||||
{item.displayName}
|
|
||||||
</Text>
|
|
||||||
</Typography>
|
|
||||||
{item.description ? (
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<Text
|
|
||||||
slot="description"
|
|
||||||
className={styles.itemDescription}
|
|
||||||
>
|
|
||||||
{item.description}
|
|
||||||
</Text>
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
</Collection>
|
|
||||||
</MenuSection>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Menu>
|
|
||||||
</Virtualizer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
.menu {
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
&[data-empty] {
|
|
||||||
height: initial;
|
|
||||||
overflow-y: initial;
|
|
||||||
padding-left: var(--Space-x1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu ~ .menu {
|
|
||||||
padding-top: var(--Space-x2);
|
|
||||||
border-top: solid 1px var(--Border-Divider-Subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionHeader {
|
|
||||||
color: var(--UI-Text-Placeholder);
|
|
||||||
padding-left: var(--Space-x1);
|
|
||||||
padding-bottom: var(--Space-x05);
|
|
||||||
|
|
||||||
/* Due to Virtualizer we cannot use gap in .menu,
|
|
||||||
instead we use padding-top on each section header */
|
|
||||||
padding-top: var(--Space-x2);
|
|
||||||
/* Except for the first section header */
|
|
||||||
.menu > div > div:first-child & {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
display: block;
|
|
||||||
padding: var(--Space-x1);
|
|
||||||
border-radius: var(--Corner-radius-Medium);
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&[data-focused],
|
|
||||||
&[data-focus-visible],
|
|
||||||
&[data-selected],
|
|
||||||
&[data-hovered] {
|
|
||||||
background: var(--Base-Surface-Primary-light-Hover-alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.itemLabel {
|
|
||||||
display: block;
|
|
||||||
color: var(--UI-Text-High-contrast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.itemDescription {
|
|
||||||
color: var(--UI-Text-Placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
.noResultsLabel {
|
|
||||||
color: var(--Text-Default);
|
|
||||||
}
|
|
||||||
|
|
||||||
.noResultsDescription {
|
|
||||||
color: var(--Text-Tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menuDivider {
|
|
||||||
padding-top: var(--Space-x2);
|
|
||||||
padding-bottom: var(--Space-x2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menuDivider:before {
|
|
||||||
display: block;
|
|
||||||
content: "";
|
|
||||||
height: 1px;
|
|
||||||
background: var(--Border-Divider-Subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionsSection .item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: var(--Space-x05);
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
.inline {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
|
||||||
.inline {
|
|
||||||
display: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
import { useIsMounted, useMediaQuery } from "usehooks-ts"
|
|
||||||
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
|
||||||
|
|
||||||
import { isDefined } from "@/server/utils"
|
|
||||||
|
|
||||||
import { ClientInline } from "./ClientInline"
|
|
||||||
import { ClientModal } from "./ClientModal"
|
|
||||||
|
|
||||||
import styles from "./client.module.css"
|
|
||||||
|
|
||||||
import type {
|
|
||||||
JumpToData,
|
|
||||||
JumpToProps,
|
|
||||||
LocationMatchResults,
|
|
||||||
ScoringMatch,
|
|
||||||
} from "@/types/components/destinationOverviewPage/jumpTo"
|
|
||||||
import type { ClientProps } from "@/types/components/destinationOverviewPage/jumpTo/client"
|
|
||||||
|
|
||||||
export function JumpToClient<T extends JumpToData>({
|
|
||||||
data,
|
|
||||||
history,
|
|
||||||
onAction,
|
|
||||||
onClearHistory,
|
|
||||||
}: JumpToProps<T>) {
|
|
||||||
const intl = useIntl()
|
|
||||||
const isMounted = useIsMounted()
|
|
||||||
const displayInModal = useMediaQuery("(max-width: 767px)")
|
|
||||||
|
|
||||||
const [filterString, setFilterString] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const filter = useCallback(
|
|
||||||
(needle: string): LocationMatchResults => {
|
|
||||||
needle = needle.toLowerCase().trim().replace("scandic ", "")
|
|
||||||
|
|
||||||
// This algorithm ranks the location data set based on a ruleset. Each
|
|
||||||
// match is given a score to rank the results. Different rules give
|
|
||||||
// different scores. The lower the string matching index the higer the
|
|
||||||
// score. All matchings are done with lower case comparison.
|
|
||||||
//
|
|
||||||
// Ruleset, from higest to lower in ranking score:
|
|
||||||
//
|
|
||||||
// 1. Match on name. If no match on name, check cityIdentifier. This
|
|
||||||
// allows for cities that suffer from different spellings to have a change
|
|
||||||
// to get matched and ranked better.
|
|
||||||
//
|
|
||||||
// 2. Match on keywords. Only the highest ranking keyword is considered.
|
|
||||||
// This prevents keyword overloading and evens out the matches.
|
|
||||||
const matchesWithScore = data
|
|
||||||
.map<ScoringMatch | null>((item) => {
|
|
||||||
// Rank all the names and filter out those that don't rank at all
|
|
||||||
const nameScores = item.rankingNames
|
|
||||||
.map((v) => {
|
|
||||||
const index = v.indexOf(needle)
|
|
||||||
const score = index !== -1 ? 1000 - index : 0
|
|
||||||
return score
|
|
||||||
})
|
|
||||||
.filter((score) => score > 0)
|
|
||||||
|
|
||||||
// Calculate the highest ranking name
|
|
||||||
const bestNameScore = nameScores.length ? Math.max(...nameScores) : 0
|
|
||||||
|
|
||||||
// Rank all the keywords and filter out those that don't rank at all
|
|
||||||
const keywordScores = item.rankingKeywords
|
|
||||||
.map((v) => {
|
|
||||||
const index = v.indexOf(needle)
|
|
||||||
const score = index !== -1 ? 500 - index : 0
|
|
||||||
return score
|
|
||||||
})
|
|
||||||
.filter((score) => score > 0)
|
|
||||||
|
|
||||||
// Calculate the highest ranking keyword
|
|
||||||
const bestKeywordScore = keywordScores.length
|
|
||||||
? Math.max(...keywordScores)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const totalScore = bestNameScore + bestKeywordScore
|
|
||||||
|
|
||||||
return totalScore > 0
|
|
||||||
? {
|
|
||||||
id: item.id,
|
|
||||||
displayName: item.displayName,
|
|
||||||
type: item.type,
|
|
||||||
description: item.description,
|
|
||||||
url: item.url,
|
|
||||||
score: totalScore,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
})
|
|
||||||
.filter(isDefined)
|
|
||||||
.sort((a, b) => {
|
|
||||||
return b.score - a.score
|
|
||||||
})
|
|
||||||
|
|
||||||
if (matchesWithScore.length > 0) {
|
|
||||||
// Map matchesWithScore to build the final results of matches and
|
|
||||||
// remove the score from the output as it is not needed anymore
|
|
||||||
const matches = matchesWithScore
|
|
||||||
.map(({ score, ...item }) => item) // No need for score anymore
|
|
||||||
.reduce<LocationMatchResults>(
|
|
||||||
(acc, item) => {
|
|
||||||
// Do this verbosely because its helps TS understand the data flow better.
|
|
||||||
if (item.type === "cities") {
|
|
||||||
acc[0].children.push(item)
|
|
||||||
} else if (item.type === "hotels") {
|
|
||||||
acc[1].children.push(item)
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
},
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: "cities",
|
|
||||||
name: "Cities",
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "hotels",
|
|
||||||
name: "Hotels",
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Hide section that does not have any matches
|
|
||||||
return matches.filter((section) => section.children.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
[data]
|
|
||||||
)
|
|
||||||
|
|
||||||
const latest = useMemo(() => {
|
|
||||||
if (data && history && history.length) {
|
|
||||||
const children = history
|
|
||||||
.map((v) => {
|
|
||||||
return data.find((d) => d.id === v.id && d.type === v.type)
|
|
||||||
})
|
|
||||||
.filter(isDefined)
|
|
||||||
.slice(0, 5) // Only show five items
|
|
||||||
|
|
||||||
const results: LocationMatchResults = [
|
|
||||||
{
|
|
||||||
id: "latestSearches",
|
|
||||||
name: "Latest searches",
|
|
||||||
children: children,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions", // The string "Actions" converts into a divider
|
|
||||||
name: "Actions",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: "clearHistory",
|
|
||||||
type: "clearHistory",
|
|
||||||
closesModal: false,
|
|
||||||
icon: <MaterialIcon icon="delete" color="CurrentColor" />,
|
|
||||||
displayName: intl.formatMessage({
|
|
||||||
defaultMessage: "Clear searches",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}, [data, history, intl])
|
|
||||||
|
|
||||||
const results = useMemo(() => {
|
|
||||||
if (filterString) {
|
|
||||||
return filter(filterString)
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}, [filterString, filter])
|
|
||||||
|
|
||||||
const props: ClientProps = useMemo(() => {
|
|
||||||
return {
|
|
||||||
results,
|
|
||||||
latest,
|
|
||||||
setFilterString,
|
|
||||||
onAction: (key) => {
|
|
||||||
switch (key) {
|
|
||||||
case "clearHistory":
|
|
||||||
onClearHistory()
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
onAction(key)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}, [results, latest, setFilterString, onAction, onClearHistory])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.modal} hidden={isMounted() && !displayInModal}>
|
|
||||||
<ClientModal {...props} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.inline} hidden={isMounted() && displayInModal}>
|
|
||||||
<ClientInline {...props} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { use } from "react"
|
|
||||||
|
|
||||||
import { useSearchHistory } from "@/components/Forms/BookingWidget/FormContent/Search/useSearchHistory"
|
|
||||||
|
|
||||||
import { JumpToClient } from "../Client"
|
|
||||||
|
|
||||||
import type { JumpToResolverProps } from "@/types/components/destinationOverviewPage/jumpTo/resolver"
|
|
||||||
|
|
||||||
export function JumpToResolver({ dataPromise }: JumpToResolverProps) {
|
|
||||||
const data = use(dataPromise)
|
|
||||||
|
|
||||||
const { searchHistory, insertSearchHistoryItem, clearHistory } =
|
|
||||||
useSearchHistory()
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<JumpToClient
|
|
||||||
data={data}
|
|
||||||
history={searchHistory}
|
|
||||||
onAction={(key) => {
|
|
||||||
const item = data.find((d) => d.id === key)
|
|
||||||
if (!item) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
insertSearchHistoryItem({
|
|
||||||
id: item.id,
|
|
||||||
name: item.displayName,
|
|
||||||
type: item.type,
|
|
||||||
searchTokens: [],
|
|
||||||
destination: item.description,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onClearHistory={() => {
|
|
||||||
clearHistory()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,63 @@
|
|||||||
import { getJumpToData } from "@/lib/trpc/memoizedRequests"
|
"use client"
|
||||||
|
|
||||||
import { JumpToResolver } from "@/components/ContentType/DestinationPage/DestinationOverviewPage/JumpTo/Resolver"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import * as Sentry from "@sentry/nextjs"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
export async function JumpTo() {
|
import { Search } from "@/components/Forms/BookingWidget/FormContent/Search"
|
||||||
const dataPromise = getJumpToData()
|
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||||
|
|
||||||
return <JumpToResolver dataPromise={dataPromise} />
|
const jumpToSchema = z.object({
|
||||||
|
destinationSearch: z.string().min(1, "Please enter a search term"),
|
||||||
|
})
|
||||||
|
type JumpToSchema = z.infer<typeof jumpToSchema>
|
||||||
|
|
||||||
|
export function JumpTo() {
|
||||||
|
const router = useRouter()
|
||||||
|
const intl = useIntl()
|
||||||
|
const methods = useForm<JumpToSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
destinationSearch: "",
|
||||||
|
},
|
||||||
|
shouldFocusError: false,
|
||||||
|
mode: "onSubmit",
|
||||||
|
resolver: zodResolver(jumpToSchema),
|
||||||
|
reValidateMode: "onSubmit",
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<Search
|
||||||
|
variant="rounded"
|
||||||
|
handlePressEnter={() => {
|
||||||
|
void 0
|
||||||
|
}}
|
||||||
|
inputName={"destinationSearch"}
|
||||||
|
onSelect={(item) => {
|
||||||
|
if (!item.url) {
|
||||||
|
Sentry.captureMessage(
|
||||||
|
"Unable to JumpTo selected location, no URL provided",
|
||||||
|
{
|
||||||
|
extra: { locationName: item.name },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
toast.error(
|
||||||
|
intl.formatMessage(
|
||||||
|
{ defaultMessage: "Unable to open page for {locationName}" },
|
||||||
|
{ locationName: item.name }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(item.url)
|
||||||
|
}}
|
||||||
|
withSearchButton
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.searchContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--Corner-radius-Rounded);
|
||||||
|
border: 1px solid var(--Border-Default);
|
||||||
|
background: var(--Surface-Primary-Default);
|
||||||
|
padding: var(--Space-x15) var(--Space-x15) var(--Space-x15) var(--Space-x3);
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,12 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 610px;
|
height: 610px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
.mapContainer {
|
|
||||||
height: 580px;
|
height: 580px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@media screen and (min-width: 1367px) {
|
@media screen and (min-width: 1367px) {
|
||||||
.mapContainer {
|
|
||||||
height: 560px;
|
height: 560px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,14 +35,25 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jumpToContainer {
|
.headerContainer {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: var(--Space-x4);
|
gap: var(--Space-x4);
|
||||||
padding: var(--Space-x4) var(--Space-x2);
|
padding: var(--Space-x4) var(--Space-x2);
|
||||||
background: var(--Surface-Secondary-Default);
|
background: var(--Surface-Secondary-Default);
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
align-items: center;
|
||||||
color: var(--Text-Interactive-Default);
|
|
||||||
text-align: center;
|
.heading {
|
||||||
|
color: var(--Text-Interactive-Default);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jumpToContainer {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ export default async function DestinationOverviewPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.jumpToContainer}>
|
<div className={styles.headerContainer}>
|
||||||
<Typography variant="Title/lg">
|
<Typography variant="Title/lg">
|
||||||
<h1 className={styles.heading}>{destinationOverviewPage.heading}</h1>
|
<h1 className={styles.heading}>{destinationOverviewPage.heading}</h1>
|
||||||
</Typography>
|
</Typography>
|
||||||
<JumpTo />
|
<div className={styles.jumpToContainer}>
|
||||||
|
<JumpTo />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.mapContainer}>
|
<div className={styles.mapContainer}>
|
||||||
<Suspense fallback={<SkeletonShimmer width="100%" height="100%" />}>
|
<Suspense fallback={<SkeletonShimmer width="100%" height="100%" />}>
|
||||||
|
|||||||
@@ -47,21 +47,13 @@ export function ListItemSkeleton() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={classNames}>
|
<li className={classNames}>
|
||||||
<Body
|
<div style={{ marginBottom: "0.25rem" }}>
|
||||||
color="black"
|
|
||||||
textTransform="bold"
|
|
||||||
style={{ marginBottom: "0.25rem" }}
|
|
||||||
>
|
|
||||||
<SkeletonShimmer width={"200px"} height="18px" display="block" />
|
<SkeletonShimmer width={"200px"} height="18px" display="block" />
|
||||||
</Body>
|
</div>
|
||||||
|
|
||||||
<Body
|
<div>
|
||||||
color="black"
|
|
||||||
textTransform="bold"
|
|
||||||
style={{ marginBottom: "0.25rem" }}
|
|
||||||
>
|
|
||||||
<SkeletonShimmer width={"70px"} height="18px" display="block" />
|
<SkeletonShimmer width={"70px"} height="18px" display="block" />
|
||||||
</Body>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect } from "react"
|
||||||
import { useFormContext } from "react-hook-form"
|
import { useFormContext } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
import { useDebounceValue } from "usehooks-ts"
|
import { useDebounceValue } from "usehooks-ts"
|
||||||
@@ -23,6 +23,7 @@ import styles from "./searchList.module.css"
|
|||||||
import type { SearchListProps } from "@/types/components/search"
|
import type { SearchListProps } from "@/types/components/search"
|
||||||
|
|
||||||
export default function SearchList({
|
export default function SearchList({
|
||||||
|
searchInputName,
|
||||||
getItemProps,
|
getItemProps,
|
||||||
getMenuProps,
|
getMenuProps,
|
||||||
handleClearSearchHistory,
|
handleClearSearchHistory,
|
||||||
@@ -33,14 +34,14 @@ export default function SearchList({
|
|||||||
}: SearchListProps) {
|
}: SearchListProps) {
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const [hasMounted, setHasMounted] = useState(false)
|
|
||||||
const {
|
const {
|
||||||
clearErrors,
|
clearErrors,
|
||||||
formState: { errors, isSubmitted },
|
formState: { errors, isSubmitted },
|
||||||
} = useFormContext()
|
} = useFormContext()
|
||||||
const searchError = errors["search"]
|
const searchError = errors[searchInputName]
|
||||||
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useDebounceValue(search, 250)
|
const [debouncedSearch, setDebouncedSearch] = useDebounceValue(search, 300)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDebouncedSearch(search)
|
setDebouncedSearch(search)
|
||||||
@@ -57,14 +58,14 @@ export default function SearchList({
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearErrors("search")
|
clearErrors(searchInputName)
|
||||||
}, [search, clearErrors])
|
}, [search, clearErrors, searchInputName])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
||||||
if (searchError) {
|
if (searchError) {
|
||||||
timeoutID = setTimeout(() => {
|
timeoutID = setTimeout(() => {
|
||||||
clearErrors("search")
|
clearErrors(searchInputName)
|
||||||
// magic number originates from animation
|
// magic number originates from animation
|
||||||
// 5000ms delay + 120ms exectuion
|
// 5000ms delay + 120ms exectuion
|
||||||
}, 5120)
|
}, 5120)
|
||||||
@@ -75,15 +76,7 @@ export default function SearchList({
|
|||||||
clearTimeout(timeoutID)
|
clearTimeout(timeoutID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [clearErrors, searchError])
|
}, [clearErrors, searchError, searchInputName])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasMounted(true)
|
|
||||||
}, [setHasMounted])
|
|
||||||
|
|
||||||
if (!hasMounted) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchError && isSubmitted && typeof searchError.message === "string") {
|
if (searchError && isSubmitted && typeof searchError.message === "string") {
|
||||||
if (searchError.message === "Required") {
|
if (searchError.message === "Required") {
|
||||||
@@ -166,7 +159,7 @@ export default function SearchList({
|
|||||||
"We couldn't find a matching location for your search.",
|
"We couldn't find a matching location for your search.",
|
||||||
})}
|
})}
|
||||||
</Body>
|
</Body>
|
||||||
{searchHistory && (
|
{searchHistory && searchHistory.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider className={styles.noResultsDivider} color="beige" />
|
<Divider className={styles.noResultsDivider} color="beige" />
|
||||||
<Footnote
|
<Footnote
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
import Downshift from "downshift"
|
import Downshift from "downshift"
|
||||||
import { type ChangeEvent, type FormEvent } from "react"
|
import { type ChangeEvent, type FormEvent } from "react"
|
||||||
import { useFormContext, useWatch } from "react-hook-form"
|
import { useFormContext, useWatch } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { type AutoCompleteLocation } from "@/server/routers/autocomplete/schema"
|
import { type AutoCompleteLocation } from "@/server/routers/autocomplete/schema"
|
||||||
|
|
||||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
|
||||||
|
|
||||||
import { Input } from "../Input"
|
import { Input } from "../Input"
|
||||||
import SearchList from "./SearchList"
|
import SearchList from "./SearchList"
|
||||||
@@ -15,15 +20,27 @@ import { useSearchHistory } from "./useSearchHistory"
|
|||||||
|
|
||||||
import styles from "./search.module.css"
|
import styles from "./search.module.css"
|
||||||
|
|
||||||
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
interface SearchProps {
|
||||||
import type { SearchProps } from "@/types/components/search"
|
className?: string
|
||||||
|
handlePressEnter: () => void
|
||||||
|
inputName: string
|
||||||
|
onSelect?: (selectedItem: AutoCompleteLocation) => void
|
||||||
|
variant?: "rounded" | "default"
|
||||||
|
withSearchButton?: boolean
|
||||||
|
selectOnBlur?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const SEARCH_TERM_NAME = "search"
|
export function Search({
|
||||||
|
handlePressEnter,
|
||||||
export default function Search({ handlePressEnter }: SearchProps) {
|
inputName: SEARCH_TERM_NAME,
|
||||||
const { register, setValue } = useFormContext<BookingWidgetSchema>()
|
onSelect,
|
||||||
|
variant,
|
||||||
|
withSearchButton = false,
|
||||||
|
selectOnBlur = false,
|
||||||
|
}: SearchProps) {
|
||||||
|
const { register, setValue, setFocus } = useFormContext()
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const searchTerm = useWatch({ name: SEARCH_TERM_NAME })
|
const searchTerm = useWatch({ name: SEARCH_TERM_NAME }) as string
|
||||||
const { searchHistory, insertSearchHistoryItem, clearHistory } =
|
const { searchHistory, insertSearchHistoryItem, clearHistory } =
|
||||||
useSearchHistory()
|
useSearchHistory()
|
||||||
|
|
||||||
@@ -47,6 +64,7 @@ export default function Search({ handlePressEnter }: SearchProps) {
|
|||||||
case "cities":
|
case "cities":
|
||||||
setValue("hotel", undefined)
|
setValue("hotel", undefined)
|
||||||
setValue("city", selectedItem.name)
|
setValue("city", selectedItem.name)
|
||||||
|
|
||||||
break
|
break
|
||||||
case "hotels":
|
case "hotels":
|
||||||
setValue("hotel", +selectedItem.id)
|
setValue("hotel", +selectedItem.id)
|
||||||
@@ -56,12 +74,21 @@ export default function Search({ handlePressEnter }: SearchProps) {
|
|||||||
console.error("Unhandled type:", selectedItem.type)
|
console.error("Unhandled type:", selectedItem.type)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSelect?.(selectedItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClearSearchHistory() {
|
function handleClearSearchHistory() {
|
||||||
clearHistory()
|
clearHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchInputClassName = searchInputVariants({
|
||||||
|
withSearchButton: withSearchButton,
|
||||||
|
})
|
||||||
|
const clearButtonClassName = clearButtonVariants({
|
||||||
|
visible: !!searchTerm?.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Downshift
|
<Downshift
|
||||||
inputValue={searchTerm}
|
inputValue={searchTerm}
|
||||||
@@ -78,48 +105,95 @@ export default function Search({ handlePressEnter }: SearchProps) {
|
|||||||
highlightedIndex,
|
highlightedIndex,
|
||||||
isOpen,
|
isOpen,
|
||||||
openMenu,
|
openMenu,
|
||||||
|
selectHighlightedItem,
|
||||||
}) => (
|
}) => (
|
||||||
<div className={styles.container}>
|
<div className={searchContainerVariants({ variant })}>
|
||||||
<label
|
<div className={styles.inputContainer}>
|
||||||
{...getLabelProps({ htmlFor: SEARCH_TERM_NAME })}
|
<label
|
||||||
className={styles.label}
|
{...getLabelProps({ htmlFor: SEARCH_TERM_NAME })}
|
||||||
>
|
className={labelVariants({
|
||||||
<Caption
|
color: !withSearchButton || isOpen ? "red" : "default",
|
||||||
type="bold"
|
})}
|
||||||
color={isOpen ? "uiTextActive" : "red"}
|
|
||||||
asChild
|
|
||||||
>
|
>
|
||||||
<span>
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
{intl.formatMessage({
|
<span>
|
||||||
defaultMessage: "Where to?",
|
{intl.formatMessage({ defaultMessage: "Where to?" })}
|
||||||
})}
|
</span>
|
||||||
</span>
|
</Typography>
|
||||||
</Caption>
|
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||||
</label>
|
<div className={searchInputClassName}>
|
||||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
<Input
|
||||||
<label className={styles.searchInput}>
|
{...getInputProps({
|
||||||
<Input
|
id: SEARCH_TERM_NAME,
|
||||||
{...getInputProps({
|
onFocus() {
|
||||||
id: SEARCH_TERM_NAME,
|
openMenu()
|
||||||
onFocus() {
|
},
|
||||||
openMenu()
|
placeholder: intl.formatMessage({
|
||||||
},
|
defaultMessage: "Hotels & Destinations",
|
||||||
placeholder: intl.formatMessage({
|
}),
|
||||||
defaultMessage: "Hotels & Destinations",
|
value: searchTerm,
|
||||||
}),
|
...register(SEARCH_TERM_NAME, {
|
||||||
value: searchTerm,
|
onChange: handleOnChange,
|
||||||
...register(SEARCH_TERM_NAME, {
|
onBlur: () => {
|
||||||
onChange: handleOnChange,
|
if (selectOnBlur) {
|
||||||
}),
|
selectHighlightedItem()
|
||||||
onKeyDown: (e) => {
|
}
|
||||||
if (e.key === "Enter" && !isOpen) {
|
},
|
||||||
handlePressEnter()
|
}),
|
||||||
}
|
onKeyDown: (e) => {
|
||||||
},
|
if (e.key === "Enter" && !isOpen) {
|
||||||
type: "search",
|
handlePressEnter()
|
||||||
})}
|
}
|
||||||
/>
|
},
|
||||||
|
type: "search",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
{withSearchButton && (
|
||||||
|
<div className={styles.searchButtonContainer}>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<Button
|
||||||
|
variant="Text"
|
||||||
|
size="Small"
|
||||||
|
onPress={() => {
|
||||||
|
setValue(SEARCH_TERM_NAME, "")
|
||||||
|
}}
|
||||||
|
className={clearButtonClassName}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ defaultMessage: "Clear" })}
|
||||||
|
</Button>
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
className={styles.searchButton}
|
||||||
|
variant="Primary"
|
||||||
|
size="Small"
|
||||||
|
type="submit"
|
||||||
|
onPress={() => {
|
||||||
|
if (!searchTerm) {
|
||||||
|
setFocus(SEARCH_TERM_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openMenu()
|
||||||
|
setTimeout(() => {
|
||||||
|
// This is a workaround to ensure that the menu is open before selecting the highlighted item
|
||||||
|
// Otherwise there is no highlighted item.
|
||||||
|
// Would need to keep track of the last highlighted item otherwise
|
||||||
|
selectHighlightedItem()
|
||||||
|
}, 0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<>
|
||||||
|
<MaterialIcon icon="search" color="CurrentColor" />
|
||||||
|
{intl.formatMessage({ defaultMessage: "Search" })}
|
||||||
|
</>
|
||||||
|
</Typography>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SearchList
|
<SearchList
|
||||||
getItemProps={getItemProps}
|
getItemProps={getItemProps}
|
||||||
@@ -129,6 +203,7 @@ export default function Search({ handlePressEnter }: SearchProps) {
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
search={searchTerm}
|
search={searchTerm}
|
||||||
searchHistory={searchHistory}
|
searchHistory={searchHistory}
|
||||||
|
searchInputName={SEARCH_TERM_NAME}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -141,13 +216,9 @@ export function SearchSkeleton() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.label}>
|
<div className={styles.label}>
|
||||||
<Caption type="bold" color="red" asChild>
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
<span>
|
<span>{intl.formatMessage({ defaultMessage: "Where to?" })}</span>
|
||||||
{intl.formatMessage({
|
</Typography>
|
||||||
defaultMessage: "Where to?",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Caption>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<SkeletonShimmer width={"100%"} display="block" height="16px" />
|
<SkeletonShimmer width={"100%"} display="block" height="16px" />
|
||||||
@@ -155,3 +226,48 @@ export function SearchSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchContainerVariants = cva(styles.container, {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "",
|
||||||
|
rounded: styles.rounded,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchInputVariants = cva(styles.searchInput, {
|
||||||
|
variants: {
|
||||||
|
withSearchButton: {
|
||||||
|
true: styles.withSearchButton,
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
withSearchButton: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearButtonVariants = cva(styles.clearButton, {
|
||||||
|
variants: {
|
||||||
|
visible: {
|
||||||
|
true: styles.clearButtonVisible,
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelVariants = cva(styles.label, {
|
||||||
|
variants: {
|
||||||
|
color: {
|
||||||
|
default: "",
|
||||||
|
red: styles.red,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
color: "default",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,34 +6,71 @@
|
|||||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
|
||||||
|
&.rounded {
|
||||||
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
padding: var(--Space-x15) var(--Space-x15) var(--Space-x15) var(--Space-x3);
|
||||||
|
border: 1px solid var(--Border-Intense);
|
||||||
|
border-radius: var(--Corner-radius-Rounded);
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:has(input:active, input:focus, input:focus-within) {
|
||||||
|
background-color: var(--Base-Background-Primary-Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(input:active, input:focus, input:focus-within) {
|
||||||
|
border-color: 1px solid var(--UI-Input-Controls-Border-Focus);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.container:hover,
|
.label {
|
||||||
.container:has(input:active, input:focus, input:focus-within) {
|
flex: 1;
|
||||||
background-color: var(--Base-Background-Primary-Normal);
|
&:has(
|
||||||
|
~ .inputContainer input:active,
|
||||||
|
~ .inputContainer input:focus,
|
||||||
|
~ .inputContainer input:focus-within
|
||||||
|
)
|
||||||
|
p {
|
||||||
|
color: var(--UI-Text-Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.red {
|
||||||
|
color: var(--Scandic-Brand-Scandic-Red);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.container:has(input:active, input:focus, input:focus-within) {
|
.searchButtonContainer {
|
||||||
border-color: 1px solid var(--UI-Input-Controls-Border-Focus);
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--Space-x05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.label:has(
|
.searchButton {
|
||||||
~ .inputContainer input:active,
|
display: flex;
|
||||||
~ .inputContainer input:focus,
|
align-items: center;
|
||||||
~ .inputContainer input:focus-within
|
gap: var(--Space-x05);
|
||||||
)
|
cursor: pointer;
|
||||||
p {
|
}
|
||||||
color: var(--UI-Text-Active);
|
|
||||||
|
.inputContainer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInput {
|
.searchInput {
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: var(--Spacing-x3) var(--Spacing-x-one-and-half) var(--Spacing-x-half);
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
||||||
@@ -42,4 +79,21 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.withSearchButton {
|
||||||
|
& input[type="search"]::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearButton {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.clearButtonVisible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AutoCompleteLocation,
|
type AutoCompleteLocation,
|
||||||
autoCompleteLocationSchema,
|
autoCompleteLocationSchema,
|
||||||
} from "@/server/routers/autocomplete/schema"
|
} from "@/server/routers/autocomplete/schema"
|
||||||
|
|
||||||
export const SEARCH_HISTORY_LOCALSTORAGE_KEY = "searchHistory"
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
export function useSearchHistory() {
|
export function useSearchHistory() {
|
||||||
const MAX_HISTORY_LENGTH = 5
|
const MAX_HISTORY_LENGTH = 5
|
||||||
|
const KEY = useSearchHistoryKey()
|
||||||
|
|
||||||
function getHistoryFromLocalStorage(): AutoCompleteLocation[] {
|
const getHistoryFromLocalStorage = useCallback((): AutoCompleteLocation[] => {
|
||||||
const stringifiedHistory = localStorage.getItem(
|
const stringifiedHistory = localStorage.getItem(KEY)
|
||||||
SEARCH_HISTORY_LOCALSTORAGE_KEY
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedHistory = JSON.parse(stringifiedHistory ?? "[]")
|
const parsedHistory = JSON.parse(stringifiedHistory ?? "[]")
|
||||||
@@ -26,14 +26,18 @@ export function useSearchHistory() {
|
|||||||
return existingHistory
|
return existingHistory
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to parse search history:", error)
|
console.error("Failed to parse search history:", error)
|
||||||
localStorage.removeItem(SEARCH_HISTORY_LOCALSTORAGE_KEY)
|
localStorage.removeItem(KEY)
|
||||||
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}, [KEY])
|
||||||
|
|
||||||
function updateSearchHistory(newItem: AutoCompleteLocation) {
|
function updateSearchHistory(newItem: AutoCompleteLocation) {
|
||||||
const existingHistory = getHistoryFromLocalStorage()
|
const existingHistory = getHistoryFromLocalStorage()
|
||||||
|
if (!autoCompleteLocationSchema.safeParse(newItem).success) {
|
||||||
|
return existingHistory
|
||||||
|
}
|
||||||
|
|
||||||
const oldSearchHistoryWithoutTheNew = existingHistory.filter(
|
const oldSearchHistoryWithoutTheNew = existingHistory.filter(
|
||||||
(h) => h.type !== newItem.type || h.id !== newItem.id
|
(h) => h.type !== newItem.type || h.id !== newItem.id
|
||||||
)
|
)
|
||||||
@@ -42,10 +46,7 @@ export function useSearchHistory() {
|
|||||||
newItem,
|
newItem,
|
||||||
...oldSearchHistoryWithoutTheNew.slice(0, MAX_HISTORY_LENGTH - 1),
|
...oldSearchHistoryWithoutTheNew.slice(0, MAX_HISTORY_LENGTH - 1),
|
||||||
]
|
]
|
||||||
localStorage.setItem(
|
localStorage.setItem(KEY, JSON.stringify(updatedSearchHistory))
|
||||||
SEARCH_HISTORY_LOCALSTORAGE_KEY,
|
|
||||||
JSON.stringify(updatedSearchHistory)
|
|
||||||
)
|
|
||||||
|
|
||||||
return updatedSearchHistory
|
return updatedSearchHistory
|
||||||
}
|
}
|
||||||
@@ -54,17 +55,19 @@ export function useSearchHistory() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchHistory(getHistoryFromLocalStorage())
|
setSearchHistory(getHistoryFromLocalStorage())
|
||||||
}, [])
|
}, [KEY, getHistoryFromLocalStorage])
|
||||||
|
|
||||||
function clearHistory() {
|
function clearHistory() {
|
||||||
localStorage.removeItem(SEARCH_HISTORY_LOCALSTORAGE_KEY)
|
localStorage.removeItem(KEY)
|
||||||
setSearchHistory([])
|
setSearchHistory([])
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertSearchHistoryItem(
|
function insertSearchHistoryItem(
|
||||||
newItem: AutoCompleteLocation
|
newItem: AutoCompleteLocation
|
||||||
): AutoCompleteLocation[] {
|
): AutoCompleteLocation[] {
|
||||||
const updatedHistory = updateSearchHistory(newItem)
|
const updatedHistory = updateSearchHistory(newItem)
|
||||||
setSearchHistory(updatedHistory)
|
setSearchHistory(updatedHistory)
|
||||||
|
|
||||||
return updatedHistory
|
return updatedHistory
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,3 +77,10 @@ export function useSearchHistory() {
|
|||||||
clearHistory,
|
clearHistory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useSearchHistoryKey() {
|
||||||
|
const SEARCH_HISTORY_LOCALSTORAGE_KEY = "searchHistory"
|
||||||
|
const lang = useLang()
|
||||||
|
|
||||||
|
return `${SEARCH_HISTORY_LOCALSTORAGE_KEY}-${lang}`.toLowerCase()
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import Button from "@/components/TempDesignSystem/Button"
|
|||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
|
||||||
import { RemoveExtraRooms } from "./BookingCode"
|
import { RemoveExtraRooms } from "./BookingCode"
|
||||||
import Search, { SearchSkeleton } from "./Search"
|
import { Search, SearchSkeleton } from "./Search"
|
||||||
import Voucher, { VoucherSkeleton } from "./Voucher"
|
import Voucher, { VoucherSkeleton } from "./Voucher"
|
||||||
|
|
||||||
import styles from "./formContent.module.css"
|
import styles from "./formContent.module.css"
|
||||||
@@ -41,7 +41,11 @@ export default function FormContent({
|
|||||||
<div className={styles.input}>
|
<div className={styles.input}>
|
||||||
<div className={styles.inputContainer}>
|
<div className={styles.inputContainer}>
|
||||||
<div className={styles.where}>
|
<div className={styles.where}>
|
||||||
<Search handlePressEnter={onSubmit} />
|
<Search
|
||||||
|
handlePressEnter={onSubmit}
|
||||||
|
selectOnBlur={true}
|
||||||
|
inputName="search"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.when}>
|
<div className={styles.when}>
|
||||||
<Caption color="red" type="bold">
|
<Caption color="red" type="bold">
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fetch-retry": "^6.0.0",
|
"fetch-retry": "^6.0.0",
|
||||||
"framer-motion": "^11.3.28",
|
"framer-motion": "^11.3.28",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"graphql": "^16.8.1",
|
"graphql": "^16.8.1",
|
||||||
"graphql-request": "^6.1.0",
|
"graphql-request": "^6.1.0",
|
||||||
"graphql-tag": "^2.12.6",
|
"graphql-tag": "^2.12.6",
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { z } from "zod"
|
|||||||
|
|
||||||
import { Lang } from "@/constants/languages"
|
import { Lang } from "@/constants/languages"
|
||||||
import { safeProtectedServiceProcedure } from "@/server/trpc"
|
import { safeProtectedServiceProcedure } from "@/server/trpc"
|
||||||
|
import { isDefined } from "@/server/utils"
|
||||||
|
|
||||||
import { getCacheClient } from "@/services/dataCache"
|
import { getCacheClient } from "@/services/dataCache"
|
||||||
|
import { safeTry } from "@/utils/safeTry"
|
||||||
|
|
||||||
|
import { getCityPageUrls } from "../contentstack/destinationCityPage/utils"
|
||||||
|
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
|
||||||
import { getCitiesByCountry, getCountries, getLocations } from "../hotels/utils"
|
import { getCitiesByCountry, getCountries, getLocations } from "../hotels/utils"
|
||||||
import { filterLocationByQuery } from "./util/filterLocationByQuery"
|
import { filterAndCategorizeAutoComplete } from "./util/filterAndCategorizeAutoComplete"
|
||||||
import { mapLocationToAutoCompleteLocation } from "./util/mapLocationToAutoCompleteLocation"
|
import { mapLocationToAutoCompleteLocation } from "./util/mapLocationToAutoCompleteLocation"
|
||||||
import { sortAutocompleteLocations } from "./util/sortAutocompleteLocations"
|
|
||||||
|
|
||||||
import type { AutoCompleteLocation } from "./schema"
|
import type { AutoCompleteLocation } from "./schema"
|
||||||
|
|
||||||
@@ -35,10 +38,13 @@ export const getDestinationsAutoCompleteRoute = safeProtectedServiceProcedure
|
|||||||
.query(async ({ ctx, input }): Promise<DestinationsAutoCompleteOutput> => {
|
.query(async ({ ctx, input }): Promise<DestinationsAutoCompleteOutput> => {
|
||||||
const cacheClient = await getCacheClient()
|
const cacheClient = await getCacheClient()
|
||||||
|
|
||||||
|
const lang = input.lang || ctx.lang
|
||||||
const locations: AutoCompleteLocation[] = await cacheClient.cacheOrGet(
|
const locations: AutoCompleteLocation[] = await cacheClient.cacheOrGet(
|
||||||
`autocomplete:destinations:locations:${input.lang}`,
|
`autocomplete:destinations:locations:${lang}`,
|
||||||
async () => {
|
async () => {
|
||||||
const lang = input.lang || ctx.lang
|
const hotelUrlsPromise = safeTry(getHotelPageUrls(lang))
|
||||||
|
const cityUrlsPromise = safeTry(getCityPageUrls(lang))
|
||||||
|
|
||||||
const countries = await getCountries({
|
const countries = await getCountries({
|
||||||
lang: lang,
|
lang: lang,
|
||||||
serviceToken: ctx.serviceToken,
|
serviceToken: ctx.serviceToken,
|
||||||
@@ -60,16 +66,44 @@ export const getDestinationsAutoCompleteRoute = safeProtectedServiceProcedure
|
|||||||
citiesByCountry: citiesByCountry,
|
citiesByCountry: citiesByCountry,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [hotelUrls, hotelUrlsError] = await hotelUrlsPromise
|
||||||
|
const [cityUrls, cityUrlsError] = await cityUrlsPromise
|
||||||
|
|
||||||
|
if (hotelUrlsError || cityUrlsError || !hotelUrls || !cityUrls) {
|
||||||
|
throw new Error("Unable to fetch location URLs")
|
||||||
|
}
|
||||||
|
|
||||||
return locations
|
return locations
|
||||||
|
.map((location) => {
|
||||||
|
let url: string | undefined
|
||||||
|
|
||||||
|
if (location.type === "cities") {
|
||||||
|
url = cityUrls.find(
|
||||||
|
(c) =>
|
||||||
|
c.city &&
|
||||||
|
location.cityIdentifier &&
|
||||||
|
c.city === location.cityIdentifier
|
||||||
|
)?.url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.type === "hotels") {
|
||||||
|
url = hotelUrls.find(
|
||||||
|
(h) => h.hotelId && location.id && h.hotelId === location.id
|
||||||
|
)?.url
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...location, url }
|
||||||
|
})
|
||||||
.map(mapLocationToAutoCompleteLocation)
|
.map(mapLocationToAutoCompleteLocation)
|
||||||
.filter(isDefined)
|
.filter(isDefined)
|
||||||
},
|
},
|
||||||
"1d"
|
"1d"
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredLocations = locations.filter((location) =>
|
const hits = filterAndCategorizeAutoComplete({
|
||||||
filterLocationByQuery({ location, query: input.query })
|
locations,
|
||||||
)
|
query: input.query,
|
||||||
|
})
|
||||||
|
|
||||||
const selectedHotel = locations.find(
|
const selectedHotel = locations.find(
|
||||||
(location) =>
|
(location) =>
|
||||||
@@ -81,21 +115,8 @@ export const getDestinationsAutoCompleteRoute = safeProtectedServiceProcedure
|
|||||||
location.type === "cities" && location.name === input.selectedCity
|
location.type === "cities" && location.name === input.selectedCity
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortedCities = sortAutocompleteLocations(
|
|
||||||
filteredLocations.filter(isCity),
|
|
||||||
input.query
|
|
||||||
)
|
|
||||||
|
|
||||||
const sortedHotels = sortAutocompleteLocations(
|
|
||||||
filteredLocations.filter(isHotel),
|
|
||||||
input.query
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hits: {
|
hits: hits,
|
||||||
cities: sortedCities,
|
|
||||||
hotels: sortedHotels,
|
|
||||||
},
|
|
||||||
currentSelection: {
|
currentSelection: {
|
||||||
city: isCity(selectedCity) ? selectedCity : null,
|
city: isCity(selectedCity) ? selectedCity : null,
|
||||||
hotel: isHotel(selectedHotel) ? selectedHotel : null,
|
hotel: isHotel(selectedHotel) ? selectedHotel : null,
|
||||||
@@ -114,9 +135,3 @@ function isCity(
|
|||||||
): location is AutoCompleteLocation & { type: "cities" } {
|
): location is AutoCompleteLocation & { type: "cities" } {
|
||||||
return !!location && location.type === "cities"
|
return !!location && location.type === "cities"
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDefined(
|
|
||||||
value: AutoCompleteLocation | null | undefined
|
|
||||||
): value is AutoCompleteLocation {
|
|
||||||
return !!value
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,5 +6,7 @@ export const autoCompleteLocationSchema = z.object({
|
|||||||
type: z.enum(["cities", "hotels"]),
|
type: z.enum(["cities", "hotels"]),
|
||||||
searchTokens: z.array(z.string()),
|
searchTokens: z.array(z.string()),
|
||||||
destination: z.string(),
|
destination: z.string(),
|
||||||
|
url: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AutoCompleteLocation = z.infer<typeof autoCompleteLocationSchema>
|
export type AutoCompleteLocation = z.infer<typeof autoCompleteLocationSchema>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { filterAutoCompleteLocations } from "./filterAutoCompleteLocations"
|
||||||
|
|
||||||
|
import type { AutoCompleteLocation } from "../schema"
|
||||||
|
|
||||||
|
export type DestinationsAutoCompleteOutput = {
|
||||||
|
hits: {
|
||||||
|
hotels: AutoCompleteLocation[]
|
||||||
|
cities: AutoCompleteLocation[]
|
||||||
|
}
|
||||||
|
currentSelection: {
|
||||||
|
hotel: (AutoCompleteLocation & { type: "hotels" }) | null
|
||||||
|
city: (AutoCompleteLocation & { type: "cities" }) | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterAndCategorizeAutoComplete({
|
||||||
|
locations,
|
||||||
|
query,
|
||||||
|
}: {
|
||||||
|
locations: AutoCompleteLocation[]
|
||||||
|
query: string
|
||||||
|
}) {
|
||||||
|
const rankedLocations = filterAutoCompleteLocations(locations, query)
|
||||||
|
|
||||||
|
const sortedCities = rankedLocations.filter(isCity)
|
||||||
|
const sortedHotels = rankedLocations.filter(isHotel)
|
||||||
|
|
||||||
|
return {
|
||||||
|
cities: sortedCities,
|
||||||
|
hotels: sortedHotels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHotel(
|
||||||
|
location: AutoCompleteLocation | null | undefined
|
||||||
|
): location is AutoCompleteLocation & { type: "hotels" } {
|
||||||
|
return !!location && location.type === "hotels"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCity(
|
||||||
|
location: AutoCompleteLocation | null | undefined
|
||||||
|
): location is AutoCompleteLocation & { type: "cities" } {
|
||||||
|
return !!location && location.type === "cities"
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { describe, expect, it } from "@jest/globals"
|
||||||
|
|
||||||
|
import { filterAutoCompleteLocations } from "./filterAutoCompleteLocations"
|
||||||
|
|
||||||
|
import type { DeepPartial } from "@/types/DeepPartial"
|
||||||
|
import type { AutoCompleteLocation } from "../schema"
|
||||||
|
|
||||||
|
describe("rankAutoCompleteLocations", () => {
|
||||||
|
it("should give no hits when the query does not match", () => {
|
||||||
|
const locations = [scandicAlborgOst] as DeepPartial<AutoCompleteLocation>[]
|
||||||
|
|
||||||
|
const query = "NonMatchingQuery"
|
||||||
|
const ranked = filterAutoCompleteLocations(
|
||||||
|
locations as AutoCompleteLocation[],
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ranked.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include items when the query matches parts of name", () => {
|
||||||
|
const locations = [scandicAlborgOst] as DeepPartial<AutoCompleteLocation>[]
|
||||||
|
|
||||||
|
const query = "Øst"
|
||||||
|
const ranked = filterAutoCompleteLocations(
|
||||||
|
locations as AutoCompleteLocation[],
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ranked.length).toBe(1)
|
||||||
|
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow multiple search terms", () => {
|
||||||
|
const locations = [scandicAlborgOst] as DeepPartial<AutoCompleteLocation>[]
|
||||||
|
|
||||||
|
const query = "Aalborg Øst"
|
||||||
|
const ranked = filterAutoCompleteLocations(
|
||||||
|
locations as AutoCompleteLocation[],
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ranked.length).toBe(1)
|
||||||
|
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should rank full word higher than part of word", () => {
|
||||||
|
const locations = [
|
||||||
|
scandicSyvSostre,
|
||||||
|
scandicAlborgOst,
|
||||||
|
] as DeepPartial<AutoCompleteLocation>[]
|
||||||
|
|
||||||
|
const query = "Øst"
|
||||||
|
const ranked = filterAutoCompleteLocations(
|
||||||
|
locations as AutoCompleteLocation[],
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ranked.length).toBe(2)
|
||||||
|
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
|
||||||
|
expect(ranked.at(1)!.name).toEqual("Scandic Syv Søstre")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should ignore items without match", () => {
|
||||||
|
const locations = [
|
||||||
|
scandicSyvSostre,
|
||||||
|
scandicAlborgOst,
|
||||||
|
berlinLodge,
|
||||||
|
scandicBrennemoen,
|
||||||
|
] as DeepPartial<AutoCompleteLocation>[]
|
||||||
|
|
||||||
|
const query = "Øst"
|
||||||
|
const ranked = filterAutoCompleteLocations(
|
||||||
|
locations as AutoCompleteLocation[],
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ranked.length).toBe(3)
|
||||||
|
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
|
||||||
|
expect(ranked.at(1)!.name).toEqual("Scandic Syv Søstre")
|
||||||
|
expect(ranked.at(2)!.name).toEqual("Scandic Brennemoen")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should ignore 'scandic' from name and destination when searching", () => {
|
||||||
|
const locations = [scandicAlborgOst, scandicBrennemoen].map((x) => ({
|
||||||
|
...x,
|
||||||
|
searchTokens: [],
|
||||||
|
})) as DeepPartial<AutoCompleteLocation>[]
|
||||||
|
|
||||||
|
const query = "scandic"
|
||||||
|
const ranked = filterAutoCompleteLocations(
|
||||||
|
locations as AutoCompleteLocation[],
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ranked.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should get hits for destination", () => {
|
||||||
|
const locations = [
|
||||||
|
scandicAlborgOst,
|
||||||
|
scandicBrennemoen,
|
||||||
|
] as DeepPartial<AutoCompleteLocation>[]
|
||||||
|
|
||||||
|
const query = "Mysen"
|
||||||
|
const ranked = filterAutoCompleteLocations(
|
||||||
|
locations as AutoCompleteLocation[],
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ranked.length).toBe(1)
|
||||||
|
expect(ranked.at(0)!.name).toBe("Scandic Brennemoen")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should get hits for searchTokens", () => {
|
||||||
|
const locations = [
|
||||||
|
scandicAlborgOst,
|
||||||
|
scandicBrennemoen,
|
||||||
|
] as DeepPartial<AutoCompleteLocation>[]
|
||||||
|
|
||||||
|
const query = "tusenfryd"
|
||||||
|
const ranked = filterAutoCompleteLocations(
|
||||||
|
locations as AutoCompleteLocation[],
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ranked.length).toBe(1)
|
||||||
|
expect(ranked.at(0)!.name).toBe("Scandic Brennemoen")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should match when using the wrong aumlaut ö -> ø", () => {
|
||||||
|
const locations = [scandicBodo] as DeepPartial<AutoCompleteLocation>[]
|
||||||
|
|
||||||
|
const query = "bodö"
|
||||||
|
const ranked = filterAutoCompleteLocations(
|
||||||
|
locations as AutoCompleteLocation[],
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ranked.length).toBe(1)
|
||||||
|
expect(ranked.at(0)!.name).toBe("Scandic Bodø")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const scandicAlborgOst: DeepPartial<AutoCompleteLocation> = {
|
||||||
|
name: "Scandic Aalborg Øst",
|
||||||
|
destination: "Aalborg",
|
||||||
|
searchTokens: [
|
||||||
|
"aalborg",
|
||||||
|
"aalborg øst",
|
||||||
|
"scandic aalborg øst",
|
||||||
|
"aalborg ost",
|
||||||
|
"scandic aalborg ost",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const scandicBrennemoen: DeepPartial<AutoCompleteLocation> = {
|
||||||
|
name: "Scandic Brennemoen",
|
||||||
|
destination: "Mysen",
|
||||||
|
searchTokens: [
|
||||||
|
"mysen",
|
||||||
|
"askim",
|
||||||
|
"indre østfold",
|
||||||
|
"drøbak",
|
||||||
|
"slitu",
|
||||||
|
"morenen",
|
||||||
|
"østfoldbadet",
|
||||||
|
"tusenfryd",
|
||||||
|
"brennemoen",
|
||||||
|
"scandic brennemoen",
|
||||||
|
"indre ostfold",
|
||||||
|
"drobak",
|
||||||
|
"ostfoldbadet",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const scandicSyvSostre: DeepPartial<AutoCompleteLocation> = {
|
||||||
|
name: "Scandic Syv Søstre",
|
||||||
|
destination: "Sandnessjoen",
|
||||||
|
searchTokens: [
|
||||||
|
"syv sostre",
|
||||||
|
"sandnessjoen",
|
||||||
|
"sandnessjøen",
|
||||||
|
"syv søstre",
|
||||||
|
"scandic syv søstre",
|
||||||
|
"scandic syv sostre",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const berlinLodge: DeepPartial<AutoCompleteLocation> = {
|
||||||
|
name: "Berlin Lodge",
|
||||||
|
searchTokens: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const scandicBodo: DeepPartial<AutoCompleteLocation> = {
|
||||||
|
name: "Scandic Bodø",
|
||||||
|
destination: "Bodo",
|
||||||
|
searchTokens: [
|
||||||
|
"bodo",
|
||||||
|
"kjerringoy",
|
||||||
|
"bodo i vinden",
|
||||||
|
"visit bodo",
|
||||||
|
"badin",
|
||||||
|
"scandic bodo",
|
||||||
|
"bodø",
|
||||||
|
"stormen",
|
||||||
|
"midnattsol",
|
||||||
|
"hurtigruten",
|
||||||
|
"saltstraumen",
|
||||||
|
"nord universitet",
|
||||||
|
"kjerringøy",
|
||||||
|
"nordlys",
|
||||||
|
"tuvsjyen",
|
||||||
|
"stella polaris",
|
||||||
|
"topptur",
|
||||||
|
"svartisen",
|
||||||
|
"polarsirkelen",
|
||||||
|
"aurora borealis",
|
||||||
|
"bodø i vinden",
|
||||||
|
"visit bodø",
|
||||||
|
"bådin",
|
||||||
|
"norsk luftfartsmuseum",
|
||||||
|
"rib",
|
||||||
|
"scandic bodø",
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Fuse from "fuse.js"
|
||||||
|
|
||||||
|
import type { AutoCompleteLocation } from "../schema"
|
||||||
|
|
||||||
|
type SearchableAutoCompleteLocation = AutoCompleteLocation & {
|
||||||
|
nameTokens: string[]
|
||||||
|
destinationTokens: string[]
|
||||||
|
}
|
||||||
|
type SearchableKey = keyof SearchableAutoCompleteLocation
|
||||||
|
|
||||||
|
const fuseConfig = new Fuse([] as SearchableAutoCompleteLocation[], {
|
||||||
|
minMatchCharLength: 2,
|
||||||
|
isCaseSensitive: false,
|
||||||
|
ignoreDiacritics: true,
|
||||||
|
includeMatches: true,
|
||||||
|
includeScore: true,
|
||||||
|
threshold: 0.2,
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
name: "nameTokens" satisfies SearchableKey,
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "destinationTokens" satisfies SearchableKey,
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "searchTokens" satisfies SearchableKey,
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export function filterAutoCompleteLocations<T extends AutoCompleteLocation>(
|
||||||
|
locations: T[],
|
||||||
|
query: string
|
||||||
|
) {
|
||||||
|
const searchable = locations.map((x) => ({
|
||||||
|
...x,
|
||||||
|
nameTokens: extractTokens(x.name),
|
||||||
|
destinationTokens: extractTokens(x.destination),
|
||||||
|
}))
|
||||||
|
fuseConfig.setCollection(searchable)
|
||||||
|
|
||||||
|
const searchResults = fuseConfig.search(query, { limit: 50 })
|
||||||
|
|
||||||
|
return searchResults.map(
|
||||||
|
(x) =>
|
||||||
|
({
|
||||||
|
id: x.item.id,
|
||||||
|
name: x.item.name,
|
||||||
|
destination: x.item.destination,
|
||||||
|
searchTokens: x.item.searchTokens,
|
||||||
|
type: x.item.type,
|
||||||
|
url: x.item.url,
|
||||||
|
}) satisfies AutoCompleteLocation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTokens(value: string): string[] {
|
||||||
|
const cleaned = value?.toLowerCase().replaceAll("scandic", "").trim() ?? ""
|
||||||
|
const output = [...new Set([cleaned, ...cleaned.split(" ")])]
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { describe, expect, it } from "@jest/globals"
|
|
||||||
|
|
||||||
import { filterLocationByQuery } from "./filterLocationByQuery"
|
|
||||||
|
|
||||||
import type { DeepPartial } from "@/types/DeepPartial"
|
|
||||||
import type { AutoCompleteLocation } from "../schema"
|
|
||||||
|
|
||||||
describe("filterLocationByQuery", () => {
|
|
||||||
it("should return false if the query is too short", () => {
|
|
||||||
const location: DeepPartial<AutoCompleteLocation> = {
|
|
||||||
searchTokens: ["beach", "luxury"],
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(
|
|
||||||
filterLocationByQuery({
|
|
||||||
location: location as AutoCompleteLocation,
|
|
||||||
query: " a ",
|
|
||||||
})
|
|
||||||
).toBe(false)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
filterLocationByQuery({
|
|
||||||
location: location as AutoCompleteLocation,
|
|
||||||
query: " ",
|
|
||||||
})
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should return true if one of the search tokens includes part of a valid query token", () => {
|
|
||||||
const location: DeepPartial<AutoCompleteLocation> = {
|
|
||||||
searchTokens: ["beach", "grand hotel", "stockholm"],
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(
|
|
||||||
filterLocationByQuery({
|
|
||||||
location: location as AutoCompleteLocation,
|
|
||||||
query: "Bea",
|
|
||||||
})
|
|
||||||
).toBe(true)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
filterLocationByQuery({
|
|
||||||
location: location as AutoCompleteLocation,
|
|
||||||
query: "hotel",
|
|
||||||
})
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should return false if none of the search tokens include a valid query token", () => {
|
|
||||||
const location: DeepPartial<AutoCompleteLocation> = {
|
|
||||||
searchTokens: ["beach", "grand hotel", "stockholm"],
|
|
||||||
}
|
|
||||||
expect(
|
|
||||||
filterLocationByQuery({
|
|
||||||
location: location as AutoCompleteLocation,
|
|
||||||
query: "xyz",
|
|
||||||
})
|
|
||||||
).toBe(false)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
filterLocationByQuery({
|
|
||||||
location: location as AutoCompleteLocation,
|
|
||||||
query: "garbage",
|
|
||||||
})
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should correctly handle queries with punctuation and extra spaces", () => {
|
|
||||||
const location: DeepPartial<AutoCompleteLocation> = {
|
|
||||||
searchTokens: ["grand hotel", "stockholm"],
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(
|
|
||||||
filterLocationByQuery({
|
|
||||||
location: location as AutoCompleteLocation,
|
|
||||||
query: " Grand Hotel! ",
|
|
||||||
})
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should work with queries containing multiple valid tokens", () => {
|
|
||||||
const location: DeepPartial<AutoCompleteLocation> = {
|
|
||||||
searchTokens: ["beach", "luxury", "grand hotel", "stockholm"],
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(
|
|
||||||
filterLocationByQuery({
|
|
||||||
location: location as AutoCompleteLocation,
|
|
||||||
query: "luxury beach",
|
|
||||||
})
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { AutoCompleteLocation } from "../schema"
|
|
||||||
|
|
||||||
export function filterLocationByQuery({
|
|
||||||
location,
|
|
||||||
query,
|
|
||||||
}: {
|
|
||||||
location: AutoCompleteLocation
|
|
||||||
query: string
|
|
||||||
}) {
|
|
||||||
const queryable = query
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^A-Za-zÀ-ÖØ-öø-ÿ0-9\s]/g, "") // Only keep alphanumeric characters and it's accents
|
|
||||||
.substring(0, 30)
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((s) => s.length > 2)
|
|
||||||
|
|
||||||
if (queryable.length === 0) return false
|
|
||||||
|
|
||||||
return location.searchTokens?.some((token) =>
|
|
||||||
queryable.some((q) => token.toLowerCase().includes(q))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { normalizeAumlauts } from "./normalizeAumlauts"
|
||||||
|
|
||||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||||
|
|
||||||
export function getSearchTokens(location: Location) {
|
export function getSearchTokens(location: Location) {
|
||||||
@@ -11,25 +13,8 @@ export function getSearchTokens(location: Location) {
|
|||||||
.filter(hasValue)
|
.filter(hasValue)
|
||||||
.map((x) => x.toLocaleLowerCase())
|
.map((x) => x.toLocaleLowerCase())
|
||||||
|
|
||||||
const additionalTokens: string[] = []
|
const normalizedTokens = normalizeAumlauts(tokens)
|
||||||
|
return normalizedTokens
|
||||||
tokens.forEach((token) => {
|
|
||||||
const replaced = token
|
|
||||||
.replace(/å/g, "a")
|
|
||||||
.replace(/ä/g, "a")
|
|
||||||
.replace(/ö/g, "o")
|
|
||||||
.replace(/æ/g, "a")
|
|
||||||
.replace(/ø/g, "o")
|
|
||||||
.replace(/é/g, "e")
|
|
||||||
.replace(/ü/g, "u")
|
|
||||||
if (replaced !== token) {
|
|
||||||
additionalTokens.push(replaced)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const allTokens = [...new Set([...tokens, ...additionalTokens])]
|
|
||||||
|
|
||||||
return allTokens
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasValue(value: string | null | undefined): value is string {
|
function hasValue(value: string | null | undefined): value is string {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Location } from "@/types/trpc/routers/hotel/locations"
|
|||||||
import type { AutoCompleteLocation } from "../schema"
|
import type { AutoCompleteLocation } from "../schema"
|
||||||
|
|
||||||
export function mapLocationToAutoCompleteLocation(
|
export function mapLocationToAutoCompleteLocation(
|
||||||
location: Location | null | undefined
|
location: (Location & { url?: string }) | null | undefined
|
||||||
): AutoCompleteLocation | null {
|
): AutoCompleteLocation | null {
|
||||||
if (!location) return null
|
if (!location) return null
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ export function mapLocationToAutoCompleteLocation(
|
|||||||
id: location.id,
|
id: location.id,
|
||||||
name: location.name,
|
name: location.name,
|
||||||
type: location.type,
|
type: location.type,
|
||||||
|
url: location.url,
|
||||||
searchTokens: getSearchTokens(location),
|
searchTokens: getSearchTokens(location),
|
||||||
destination:
|
destination:
|
||||||
location.type === "hotels"
|
location.type === "hotels"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function normalizeAumlauts(terms: string[]): string[] {
|
||||||
|
const additionalTerms: string[] = []
|
||||||
|
|
||||||
|
terms.forEach((token) => {
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const replaced = token
|
||||||
|
.replace(/å/g, "a")
|
||||||
|
.replace(/ä/g, "a")
|
||||||
|
.replace(/ö/g, "o")
|
||||||
|
.replace(/ø/g, "o")
|
||||||
|
.replace(/æ/g, "a")
|
||||||
|
.replace(/é/g, "e")
|
||||||
|
.replace(/ü/g, "u")
|
||||||
|
|
||||||
|
if (replaced !== token) {
|
||||||
|
additionalTerms.push(replaced)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...new Set([...additionalTerms, ...terms])]
|
||||||
|
}
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { describe, expect, it } from "@jest/globals"
|
|
||||||
|
|
||||||
import { sortAutocompleteLocations } from "./sortAutocompleteLocations"
|
|
||||||
|
|
||||||
import type { DeepPartial } from "@/types/DeepPartial"
|
|
||||||
import type { AutoCompleteLocation } from "../schema"
|
|
||||||
|
|
||||||
describe("sortAutocompleteLocations", () => {
|
|
||||||
it("should put locations with names starting with the query at the top", () => {
|
|
||||||
const locations: DeepPartial<AutoCompleteLocation>[] = [
|
|
||||||
{ name: "Paris Hotel" },
|
|
||||||
{ name: "London Inn" },
|
|
||||||
{ name: "paradise Resort" },
|
|
||||||
{ name: "Berlin Lodge" },
|
|
||||||
]
|
|
||||||
const query = "par"
|
|
||||||
|
|
||||||
const sorted = sortAutocompleteLocations(
|
|
||||||
locations as AutoCompleteLocation[],
|
|
||||||
query
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(sorted.map((loc) => loc.name)).toEqual([
|
|
||||||
"paradise Resort",
|
|
||||||
"Paris Hotel",
|
|
||||||
"Berlin Lodge",
|
|
||||||
"London Inn",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should sort locations alphabetically if both start with the query", () => {
|
|
||||||
const locations: DeepPartial<AutoCompleteLocation>[] = [
|
|
||||||
{ name: "Alpha Place" },
|
|
||||||
{ name: "alphabet City" },
|
|
||||||
]
|
|
||||||
const query = "al"
|
|
||||||
const sorted = sortAutocompleteLocations(
|
|
||||||
locations as AutoCompleteLocation[],
|
|
||||||
query
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(sorted.map((loc) => loc.name)).toEqual([
|
|
||||||
"Alpha Place",
|
|
||||||
"alphabet City",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should sort locations alphabetically if neither name starts with the query", () => {
|
|
||||||
const locations: DeepPartial<AutoCompleteLocation>[] = [
|
|
||||||
{ name: "Zenith" },
|
|
||||||
{ name: "apple orchard" },
|
|
||||||
{ name: "Mountain Retreat" },
|
|
||||||
]
|
|
||||||
const query = "xyz"
|
|
||||||
const sorted = sortAutocompleteLocations(
|
|
||||||
locations as AutoCompleteLocation[],
|
|
||||||
query
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(sorted.map((loc) => loc.name)).toEqual([
|
|
||||||
"apple orchard",
|
|
||||||
"Mountain Retreat",
|
|
||||||
"Zenith",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should handle an empty query by sorting alphabetically", () => {
|
|
||||||
const locations: DeepPartial<AutoCompleteLocation>[] = [
|
|
||||||
{ name: "Delta" },
|
|
||||||
{ name: "Alpha" },
|
|
||||||
{ name: "Charlie" },
|
|
||||||
{ name: "Bravo" },
|
|
||||||
]
|
|
||||||
const query = ""
|
|
||||||
const sorted = sortAutocompleteLocations(
|
|
||||||
locations as AutoCompleteLocation[],
|
|
||||||
query
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(sorted.map((loc) => loc.name)).toEqual([
|
|
||||||
"Alpha",
|
|
||||||
"Bravo",
|
|
||||||
"Charlie",
|
|
||||||
"Delta",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be case-insensitive when sorting names", () => {
|
|
||||||
const locations: DeepPartial<AutoCompleteLocation>[] = [
|
|
||||||
{ name: "Mountain Cabin" },
|
|
||||||
{ name: "Beachside Villa" },
|
|
||||||
{ name: "beach House" },
|
|
||||||
]
|
|
||||||
const query = "beach"
|
|
||||||
const sorted = sortAutocompleteLocations(
|
|
||||||
locations as AutoCompleteLocation[],
|
|
||||||
query
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(sorted.map((x) => x.name)).toEqual([
|
|
||||||
"beach House",
|
|
||||||
"Beachside Villa",
|
|
||||||
"Mountain Cabin",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { AutoCompleteLocation } from "../schema"
|
|
||||||
|
|
||||||
export function sortAutocompleteLocations<T extends AutoCompleteLocation>(
|
|
||||||
locations: T[],
|
|
||||||
query: string
|
|
||||||
) {
|
|
||||||
return locations.toSorted((a, b) => {
|
|
||||||
const queryLower = query.toLowerCase()
|
|
||||||
const aStarts = a.name.toLowerCase().startsWith(queryLower)
|
|
||||||
const bStarts = b.name.toLowerCase().startsWith(queryLower)
|
|
||||||
|
|
||||||
if (aStarts && !bStarts) return -1
|
|
||||||
if (!aStarts && bStarts) return 1
|
|
||||||
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,7 @@ export interface SearchListProps {
|
|||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
handleClearSearchHistory: () => void
|
handleClearSearchHistory: () => void
|
||||||
highlightedIndex: HighlightedIndex
|
highlightedIndex: HighlightedIndex
|
||||||
|
searchInputName: string
|
||||||
search: string
|
search: string
|
||||||
searchHistory: AutoCompleteLocation[] | null
|
searchHistory: AutoCompleteLocation[] | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6430,6 +6430,7 @@ __metadata:
|
|||||||
fast-deep-equal: "npm:^3.1.3"
|
fast-deep-equal: "npm:^3.1.3"
|
||||||
fetch-retry: "npm:^6.0.0"
|
fetch-retry: "npm:^6.0.0"
|
||||||
framer-motion: "npm:^11.3.28"
|
framer-motion: "npm:^11.3.28"
|
||||||
|
fuse.js: "npm:^7.1.0"
|
||||||
graphql: "npm:^16.8.1"
|
graphql: "npm:^16.8.1"
|
||||||
graphql-request: "npm:^6.1.0"
|
graphql-request: "npm:^6.1.0"
|
||||||
graphql-tag: "npm:^2.12.6"
|
graphql-tag: "npm:^2.12.6"
|
||||||
@@ -13006,6 +13007,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"fuse.js@npm:^7.1.0":
|
||||||
|
version: 7.1.0
|
||||||
|
resolution: "fuse.js@npm:7.1.0"
|
||||||
|
checksum: 10c0/c0d1b1d192a4bdf3eade897453ddd28aff96b70bf3e49161a45880f9845ebaee97265595db633776700a5bcf8942223c752754a848d70c508c3c9fd997faad1e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"gensync@npm:^1.0.0-beta.2":
|
"gensync@npm:^1.0.0-beta.2":
|
||||||
version: 1.0.0-beta.2
|
version: 1.0.0-beta.2
|
||||||
resolution: "gensync@npm:1.0.0-beta.2"
|
resolution: "gensync@npm:1.0.0-beta.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user