feat(SW-1446): add Jump to functionality to Destination Overview Page

This commit is contained in:
Michael Zetterberg
2025-03-19 06:56:38 +01:00
parent 85a90baa12
commit 9e84da45bc
44 changed files with 3069 additions and 1297 deletions

View File

@@ -15,6 +15,7 @@ import CookieBotConsent from "@/components/CookieBot"
import Footer from "@/components/Footer"
import Header from "@/components/Header"
import StorageCleaner from "@/components/HotelReservation/EnterDetails/StorageCleaner"
import { RACRouterProvider } from "@/components/RACRouterProvider"
import SitewideAlert from "@/components/SitewideAlert"
import { ToastHandler } from "@/components/TempDesignSystem/Toasts"
import AdobeSDKScript from "@/components/TrackingSDK/AdobeSDKScript"
@@ -56,27 +57,31 @@ export default async function RootLayout({
`}</Script>
</head>
<body className="scandic">
<SessionProvider basePath="/api/web/auth">
<ClientIntlProvider
defaultLocale={Lang.en}
locale={params.lang}
messages={messages}
>
<TrpcProvider>
<RouterTracking />
<SitewideAlert />
<Header />
{bookingwidget}
{children}
<Footer />
<ToastHandler />
<SessionRefresher />
<StorageCleaner />
<CookieBotConsent />
<ReactQueryDevtools initialIsOpen={false} />
</TrpcProvider>
</ClientIntlProvider>
</SessionProvider>
<div className="root">
<SessionProvider basePath="/api/web/auth">
<ClientIntlProvider
defaultLocale={Lang.en}
locale={params.lang}
messages={messages}
>
<TrpcProvider>
<RACRouterProvider>
<RouterTracking />
<SitewideAlert />
<Header />
{bookingwidget}
{children}
<Footer />
<ToastHandler />
<SessionRefresher />
<StorageCleaner />
<CookieBotConsent />
<ReactQueryDevtools initialIsOpen={false} />
</RACRouterProvider>
</TrpcProvider>
</ClientIntlProvider>
</SessionProvider>
</div>
</body>
</html>
)

View File

@@ -63,6 +63,23 @@ body.overflow-hidden {
overflow: hidden;
}
.root {
isolation: isolate;
}
/* From Tailwind */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
ul {
padding-inline-start: 0;
margin-block-start: 0;

View File

@@ -0,0 +1,114 @@
.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: 430px;
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

@@ -0,0 +1,161 @@
"use client"
import { cx } from "class-variance-authority"
import { useRouter } from "next/navigation"
import { memo, useMemo, 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"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { ResultHistory } from "../Results/ResultHistory"
import { ResultMatches } from "../Results/ResultMatches"
import styles from "./clientInline.module.css"
import type { ClientProps } from "@/types/components/destinationOverviewPage/jumpTo/client"
const ResultMatchesMemo = memo(ResultMatches)
const ResultHistoryMemo = memo(ResultHistory)
export function ClientInline({
results,
latest,
setFilterString,
onAction,
onClearHistory,
}: ClientProps) {
const router = useRouter()
const intl = useIntl()
const [isPending, startTransition] = useTransition()
const isMounted = useIsMounted()
const showResults = !!results
const showHistory = isMounted() && (!results || results.length === 0)
const latestResults = useMemo(() => {
return latest.concat({
id: "actions", // The string "Actions" converts into a divider below
name: "Actions",
children: [
{
id: "clearHistory",
type: "clearHistory",
displayName: intl.formatMessage({ id: "Clear searches" }),
},
],
})
}, [intl, latest])
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()
startTransition(() => {
if (results) {
const firstItem = results[0].children[0]
onAction(firstItem.id)
if (firstItem.url) {
router.push(firstItem.url)
}
} else if (latest) {
const firstItem = latest[0].children[0]
onAction(firstItem.id)
if (firstItem.url) {
router.push(firstItem.url)
}
}
})
}}
>
<div className={styles.fields}>
<Typography variant="Body/Supporting text (caption)/smBold">
<Label className={styles.label}>
{intl.formatMessage({ id: "Where to?" })}
</Label>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<Input
className={styles.input}
placeholder={intl.formatMessage({
id: "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}>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({ id: "Search" })}
</Button>
</Typography>
</form>
)}
</SearchField>
<div className={styles.results}>
<div
className={cx({
[styles.menuContainer]: true,
[styles.pending]: isPending,
})}
aria-live="polite"
>
{showResults ? (
<ResultMatchesMemo results={results} onAction={onAction} />
) : null}
{showHistory ? (
<ResultHistoryMemo
results={latestResults}
onClearHistory={onClearHistory}
/>
) : null}
</div>
</div>
</div>
</Autocomplete>
)
}

View File

@@ -0,0 +1,187 @@
.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%;
& .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

@@ -0,0 +1,189 @@
"use client"
import { cx } from "class-variance-authority"
import { useRouter } from "next/navigation"
import { memo, useMemo, 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"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { ResultHistory } from "../Results/ResultHistory"
import { ResultMatches } from "../Results/ResultMatches"
import styles from "./clientModal.module.css"
import type { ClientProps } from "@/types/components/destinationOverviewPage/jumpTo/client"
const ResultMatchesMemo = memo(ResultMatches)
const ResultHistoryMemo = memo(ResultHistory)
export function ClientModal({
results,
latest,
setFilterString,
onAction,
onClearHistory,
}: ClientProps) {
const router = useRouter()
const intl = useIntl()
const [isPending, startTransition] = useTransition()
const showResults = !!results
const showHistory = !results || results.length === 0
const latestResults = useMemo(() => {
return latest.concat({
id: "actions", // The string "Actions" converts into a divider below
name: "Actions",
children: [
{
id: "clearHistory",
type: "clearHistory",
displayName: intl.formatMessage({ id: "Clear searches" }),
},
],
})
}, [intl, latest])
return (
<DialogTrigger>
<ButtonRAC className={styles.trigger}>
<div>
<Typography variant="Body/Supporting text (caption)/smBold">
<div className={styles.label}>
{intl.formatMessage({ id: "Where to?" })}
</div>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.placeholder}>
{intl.formatMessage({ id: "Hotels & Destinations" })}
</div>
</Typography>
</div>
<div className={styles.icon}>
<MaterialIcon icon="search" color="CurrentColor" />
</div>
</ButtonRAC>
<ModalOverlay className={styles.modalOverlay} isDismissable={true}>
<Modal className={styles.modal}>
<Dialog className={styles.dialog}>
<Heading level={2} className="sr-only">
{intl.formatMessage({ id: "Find a location" })}
</Heading>
<ButtonRAC
className={styles.closeButton}
slot="close"
aria-label={intl.formatMessage({ id: "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()
startTransition(() => {
if (results) {
const firstItem = results[0].children[0]
onAction(firstItem.id)
if (firstItem.url) {
router.push(firstItem.url)
}
} else if (latest) {
const firstItem = latest[0].children[0]
onAction(firstItem.id)
if (firstItem.url) {
router.push(firstItem.url)
}
}
})
}}
>
<Typography variant="Body/Supporting text (caption)/smBold">
<Label className={styles.label}>
{intl.formatMessage({ id: "Where to?" })}
</Label>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<Input
className={styles.input}
placeholder={intl.formatMessage({
id: "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}>
Clear
</ButtonRAC>
</Typography>
)}
</form>
)}
</SearchField>
<div className={styles.results}>
<div
className={cx({
[styles.menuContainer]: true,
[styles.pending]: isPending,
})}
aria-live="polite"
>
{showResults ? (
<ResultMatchesMemo
results={results}
onAction={onAction}
/>
) : null}
{showHistory ? (
<ResultHistoryMemo
results={latestResults}
onClearHistory={onClearHistory}
/>
) : null}
</div>
</div>
</div>
</Autocomplete>
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
)
}

View File

@@ -0,0 +1,104 @@
"use client"
import {
Collection,
Header,
Menu,
MenuItem,
MenuSection,
Text,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./results.module.css"
import type { ResultHistoryProps } from "@/types/components/destinationOverviewPage/jumpTo/results"
export function ResultHistory({ results, onClearHistory }: ResultHistoryProps) {
const intl = useIntl()
return (
<Menu
aria-label={intl.formatMessage({
id: "Latest searches",
})}
className={styles.menu}
items={results}
renderEmptyState={() => {
return null
}}
>
{(section) => {
if (section.id === "actions") {
return (
<MenuSection key={section.id} className={styles.actionsSection}>
<Header className="sr-only">{section.name}</Header>
<Collection items={section.children}>
{(item) => (
<MenuItem
key={item.id}
href={item.url}
className={styles.item}
textValue={item.displayName}
>
<Button
typography="Body/Supporting text (caption)/smBold"
variant="Text"
onPress={onClearHistory}
className={styles.clearHistoryButton}
>
<MaterialIcon icon="delete" color="CurrentColor" />
<Text slot="label">
{intl.formatMessage({
id: "Clear searches",
})}
</Text>
</Button>
</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>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import {
Collection,
Header,
Menu,
MenuItem,
MenuSection,
Text,
} 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 { ResultMatchesProps } from "@/types/components/destinationOverviewPage/jumpTo/results"
export function ResultMatches({ results, onAction }: ResultMatchesProps) {
const intl = useIntl()
return (
<Menu
aria-label={intl.formatMessage({ id: "Results" })}
className={styles.menu}
items={results}
onAction={(key) => onAction(key.toString())}
renderEmptyState={() => {
return (
<div className={styles.noResults}>
<Typography variant="Body/Paragraph/mdBold">
<Header className={styles.noResultsLabel}>
{intl.formatMessage({ id: "No results" })}
</Header>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<span className={styles.noResultsDescription}>
{intl.formatMessage({
id: "We couldn't find a matching location for your search.",
})}
</span>
</Typography>
</div>
)
}}
>
{(section) => (
<MenuSection key={section.id}>
<Typography variant="Title/Overline/sm">
<Header className={styles.sectionHeader}>{section.name}</Header>
</Typography>
<Collection items={section.children}>
{(item) => (
<MenuItem
className={styles.item}
href={item.url}
key={item.id}
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>
)
}

View File

@@ -0,0 +1,77 @@
"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 ResultSkeleton() {
const intl = useIntl()
return (
<section className={styles.menu}>
<Typography variant="Title/Overline/sm">
<header className={styles.sectionHeader}>
{intl.formatMessage({
id: "Loading results",
})}
</header>
</Typography>
<div>
<div className={styles.item}>
<Typography variant="Body/Paragraph/mdBold">
<SkeletonShimmer width="50%" />
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.itemDescription}>
<SkeletonShimmer width="38%" />
</div>
</Typography>
</div>
<div className={styles.item}>
<Typography variant="Body/Paragraph/mdBold">
<SkeletonShimmer width="40%" />
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.itemDescription}>
<SkeletonShimmer width="23%" />
</div>
</Typography>
</div>
<div className={styles.item}>
<Typography variant="Body/Paragraph/mdBold">
<SkeletonShimmer width="55%" />
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.itemDescription}>
<SkeletonShimmer width="40%" />
</div>
</Typography>
</div>
<div className={styles.item}>
<Typography variant="Body/Paragraph/mdBold">
<SkeletonShimmer width="27%" />
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.itemDescription}>
<SkeletonShimmer width="33%" />
</div>
</Typography>
</div>
<div className={styles.item}>
<Typography variant="Body/Paragraph/mdBold">
<SkeletonShimmer width="45%" />
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.itemDescription}>
<SkeletonShimmer width="37%" />
</div>
</Typography>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,64 @@
.menu {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.sectionHeader {
color: var(--UI-Text-Placeholder);
padding-left: var(--Space-x1);
padding-bottom: var(--Space-x05);
}
.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);
}
.noResults {
padding-left: var(--Space-x1);
}
.noResultsLabel {
color: var(--Text-Default);
}
.noResultsDescription {
color: var(--Text-Tertiary);
}
.actionsSection {
border-top: solid 1px var(--Border-Divider-Subtle);
padding-top: var(--Space-x2); /* match gap of .menu */
}
.actionsSection .item {
padding-top: 0;
padding-bottom: 0;
}
.clearHistoryButton {
display: flex;
flex-direction: row;
gap: var(--Space-x05);
align-items: center;
cursor: pointer;
}

View File

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

View File

@@ -0,0 +1,181 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import { useIsMounted, useMediaQuery } from "usehooks-ts"
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 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: LocationMatchResults = 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
return [
{
id: "latestSearches",
name: "Latest searches",
children,
},
]
}
return []
}, [data, history])
const results = useMemo(() => {
if (filterString) {
return filter(filterString)
} else {
return null
}
}, [filterString, filter])
const props: ClientProps = useMemo(() => {
return {
results,
latest,
setFilterString,
onAction,
onClearHistory,
}
}, [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

@@ -0,0 +1,47 @@
"use client"
import { use } from "react"
import { useLocalStorage } from "usehooks-ts"
import { localStorageKey } from "@/components/Forms/BookingWidget/FormContent/Search/reducer"
import { JumpToClient } from "../Client"
import type { JumpToHistory } from "@/types/components/destinationOverviewPage/jumpTo"
import type { JumpToResolverProps } from "@/types/components/destinationOverviewPage/jumpTo/resolver"
export function JumpToResolver({ dataPromise }: JumpToResolverProps) {
const data = use(dataPromise)
const [history, setHistory, clearHistory] = useLocalStorage<JumpToHistory>(
localStorageKey,
[]
)
if (!data) {
return null
}
return (
<JumpToClient
data={data}
history={history}
onAction={(key) => {
const existsInHistory = history.find((h) => h.id === key)
if (!existsInHistory) {
const item = data.find((d) => d.id === key)
if (item) {
const { id, type } = item
// latest added should be shown first
const newHistory = [{ id, type }, ...history]
setHistory(newHistory)
}
}
}}
onClearHistory={() => {
debugger
clearHistory()
}}
/>
)
}

View File

@@ -0,0 +1,9 @@
import { getJumpToData } from "@/lib/trpc/memoizedRequests"
import { JumpToResolver } from "@/components/ContentType/DestinationPage/DestinationOverviewPage/JumpTo/Resolver"
export async function JumpTo() {
const dataPromise = getJumpToData()
return <JumpToResolver dataPromise={dataPromise} />
}

View File

@@ -37,3 +37,15 @@
max-width: var(--max-width-content);
margin: 0 auto;
}
.jumpToContainer {
display: grid;
gap: var(--Space-x4);
padding: var(--Space-x4) var(--Space-x2);
background: var(--Surface-Secondary-Default);
}
.heading {
color: var(--Text-Interactive-Default);
text-align: center;
}

View File

@@ -1,5 +1,7 @@
import { Suspense } from "react"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getDestinationOverviewPage } from "@/lib/trpc/memoizedRequests"
import Blocks from "@/components/Blocks"
@@ -7,6 +9,7 @@ import SkeletonShimmer from "@/components/SkeletonShimmer"
import TrackingSDK from "@/components/TrackingSDK"
import HotelsSection from "./HotelsSection"
import { JumpTo } from "./JumpTo"
import OverviewMapContainer from "./OverviewMapContainer"
import styles from "./destinationOverviewPage.module.css"
@@ -22,6 +25,12 @@ export default async function DestinationOverviewPage() {
return (
<>
<div className={styles.jumpToContainer}>
<Typography variant="Title/lg">
<h1 className={styles.heading}>{destinationOverviewPage.heading}</h1>
</Typography>
<JumpTo />
</div>
<div className={styles.mapContainer}>
<Suspense fallback={<SkeletonShimmer width={"100%"} height={"100%"} />}>
<OverviewMapContainer />

View File

@@ -0,0 +1,22 @@
// https://react-spectrum.adobe.com/react-aria/routing.html#app-router
"use client"
import { useRouter } from "next/navigation"
import { RouterProvider } from "react-aria-components"
import type { PropsWithChildren } from "react"
declare module "react-aria-components" {
interface RouterConfig {
routerOptions: NonNullable<
Parameters<ReturnType<typeof useRouter>["push"]>[1]
>
}
}
export function RACRouterProvider({ children }: PropsWithChildren) {
const router = useRouter()
return <RouterProvider navigate={router.push}>{children}</RouterProvider>
}

View File

@@ -1,4 +1,4 @@
import { cva } from "class-variance-authority"
import { cva, cx } from "class-variance-authority"
import styles from "./skeleton.module.css"
@@ -15,11 +15,13 @@ const variants = cva(styles.shimmer, {
})
export default function SkeletonShimmer({
className,
height,
width,
contrast = "light",
display = "initial",
}: {
className?: string
height?: string
width?: string
contrast?: "light" | "dark"
@@ -27,13 +29,16 @@ export default function SkeletonShimmer({
}) {
return (
<span
className={variants({ contrast })}
className={cx(className, variants({ contrast }))}
style={{
height: height,
width: width,
maxWidth: "100%",
display: display,
}}
/>
>
{/* zero width space, allows for font styles to affect height */}
<span aria-hidden="true">&#8203;</span>
</span>
)
}

View File

@@ -37,6 +37,9 @@
animation: shimmer 3s infinite;
content: "";
}
.shimmer span {
visibility: hidden;
}
@keyframes shimmer {
100% {
transform: translateX(100%);

View File

@@ -298,6 +298,7 @@
"Filter by": "Filtrer efter",
"Final VAT breakdown will be provided at check-out.": "Endelig momsopgørelse gives ved check-out.",
"Find": "Finde",
"Find a location": "Find a location",
"Find booking": "Find booking",
"Find hotels": "Find hotel",
"Find hotels and destinations": "Find hoteller og destinationer",
@@ -438,6 +439,7 @@
"Link my accounts": "Link my accounts",
"Link your accounts": "Link your accounts",
"Linked account": "Linked account",
"Loading results": "Loading results",
"Location": "Beliggenhed",
"Location (shown in local language)": "Location (shown in local language)",
"Location in hotel": "Plassering på hotellet",
@@ -682,6 +684,7 @@
"Restaurant & Bar": "Restaurant & Bar",
"Restaurants": "Restauranter",
"Restaurants & Bars": "Restaurants & Bars",
"Results": "Results",
"Retype new password": "Gentag den nye adgangskode",
"Reward night": "Bonusnat",
"Room": "Værelse",

View File

@@ -299,6 +299,7 @@
"Filter by": "Filtern nach",
"Final VAT breakdown will be provided at check-out.": "Die endgültige Mehrwertsteueraufstellung wird beim Check-out bereitgestellt.",
"Find": "Finden",
"Find a location": "Find a location",
"Find booking": "Buchung finden",
"Find hotels": "Hotels finden",
"Find hotels and destinations": "Finden Sie Hotels und Reiseziele",
@@ -439,6 +440,7 @@
"Link my accounts": "Link my accounts",
"Link your accounts": "Link your accounts",
"Linked account": "Linked account",
"Loading results": "Loading results",
"Location": "Ort",
"Location (shown in local language)": "Location (shown in local language)",
"Location in hotel": "Lage im Hotel",
@@ -681,6 +683,7 @@
"Restaurant & Bar": "Restaurant & Bar",
"Restaurants": "Restaurants",
"Restaurants & Bars": "Restaurants & Bars",
"Results": "Results",
"Retype new password": "Neues Passwort erneut eingeben",
"Reward night": "Bonusnacht",
"Room": "Zimmer",

View File

@@ -299,6 +299,7 @@
"Filter by": "Filter by",
"Final VAT breakdown will be provided at check-out.": "Final VAT breakdown will be provided at check-out.",
"Find": "Find",
"Find a location": "Find a location",
"Find booking": "Find booking",
"Find hotels": "Find hotels",
"Find hotels and destinations": "Find hotels and destinations",
@@ -440,6 +441,7 @@
"Link my accounts": "Link my accounts",
"Link your accounts": "Link your accounts",
"Linked account": "Linked account",
"Loading results": "Loading results",
"Location": "Location",
"Location (shown in local language)": "Location (shown in local language)",
"Location in hotel": "Location in hotel",
@@ -683,6 +685,7 @@
"Restaurant & Bar": "Restaurant & Bar",
"Restaurants": "Restaurants",
"Restaurants & Bars": "Restaurants & Bars",
"Results": "Results",
"Retype new password": "Retype new password",
"Reward night": "Reward night",
"Room": "Room",

View File

@@ -298,6 +298,7 @@
"Filter by": "Suodatusperuste",
"Final VAT breakdown will be provided at check-out.": "Lopullinen ALV-erittely annetaan uloskirjautumisen yhteydessä.",
"Find": "Löytää",
"Find a location": "Find a location",
"Find booking": "Etsi varaus",
"Find hotels": "Etsi hotelleja",
"Find hotels and destinations": "Etsi hotelleja ja kohteita",
@@ -438,6 +439,7 @@
"Link my accounts": "Link my accounts",
"Link your accounts": "Link your accounts",
"Linked account": "Linked account",
"Loading results": "Loading results",
"Location": "Sijainti",
"Location (shown in local language)": "Location (shown in local language)",
"Location in hotel": "Sijainti hotellissa",
@@ -680,6 +682,7 @@
"Restaurant & Bar": "Ravintola & Baari",
"Restaurants": "Ravintolat",
"Restaurants & Bars": "Restaurants & Bars",
"Results": "Results",
"Retype new password": "Kirjoita uusi salasana uudelleen",
"Reward night": "Palkintoyö",
"Room": "Huone",

View File

@@ -297,6 +297,7 @@
"Filter by": "Filtrer etter",
"Final VAT breakdown will be provided at check-out.": "Endelig MVA-oversikt gis ved utsjekking.",
"Find": "Finne",
"Find a location": "Find a location",
"Find booking": "Finn booking",
"Find hotels": "Finn hotell",
"Find hotels and destinations": "Finn hoteller og destinasjoner",
@@ -437,6 +438,7 @@
"Link my accounts": "Link my accounts",
"Link your accounts": "Link your accounts",
"Linked account": "Linked account",
"Loading results": "Loading results",
"Location": "Beliggenhet",
"Location (shown in local language)": "Location (shown in local language)",
"Location in hotel": "Plassering på hotellet",
@@ -679,6 +681,7 @@
"Restaurant & Bar": "Restaurant & Bar",
"Restaurants": "Restauranter",
"Restaurants & Bars": "Restaurants & Bars",
"Results": "Results",
"Retype new password": "Skriv inn nytt passord på nytt",
"Reward night": "Bonusnatt",
"Room": "Rom",

View File

@@ -297,6 +297,7 @@
"Filter by": "Filtrera på",
"Final VAT breakdown will be provided at check-out.": "Slutlig momsuppdelning tillhandahålls vid utcheckning.",
"Find": "Hitta",
"Find a location": "Find a location",
"Find booking": "Hitta bokning",
"Find hotels": "Hitta hotell",
"Find hotels and destinations": "Hitta hotell och destinationer",
@@ -437,6 +438,7 @@
"Link my accounts": "Link my accounts",
"Link your accounts": "Link your accounts",
"Linked account": "Linked account",
"Loading results": "Loading results",
"Location": "Plats",
"Location (shown in local language)": "Location (shown in local language)",
"Location in hotel": "Plats på hotellet",
@@ -679,6 +681,7 @@
"Restaurant & Bar": "Restaurang & Bar",
"Restaurants": "Restauranger",
"Restaurants & Bars": "Restaurants & Bars",
"Results": "Results",
"Retype new password": "Upprepa nytt lösenord",
"Reward night": "Bonusnatt",
"Room": "Rum",

View File

@@ -0,0 +1,21 @@
query GetLocationsUrls($locale: String!) {
hotels: all_hotel_page(locale: $locale) {
items {
url
id: hotel_page_id
}
}
cities: all_destination_city_page {
items {
url
id: destination_settings {
sv: city_sweden
pl: city_poland
no: city_norway
de: city_germany
fi: city_finland
da: city_denmark
}
}
}
}

View File

@@ -1,3 +1,5 @@
import { isDefined } from "@/server/utils"
import { getLang } from "@/i18n/serverContext"
import { cache } from "@/utils/cache"
@@ -268,3 +270,79 @@ export const getPageSettingsBookingCode = cache(
export const getJobylonFeed = cache(async function getMemoizedJobylonFeed() {
return serverClient().partner.jobylon.feed.get()
})
export const getJumpToData = cache(async function getMemoizedJumpToData() {
const lang = getLang()
const [locationsResults, urlsResults] = await Promise.allSettled([
getLocations(),
serverClient().hotel.locations.urls({ lang }),
])
if (
locationsResults.status === "fulfilled" &&
urlsResults.status === "fulfilled"
) {
const locations = locationsResults.value
const urls = urlsResults.value
if (!locations || !urls) {
return null
}
return locations
.map((location) => {
const { id, name, type } = location
const isCity = type === "cities"
const isHotel = type === "hotels"
let url: string | undefined
if (isCity) {
url = urls.cities.find(
(c) =>
c.id &&
location.cityIdentifier &&
c.id === location.cityIdentifier
)?.url
} else if (isHotel) {
url = urls.hotels.find(
(h) => h.id && location.id && h.id === location.id
)?.url
}
if (!url) {
return null
}
let description = ""
if (isCity) {
description = location.country
} else if (isHotel) {
description = location.relationships.city.name
}
const rankingNames: string[] = [location.name]
if (isCity) {
if (location.cityIdentifier) {
rankingNames.push(location.cityIdentifier)
}
}
const rankingKeywords = location.keyWords || []
return {
id,
displayName: name,
type,
description,
url,
rankingNames: rankingNames.map((v) => v.toLowerCase()),
rankingKeywords: rankingKeywords.map((v) => v.toLowerCase()),
}
})
.filter(isDefined)
}
return null
})

View File

@@ -83,7 +83,7 @@
"next": "^14.2.25",
"next-auth": "5.0.0-beta.19",
"react": "^18",
"react-aria-components": "^1.6.0",
"react-aria-components": "^1.7.1",
"react-day-picker": "^9.0.8",
"react-dom": "^18",
"react-feather": "^2.0.10",

View File

@@ -164,6 +164,10 @@ export const getLocationsInput = z.object({
lang: z.nativeEnum(Lang),
})
export const getLocationsUrlsInput = z.object({
lang: z.nativeEnum(Lang),
})
export const roomFeaturesInputSchema = z.object({
hotelId: z.string(),
startDate: z.string(),

View File

@@ -3,6 +3,8 @@ import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { dt } from "@/lib/dt"
import { GetLocationsUrls } from "@/lib/graphql/Query/Locations/Locations.graphql"
import { request } from "@/lib/graphql/request"
import { badRequestError, unauthorizedError } from "@/server/errors/trpc"
import {
contentStackBaseWithServiceProcedure,
@@ -16,10 +18,15 @@ import { toApiLang } from "@/server/utils"
import { generateChildrenString } from "@/components/HotelReservation/utils"
import { getCacheClient } from "@/services/dataCache"
import { cache } from "@/utils/cache"
import { removeMultipleSlashes } from "@/utils/url"
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
import { getVerifiedUser } from "../user/query"
import { additionalDataSchema } from "./schemas/hotel/include/additionalData"
import {
type GetLocationsUrlsData,
locationsUrlsSchema,
} from "./schemas/location/urls"
import { meetingRoomsSchema } from "./schemas/meetingRoom"
import {
ancillaryPackageInputSchema,
@@ -32,6 +39,7 @@ import {
getHotelsByCSFilterInput,
getHotelsByHotelIdsAvailabilityInputSchema,
getLocationsInput,
getLocationsUrlsInput,
getMeetingRoomsInputSchema,
hotelInputSchema,
hotelsAvailabilityInputSchema,
@@ -55,6 +63,11 @@ import {
roomFeaturesSchema,
roomsAvailabilitySchema,
} from "./output"
import {
locationsUrlsCounter,
locationsUrlsFailCounter,
locationsUrlsSuccessCounter,
} from "./telemetry"
import tempRatesData from "./tempRatesData.json"
import {
getCitiesByCountry,
@@ -1505,6 +1518,79 @@ export const hotelQueryRouter = router({
"max"
)
}),
urls: publicProcedure
.input(getLocationsUrlsInput)
.query(async ({ input }) => {
const procedureName = "hotels.locations.urls"
const { lang } = input
locationsUrlsCounter.add(1, { lang })
console.info(
`${procedureName}: start`,
JSON.stringify({ query: { lang } })
)
const response = await request<GetLocationsUrlsData>(
GetLocationsUrls,
{ locale: lang },
{
key: `${lang}:${procedureName}`,
ttl: "max",
}
)
if (!response.data) {
locationsUrlsFailCounter.add(1, {
lang,
error_type: "no data",
response: JSON.stringify(response),
})
console.error(`${procedureName}: no data`, {
variables: { lang },
error_type: "no data",
response,
})
return null
}
const locationsUrls = locationsUrlsSchema.safeParse(response.data)
if (!locationsUrls.success) {
locationsUrlsFailCounter.add(1, {
lang,
error_type: "validation_error",
error: JSON.stringify(locationsUrls.error),
})
console.error(`${procedureName}: validation error`, {
variables: { lang },
error_type: "validation_error",
error: locationsUrls.error,
})
return null
}
locationsUrlsSuccessCounter.add(1, { lang })
console.info(`${procedureName}: success`, {
variables: { lang },
})
const { data } = locationsUrls
data.hotels = data.hotels.map((hotel) => {
hotel.url = removeMultipleSlashes(`/${lang}/${hotel.url}`)
return hotel
})
data.cities = data.cities.map((city) => {
city.url = removeMultipleSlashes(`/${lang}/${city.url}`)
return city
})
return locationsUrls.data
}),
}),
map: router({
city: serviceProcedure

View File

@@ -0,0 +1,68 @@
import { z } from "zod"
import { isDefined } from "@/server/utils"
export const locationsUrlsSchema = z.object({
hotels: z
.object({
items: z.array(
z.object({
id: z.string(),
url: z.string(),
})
),
})
.transform((data) => {
return data.items
}),
cities: z
.object({
items: z
.array(
z
.object({
id: z
.object({
da: z.string().nullish(),
de: z.string().nullish(),
en: z.string().nullish(),
fi: z.string().nullish(),
no: z.string().nullish(),
pl: z.string().nullish(),
sv: z.string().nullish(),
})
.transform(
(data) =>
data.da ||
data.de ||
data.en ||
data.fi ||
data.no ||
data.pl ||
data.sv
),
url: z.string().nullish(),
})
.partial()
.transform((data) => {
if (!data.id || !data.url) {
return null
}
return {
id: data.id,
url: data.url,
}
})
)
.transform((data) => {
return data.filter(isDefined)
}),
})
.transform((data) => {
return data.items
}),
})
export type GetLocationsUrlsData = z.input<typeof locationsUrlsSchema>
export type LocationsUrls = z.output<typeof locationsUrlsSchema>

View File

@@ -102,3 +102,13 @@ export const additionalDataSuccessCounter = meter.createCounter(
export const additionalDataFailCounter = meter.createCounter(
"trpc.hotels.additionalData-fail"
)
export const locationsUrlsCounter = meter.createCounter(
"trpc.hotels.locations.urls"
)
export const locationsUrlsSuccessCounter = meter.createCounter(
"trpc.hotels.locations.urls-success"
)
export const locationsUrlsFailCounter = meter.createCounter(
"trpc.hotels.locations.urls-fail"
)

View File

@@ -4,6 +4,10 @@ import { z } from "zod"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
export function isDefined<T>(argument: T | undefined | null): argument is T {
return argument !== undefined && argument !== null
}
export const langInput = z.object({
lang: z.nativeEnum(Lang),
})

View File

@@ -18,5 +18,5 @@
".next/types/**/*.ts",
"netlify/functions/**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["**/node_modules/**"]
}

View File

@@ -17,10 +17,6 @@ export type DestinationsData = DestinationCountry[]
export type Cities = DestinationCountry["cities"]
export type HotelsSectionProps = {
destinations: DestinationsData
}
export type DestinationsListProps = {
destinations: DestinationsData
}

View File

@@ -0,0 +1,9 @@
import type { JumpToData, JumpToProps, LocationMatchResultsState } from "./"
export type ClientProps = {
results: LocationMatchResultsState
latest: NonNullable<LocationMatchResultsState>
setFilterString: (filter: string | null) => void
onAction: JumpToProps<JumpToData>["onAction"]
onClearHistory: () => void
}

View File

@@ -0,0 +1,44 @@
export type JumpToDataItem = {
id: string
displayName: string
type: "hotels" | "cities"
description: string
url: string
rankingNames: string[]
rankingKeywords: string[]
}
export type JumpToData = JumpToDataItem[]
export type JumpToHistory = {
id: JumpToDataItem["id"]
type: JumpToDataItem["type"]
}[]
export type JumpToProps<T extends { id: string }[]> = {
data: T
history: JumpToHistory
onAction: (id: T[number]["id"]) => void
onClearHistory: () => void
}
export type LocationMatch = {
id: string
displayName: string
type: string
description?: string
url?: string
}
export type ScoringMatch = LocationMatch & {
score: number
}
export type LocationMatchResult = {
id: string
name: string
children: LocationMatch[]
}
export type LocationMatchResults = LocationMatchResult[]
export type LocationMatchResultsState = LocationMatchResults | null

View File

@@ -0,0 +1,5 @@
import type { getJumpToData } from "@/lib/trpc/memoizedRequests"
export type JumpToResolverProps = {
dataPromise: ReturnType<typeof getJumpToData>
}

View File

@@ -0,0 +1,9 @@
import type { ClientProps } from "./client"
export type ResultHistoryProps = Pick<ClientProps, "onClearHistory"> & {
results: ClientProps["latest"]
}
export type ResultMatchesProps = Pick<ClientProps, "onAction"> & {
results: NonNullable<ClientProps["results"]>
}

View File

@@ -39,6 +39,13 @@ export const PrimaryDefault: Story = {
},
}
export const PrimaryDisabled: Story = {
args: {
...PrimaryDefault.args,
isDisabled: true,
},
}
export const PrimaryLarge: Story = {
args: {
...PrimaryDefault.args,
@@ -69,6 +76,13 @@ export const SecondaryDefault: Story = {
},
}
export const SecondaryDisabled: Story = {
args: {
...SecondaryDefault.args,
isDisabled: true,
},
}
export const SecondaryLarge: Story = {
args: {
...SecondaryDefault.args,
@@ -99,6 +113,13 @@ export const TertiaryDefault: Story = {
},
}
export const TertiaryDisabled: Story = {
args: {
...TertiaryDefault.args,
isDisabled: true,
},
}
export const TertiaryLarge: Story = {
args: {
...TertiaryDefault.args,
@@ -120,6 +141,43 @@ export const TertiarySmall: Story = {
},
}
export const InvertedDefault: Story = {
args: {
onPress: fn(),
children: 'Inverted button',
typography: 'Body/Paragraph/mdBold',
variant: 'Inverted',
},
}
export const InvertedDisabled: Story = {
args: {
...InvertedDefault.args,
isDisabled: true,
},
}
export const InvertedLarge: Story = {
args: {
...InvertedDefault.args,
size: 'Large',
},
}
export const InvertedMedium: Story = {
args: {
...InvertedDefault.args,
size: 'Medium',
},
}
export const InvertedSmall: Story = {
args: {
...InvertedDefault.args,
size: 'Small',
},
}
export const TextDefault: Story = {
args: {
onPress: fn(),

View File

@@ -74,6 +74,24 @@
color: var(--Component-Button-Brand-Tertiary-On-fill-Disabled);
}
.variant-inverted {
background-color: var(--Component-Button-Inverted-Default);
border-color: transparent;
color: var(--Component-Button-Inverted-On-fill-Default);
}
.variant-inverted:hover {
background-color: var(--Component-Button-Inverted-Hover);
border-color: transparent;
color: var(--Component-Button-Inverted-On-fill-Hover);
}
.variant-inverted:disabled {
background-color: var(--Component-Button-Inverted-Disabled);
border-color: transparent;
color: var(--Component-Button-Inverted-On-fill-Disabled);
}
.variant-text {
background-color: transparent;
border-color: transparent;

View File

@@ -14,6 +14,7 @@ export const config = {
Primary: styles['variant-primary'],
Secondary: styles['variant-secondary'],
Tertiary: styles['variant-tertiary'],
Inverted: styles['variant-inverted'],
Text: styles['variant-text'],
Icon: styles['variant-icon'],
},

View File

@@ -126,7 +126,7 @@
},
"peerDependencies": {
"react": "^18.2.0",
"react-aria-components": "^1.6.0",
"react-aria-components": "^1.7.1",
"react-dom": "^18.2.0"
},
"devDependencies": {
@@ -161,7 +161,7 @@
"material-symbols": "^0.29.0",
"prettier": "^3.4.2",
"react": "^18.2.0",
"react-aria-components": "^1.6.0",
"react-aria-components": "^1.7.1",
"react-dom": "^18.2.0",
"react-material-symbols": "^4.4.0",
"rollup": "^4.34.8",

2575
yarn.lock

File diff suppressed because it is too large Load Diff