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:
Joakim Jäderberg
2025-04-17 06:39:42 +00:00
parent 8c0597727b
commit b98d6c10c0
35 changed files with 797 additions and 1602 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +0,0 @@
.inline {
display: none;
}
@media screen and (min-width: 768px) {
.inline {
display: unset;
}
.modal {
display: none;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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%" />}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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ø",
],
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
])
})
})

View File

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

View File

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

View File

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