@bearmenu/ui 0.8.9 → 0.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-24RFI4RQ.js +167 -0
- package/dist/{chunk-UKBNQO6Q.js → chunk-2CXU5WZO.js} +1 -1
- package/dist/chunk-2XCZJWTV.js +121 -0
- package/dist/{chunk-62ES6N32.js → chunk-43RC7MHY.js} +1 -1
- package/dist/chunk-5RWTOT5Y.js +72 -0
- package/dist/{chunk-Q6QH52LJ.js → chunk-6TEEY5YA.js} +2 -2
- package/dist/{chunk-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
- package/dist/chunk-CLLY6XB3.js +41 -0
- package/dist/chunk-DTXNDLSE.js +125 -0
- package/dist/chunk-F6FMZJDG.js +113 -0
- package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
- package/dist/chunk-H3ZZDX5L.js +61 -0
- package/dist/{chunk-H3BV4VAG.js → chunk-JS3EINZD.js} +2 -2
- package/dist/chunk-MZL7CGCL.js +109 -0
- package/dist/{chunk-TH6W2PWG.js → chunk-UDTG3RSE.js} +1 -1
- package/dist/chunk-WCTHZKU7.js +37 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/avatar.d.ts +3 -3
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +1 -1
- package/dist/components/card.d.ts +4 -4
- package/dist/components/carousel.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/command.d.ts +2 -2
- package/dist/components/concept-compare-rail.d.ts +50 -0
- package/dist/components/concept-compare-rail.js +5 -0
- package/dist/components/concept-compare.d.ts +75 -38
- package/dist/components/concept-compare.js +209 -133
- package/dist/components/concept-convergence.d.ts +41 -0
- package/dist/components/concept-convergence.js +53 -0
- package/dist/components/convergence.d.ts +25 -0
- package/dist/components/convergence.js +4 -0
- package/dist/components/dialog.d.ts +2 -2
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.js +2 -2
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +2 -2
- package/dist/components/event-list-row.js +2 -2
- package/dist/components/explainer-steps.d.ts +34 -0
- package/dist/components/explainer-steps.js +36 -0
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/glass-ground.d.ts +61 -0
- package/dist/components/glass-ground.js +3 -0
- package/dist/components/haptic-button.js +1 -1
- package/dist/components/hub-surface.js +11 -1
- package/dist/components/hue-chip.d.ts +70 -0
- package/dist/components/hue-chip.js +3 -0
- package/dist/components/input-group.d.ts +2 -2
- package/dist/components/input.d.ts +1 -1
- package/dist/components/item.d.ts +4 -4
- package/dist/components/kbd.d.ts +2 -2
- package/dist/components/kitchen-dossier.d.ts +88 -0
- package/dist/components/kitchen-dossier.js +186 -0
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-dead-end.d.ts +56 -0
- package/dist/components/menu-dead-end.js +49 -0
- package/dist/components/menu-family-block.d.ts +98 -0
- package/dist/components/menu-family-block.js +153 -0
- package/dist/components/menu-family-card.d.ts +33 -27
- package/dist/components/menu-family-card.js +41 -85
- package/dist/components/menu-field.js +92 -2
- package/dist/components/menu-filter-chips.d.ts +86 -0
- package/dist/components/menu-filter-chips.js +122 -0
- package/dist/components/menu-group-rows.d.ts +53 -0
- package/dist/components/menu-group-rows.js +67 -0
- package/dist/components/menu-hub-hero.js +10 -5
- package/dist/components/menu-index-header.d.ts +37 -0
- package/dist/components/menu-index-header.js +68 -0
- package/dist/components/menu-index-list.d.ts +48 -0
- package/dist/components/menu-index-list.js +96 -0
- package/dist/components/menu-intent-hero.d.ts +69 -0
- package/dist/components/menu-intent-hero.js +87 -0
- package/dist/components/menu-item.js +3 -3
- package/dist/components/menu-kitchen-list.d.ts +82 -0
- package/dist/components/menu-kitchen-list.js +5 -0
- package/dist/components/menu-mocks.d.ts +37 -2
- package/dist/components/menu-mocks.js +152 -27
- package/dist/components/menu-types.d.ts +182 -6
- package/dist/components/motion-icon-controls.js +2 -2
- package/dist/components/motion-icon-set.js +6 -6
- package/dist/components/motion-icon.d.ts +22 -1
- package/dist/components/motion-icon.js +1 -1
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/native-select.d.ts +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/photo-ground.d.ts +64 -0
- package/dist/components/photo-ground.js +3 -0
- package/dist/components/place-about-tab.js +2 -2
- package/dist/components/place-details-mobile.js +3 -3
- package/dist/components/place-types.d.ts +2 -2
- package/dist/components/qa-block.d.ts +36 -0
- package/dist/components/qa-block.js +24 -0
- package/dist/components/scroll-fade.js +3 -121
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/select.d.ts +1 -1
- package/dist/components/signature-chip.d.ts +2 -2
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/snap-rail.d.ts +26 -3
- package/dist/components/snap-rail.js +3 -109
- package/dist/components/spinner.d.ts +4 -4
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/tag.d.ts +4 -4
- package/dist/components/text.d.ts +5 -5
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/top-comparison.d.ts +28 -0
- package/dist/components/top-comparison.js +76 -0
- package/dist/components/top-cross-link.d.ts +34 -0
- package/dist/components/top-cross-link.js +101 -0
- package/dist/components/top-ranking-entry.d.ts +53 -0
- package/dist/components/top-ranking-entry.js +217 -0
- package/dist/components/top-ranking-hero.d.ts +61 -0
- package/dist/components/top-ranking-hero.js +99 -0
- package/dist/components/top-ranking-legend.d.ts +69 -0
- package/dist/components/top-ranking-legend.js +124 -0
- package/dist/components/top-types.d.ts +109 -0
- package/dist/components/top-types.js +1 -0
- package/dist/components/venue-grade-dimensions.d.ts +3 -1
- package/dist/components/venue-grade-dimensions.js +1 -1
- package/dist/components/venue-quotes.d.ts +1 -1
- package/dist/components/venue-quotes.js +1 -1
- package/dist/components/venue-wall.js +5 -3
- package/dist/components/weekday-theme-banner.js +1 -1
- package/dist/components/weekend-panel.js +2 -2
- package/package.json +1 -1
- package/src/components/badge.tsx +1 -1
- package/src/components/button.tsx +2 -2
- package/src/components/concept-compare-rail.tsx +132 -0
- package/src/components/concept-compare.stories.tsx +122 -54
- package/src/components/concept-compare.tsx +368 -196
- package/src/components/concept-convergence.stories.tsx +53 -0
- package/src/components/concept-convergence.tsx +90 -0
- package/src/components/convergence.stories.tsx +54 -0
- package/src/components/convergence.tsx +177 -0
- package/src/components/explainer-steps.stories.tsx +46 -0
- package/src/components/explainer-steps.tsx +74 -0
- package/src/components/glass-ground.tsx +104 -0
- package/src/components/hub-surface.tsx +11 -1
- package/src/components/hue-chip.stories.tsx +76 -0
- package/src/components/hue-chip.test.tsx +62 -0
- package/src/components/hue-chip.tsx +99 -0
- package/src/components/kitchen-dossier.stories.tsx +108 -0
- package/src/components/kitchen-dossier.tsx +321 -0
- package/src/components/menu-dead-end.stories.tsx +67 -0
- package/src/components/menu-dead-end.tsx +127 -0
- package/src/components/menu-family-block.stories.tsx +91 -0
- package/src/components/menu-family-block.tsx +303 -0
- package/src/components/menu-family-card.stories.tsx +43 -35
- package/src/components/menu-family-card.tsx +115 -142
- package/src/components/menu-filter-chips.stories.tsx +107 -0
- package/src/components/menu-filter-chips.tsx +238 -0
- package/src/components/menu-group-rows.stories.tsx +54 -0
- package/src/components/menu-group-rows.tsx +133 -0
- package/src/components/menu-hub-hero.test.tsx +39 -0
- package/src/components/menu-hub-hero.tsx +17 -5
- package/src/components/menu-index-header.stories.tsx +72 -0
- package/src/components/menu-index-header.tsx +125 -0
- package/src/components/menu-index-list.stories.tsx +50 -0
- package/src/components/menu-index-list.tsx +149 -0
- package/src/components/menu-intent-hero.stories.tsx +67 -0
- package/src/components/menu-intent-hero.tsx +189 -0
- package/src/components/menu-kitchen-list.stories.tsx +67 -0
- package/src/components/menu-kitchen-list.test.tsx +78 -0
- package/src/components/menu-kitchen-list.tsx +284 -0
- package/src/components/menu-mocks.ts +198 -23
- package/src/components/menu-types.ts +189 -6
- package/src/components/motion-icon-controls.tsx +11 -11
- package/src/components/motion-icon-set.tsx +6 -5
- package/src/components/motion-icon.test.tsx +23 -1
- package/src/components/motion-icon.tsx +20 -0
- package/src/components/photo-ground.tsx +147 -0
- package/src/components/place-types.ts +2 -2
- package/src/components/qa-block.stories.tsx +43 -0
- package/src/components/qa-block.tsx +60 -0
- package/src/components/signature-chip.tsx +3 -3
- package/src/components/snap-rail.tsx +58 -15
- package/src/components/story.tsx +225 -0
- package/src/components/top-comparison.stories.tsx +78 -0
- package/src/components/top-comparison.tsx +151 -0
- package/src/components/top-cross-link.stories.tsx +84 -0
- package/src/components/top-cross-link.tsx +171 -0
- package/src/components/top-ranking-entry.stories.tsx +171 -0
- package/src/components/top-ranking-entry.test.tsx +105 -0
- package/src/components/top-ranking-entry.tsx +425 -0
- package/src/components/top-ranking-hero.stories.tsx +76 -0
- package/src/components/top-ranking-hero.tsx +218 -0
- package/src/components/top-ranking-legend.stories.tsx +78 -0
- package/src/components/top-ranking-legend.tsx +239 -0
- package/src/components/top-types.ts +110 -0
- package/src/components/venue-grade-dimensions.tsx +2 -2
- package/src/components/venue-quotes.stories.tsx +1 -1
- package/src/components/venue-quotes.tsx +4 -2
- package/src/components/venue-wall.tsx +9 -4
- package/src/components/weekday-theme-banner.tsx +1 -1
- package/dist/chunk-THOVPZNN.js +0 -37
- package/dist/chunk-YS6BHKZO.js +0 -92
- package/dist/components/ranked-venue-list.d.ts +0 -49
- package/dist/components/ranked-venue-list.js +0 -87
- package/dist/components/signature-line-list.d.ts +0 -45
- package/dist/components/signature-line-list.js +0 -99
- package/dist/components/venue-busyness-strip.d.ts +0 -35
- package/dist/components/venue-busyness-strip.js +0 -37
- package/dist/components/venue-signature-block.d.ts +0 -39
- package/dist/components/venue-signature-block.js +0 -6
- package/dist/components/venue-spotlight.d.ts +0 -74
- package/dist/components/venue-spotlight.js +0 -138
- package/src/components/ranked-venue-list.stories.tsx +0 -80
- package/src/components/ranked-venue-list.tsx +0 -177
- package/src/components/signature-line-list.stories.tsx +0 -85
- package/src/components/signature-line-list.tsx +0 -136
- package/src/components/venue-busyness-strip.stories.tsx +0 -66
- package/src/components/venue-busyness-strip.tsx +0 -64
- package/src/components/venue-signature-block.stories.tsx +0 -68
- package/src/components/venue-signature-block.tsx +0 -71
- package/src/components/venue-spotlight.stories.tsx +0 -141
- package/src/components/venue-spotlight.tsx +0 -236
- package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MenuDeadEnd, MenuLoosenList } from "./menu-dead-end";
|
|
3
|
+
import { mockLoosenOptions } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MenuDeadEnd> = {
|
|
6
|
+
title: "Menu/MenuDeadEnd",
|
|
7
|
+
component: MenuDeadEnd,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="w-[900px] bg-background p-4">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
parameters: {
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"A filter combination that returns nothing is the intent page's worst state, and an empty-state illustration is the worst answer to it: the reader already knows the list is empty — what they do not know is which of their four filters did it. So this names what dropped out, why, and what one specific widening would restore, with the count. It is also drawn on a non-empty list whenever a filter has cost the reader something notable.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof MenuDeadEnd>;
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
args: {
|
|
31
|
+
children: (
|
|
32
|
+
<>
|
|
33
|
+
<b>Salt & Stone</b> and six others dropped out — their cheapest vegetarian pasta is 58
|
|
34
|
+
lei. Raising the ceiling to 60 brings back <b>five kitchens</b>, including the highest-rated
|
|
35
|
+
Italian room in the city.
|
|
36
|
+
</>
|
|
37
|
+
),
|
|
38
|
+
actionLabel: "Raise to 60 lei · 16 kitchens",
|
|
39
|
+
onAction: () => {},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** No single best widening — the panel explains, the list below prices each one. */
|
|
44
|
+
export const WithoutAction: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
children: (
|
|
47
|
+
<>
|
|
48
|
+
Nothing matches all four filters. <b>Open now</b> is the most expensive of them.
|
|
49
|
+
</>
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const LoosenList: StoryObj<typeof MenuLoosenList> = {
|
|
55
|
+
render: () => <MenuLoosenList options={mockLoosenOptions} />,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const Mobile: Story = {
|
|
59
|
+
args: Default.args,
|
|
60
|
+
decorators: [
|
|
61
|
+
(Story) => (
|
|
62
|
+
<div className="w-[390px] bg-background p-3">
|
|
63
|
+
<Story />
|
|
64
|
+
</div>
|
|
65
|
+
),
|
|
66
|
+
],
|
|
67
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Info } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import type { MenuLinkComponent, MenuLinkProps, MenuLoosenOption } from "./menu-types";
|
|
4
|
+
|
|
5
|
+
function DefaultLink({ href, ...props }: MenuLinkProps) {
|
|
6
|
+
return <a href={href} {...props} />;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* MenuDeadEnd — what dropped out, and the one press that brings it back.
|
|
11
|
+
*
|
|
12
|
+
* A filter combination that returns nothing is the intent page's worst state,
|
|
13
|
+
* and an empty-state illustration is the worst answer to it: the reader knows
|
|
14
|
+
* the list is empty, what they do not know is WHICH of their four filters did
|
|
15
|
+
* it. So this says what dropped out, why, and what widening one specific filter
|
|
16
|
+
* would restore — with the count, because "+5 kitchens, including the
|
|
17
|
+
* highest-rated Italian room in the city" is an answer and "try removing a
|
|
18
|
+
* filter" is a shrug.
|
|
19
|
+
*
|
|
20
|
+
* It is also drawn on a NON-empty list, whenever a filter has cost the reader
|
|
21
|
+
* something notable. The board's case is 34 kitchens still standing and Casa
|
|
22
|
+
* Boema — the one they came for — missing.
|
|
23
|
+
*
|
|
24
|
+
* Interactive: the consumer marks its own module `"use client"`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type MenuDeadEndProps = {
|
|
28
|
+
/**
|
|
29
|
+
* The explanation, composed by the caller.
|
|
30
|
+
*
|
|
31
|
+
* A node rather than a string because it emphasises real names ("**Casa
|
|
32
|
+
* Boema** and six others dropped out"), and assembling that from fragments
|
|
33
|
+
* here would produce translated word-salad in one of the two locales.
|
|
34
|
+
*/
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
/** The widening this offers, e.g. "Raise to 60 lei · 16 kitchens". */
|
|
37
|
+
actionLabel?: string;
|
|
38
|
+
/** Where the widening leads. Preferred over `onAction` — see `MenuLoosenOption.href`. */
|
|
39
|
+
actionHref?: string;
|
|
40
|
+
onAction?: () => void;
|
|
41
|
+
linkComponent?: MenuLinkComponent;
|
|
42
|
+
className?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function MenuDeadEnd({
|
|
46
|
+
children,
|
|
47
|
+
actionLabel,
|
|
48
|
+
actionHref,
|
|
49
|
+
onAction,
|
|
50
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
51
|
+
className,
|
|
52
|
+
}: MenuDeadEndProps) {
|
|
53
|
+
const actionClass =
|
|
54
|
+
"inline-flex h-7 shrink-0 items-center rounded-full bg-foreground px-3 text-[12.5px] font-bold text-background transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none lg:ml-auto";
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={cn(
|
|
59
|
+
"mt-4 flex flex-wrap items-center gap-3 rounded-[14px] bg-muted p-3.5 lg:flex-nowrap lg:gap-4 lg:px-[18px] lg:py-4",
|
|
60
|
+
className
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
<Info className="h-[18px] w-[18px] shrink-0 text-muted-foreground" aria-hidden />
|
|
64
|
+
<p className="max-w-[560px] text-[13px] leading-[1.55] text-muted-foreground [&_b]:font-bold [&_b]:text-foreground">
|
|
65
|
+
{children}
|
|
66
|
+
</p>
|
|
67
|
+
{actionLabel &&
|
|
68
|
+
(actionHref ? (
|
|
69
|
+
<LinkComponent href={actionHref} className={actionClass}>
|
|
70
|
+
{actionLabel}
|
|
71
|
+
</LinkComponent>
|
|
72
|
+
) : (
|
|
73
|
+
onAction && (
|
|
74
|
+
<button type="button" onClick={onAction} className={actionClass}>
|
|
75
|
+
{actionLabel}
|
|
76
|
+
</button>
|
|
77
|
+
)
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* MenuLoosenList — one row per active filter, each priced in kitchens.
|
|
85
|
+
*
|
|
86
|
+
* The companion to the panel above: the panel names the single best widening,
|
|
87
|
+
* this lists every one of them so the reader can choose which constraint they
|
|
88
|
+
* care least about. Every row states what it would return, because a filter the
|
|
89
|
+
* reader cannot price is a filter they will not drop.
|
|
90
|
+
*/
|
|
91
|
+
export type MenuLoosenListProps = {
|
|
92
|
+
options: MenuLoosenOption[];
|
|
93
|
+
linkComponent?: MenuLinkComponent;
|
|
94
|
+
className?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function MenuLoosenList({
|
|
98
|
+
options,
|
|
99
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
100
|
+
className,
|
|
101
|
+
}: MenuLoosenListProps) {
|
|
102
|
+
const rowClass =
|
|
103
|
+
"inline-flex h-9 items-center justify-between gap-3 rounded-full border border-border bg-card px-4 text-sm font-semibold transition-colors hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none";
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className={cn("flex flex-col gap-2.5", className)}>
|
|
107
|
+
{options.map((option) => {
|
|
108
|
+
const body = (
|
|
109
|
+
<>
|
|
110
|
+
<span className="min-w-0 truncate">{option.label}</span>
|
|
111
|
+
<span className="shrink-0 font-bold tabular-nums text-accent">{option.gainLabel}</span>
|
|
112
|
+
</>
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return option.href ? (
|
|
116
|
+
<LinkComponent key={option.key} href={option.href} className={rowClass}>
|
|
117
|
+
{body}
|
|
118
|
+
</LinkComponent>
|
|
119
|
+
) : (
|
|
120
|
+
<button key={option.key} type="button" onClick={option.onSelect} className={rowClass}>
|
|
121
|
+
{body}
|
|
122
|
+
</button>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MenuFamilyBlock } from "./menu-family-block";
|
|
3
|
+
import { mockBareFamilyBlock, mockFamilyBlock, mockFormatters, mockGradients } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const labels = {
|
|
6
|
+
venueUnit: (count: number) => `${count} kitchens`,
|
|
7
|
+
more: (count: number) => `+${count} more`,
|
|
8
|
+
priceBand: "kitchen price band",
|
|
9
|
+
browse: "Browse Italian",
|
|
10
|
+
kitchens: {
|
|
11
|
+
openVenue: (name: string) => `Open ${name}`,
|
|
12
|
+
openDish: (name: string) => `See ${name}`,
|
|
13
|
+
openNow: "Open now",
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const meta: Meta<typeof MenuFamilyBlock> = {
|
|
18
|
+
title: "Menu/MenuFamilyBlock",
|
|
19
|
+
component: MenuFamilyBlock,
|
|
20
|
+
tags: ["autodocs"],
|
|
21
|
+
args: {
|
|
22
|
+
family: mockFamilyBlock,
|
|
23
|
+
labels,
|
|
24
|
+
formatCount: mockFormatters.number,
|
|
25
|
+
hue: "oklch(0.56 0.15 38)",
|
|
26
|
+
gradient: mockGradients.pasta,
|
|
27
|
+
},
|
|
28
|
+
decorators: [
|
|
29
|
+
(Story) => (
|
|
30
|
+
<div className="w-[900px] bg-background p-4">
|
|
31
|
+
<Story />
|
|
32
|
+
</div>
|
|
33
|
+
),
|
|
34
|
+
],
|
|
35
|
+
parameters: {
|
|
36
|
+
docs: {
|
|
37
|
+
description: {
|
|
38
|
+
component:
|
|
39
|
+
"The cuisines index's family card. **The mosaic is the ground**: the grid of venue photographs is the background for the entire card, and everything floats on it through two layers — the mosaic wearing the family's hue, and an ink gradient that is absent where the photography is the subject and full where the rows begin. (There was a masked `backdrop-filter` between them until 0.8.11; ten of them down a scroll cost the consumer's menu page 9 ms per frame, so the ink carries the type alone now.) Phone: top to bottom. Desktop: the same layers turned ninety degrees, photo column clear on the left, rows inked on the right, mosaic 3×2 rather than 2×2. The content region is `.dark`-scoped, which is how `MenuKitchenList` and `HueChip` render on the ink without a fork. One cost accepted: thumbnails that sit on a photograph and survive only because the ink is heavy under them.",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default meta;
|
|
46
|
+
type Story = StoryObj<typeof MenuFamilyBlock>;
|
|
47
|
+
|
|
48
|
+
/** A stand-in photograph with enough variance that the ink has something to sit on. */
|
|
49
|
+
function photo(hue: number, label: string) {
|
|
50
|
+
return (
|
|
51
|
+
"data:image/svg+xml;utf8," +
|
|
52
|
+
encodeURIComponent(
|
|
53
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 30% 62%)"/><stop offset="1" stop-color="hsl(${hue} 20% 28%)"/></linearGradient></defs><rect width="480" height="320" fill="url(#g)"/><circle cx="120" cy="90" r="60" fill="hsl(${hue} 40% 80%)" opacity=".6"/><rect x="260" y="180" width="180" height="110" fill="hsl(${hue} 15% 20%)" opacity=".7"/><text x="50%" y="88%" font-family="monospace" font-size="18" fill="#fff" text-anchor="middle">${label}</text></svg>`
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const PHOTOS = [40, 200, 90, 300, 20, 150].map((h, i) => photo(h, `venue ${i + 1}`));
|
|
59
|
+
|
|
60
|
+
export const Default: Story = {
|
|
61
|
+
args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* No photographs: the mosaic ground with the family's initial, never a grey box.
|
|
66
|
+
* A missing photograph is not a broken image, and a placeholder makes a real
|
|
67
|
+
* city look unfinished.
|
|
68
|
+
*/
|
|
69
|
+
export const NoPhotos: Story = {
|
|
70
|
+
args: { family: { ...mockFamilyBlock, photoUrls: [] } },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** No photographs, no venue, no price — one kitchen in the whole city. */
|
|
74
|
+
export const Bare: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
family: mockBareFamilyBlock,
|
|
77
|
+
labels: { ...labels, browse: "Browse Portuguese" },
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The ink runs top to bottom: clear photography under the name, the rows on the inked foot. */
|
|
82
|
+
export const Mobile: Story = {
|
|
83
|
+
args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
|
|
84
|
+
decorators: [
|
|
85
|
+
(Story) => (
|
|
86
|
+
<div className="w-[390px] bg-background p-3">
|
|
87
|
+
<Story />
|
|
88
|
+
</div>
|
|
89
|
+
),
|
|
90
|
+
],
|
|
91
|
+
};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { Wallet } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { HueChip } from "./hue-chip";
|
|
4
|
+
import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
|
|
5
|
+
import { Separator } from "./separator";
|
|
6
|
+
import { Skeleton } from "./skeleton";
|
|
7
|
+
import { MenuKitchenList, type MenuKitchenListLabels } from "./menu-kitchen-list";
|
|
8
|
+
import type {
|
|
9
|
+
MenuFamilyBlockPreview,
|
|
10
|
+
MenuLinkComponent,
|
|
11
|
+
MenuLinkProps,
|
|
12
|
+
} from "./menu-types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* MenuFamilyBlock — the cuisines index's family card. The mosaic is the ground.
|
|
16
|
+
*
|
|
17
|
+
* ## Three layers, and everything floats on them
|
|
18
|
+
*
|
|
19
|
+
* The grid of venue photographs is the background for the ENTIRE card, edge to
|
|
20
|
+
* edge, and the facts sit on it. Three layers do the work:
|
|
21
|
+
*
|
|
22
|
+
* 1. **The mosaic**, full-bleed, wearing the family's hue through a
|
|
23
|
+
* `mix-blend-color` layer.
|
|
24
|
+
* 2. **An ink gradient** — absent where the photography is the subject, full
|
|
25
|
+
* where the rows begin. This is what buys the contrast. There used to be a
|
|
26
|
+
* masked backdrop-filter under it; it was removed for the measurement
|
|
27
|
+
* recorded in `GlassGround` (73 blur layers cost `/menu` 9 ms per scrolled
|
|
28
|
+
* frame on a phone).
|
|
29
|
+
*
|
|
30
|
+
* On the phone the ink runs top to bottom: the name and the action sit on
|
|
31
|
+
* clear photography, the chips and the rows on the inked foot. On desktop the
|
|
32
|
+
* same layers turn ninety degrees — the photo column on the left stays clear,
|
|
33
|
+
* the row column on the right is the inked one. The mosaic
|
|
34
|
+
* runs 3×2 there rather than 2×2, because at 1110px a four-tile grid makes each
|
|
35
|
+
* photograph too big to read as texture.
|
|
36
|
+
*
|
|
37
|
+
* ## The dark scope
|
|
38
|
+
*
|
|
39
|
+
* The card's content region carries the `dark` class, so every token inside —
|
|
40
|
+
* `text-foreground`, `bg-muted`, `border-border` — resolves to its dark-theme
|
|
41
|
+
* value. That is how `MenuKitchenList` and `HueChip` render correctly on the
|
|
42
|
+
* ink without a fork or a "tone" prop: the mechanism the app already uses to
|
|
43
|
+
* mark a region as dark. It also means the card looks the same in both themes,
|
|
44
|
+
* which is right — it is built on photography, and photography is not themed.
|
|
45
|
+
*
|
|
46
|
+
* ## The two costs, stated
|
|
47
|
+
*
|
|
48
|
+
* A `backdrop-filter` over a mask is expensive to composite, and this card is
|
|
49
|
+
* repeated ten times down a scroll. And the thumbnails now sit on a
|
|
50
|
+
* photograph; they survive only because the ink is heavy under them, and on a
|
|
51
|
+
* busy interior shot they will fight. Both were accepted with the design.
|
|
52
|
+
*
|
|
53
|
+
* ## No "most named" item, no venue count badge
|
|
54
|
+
*
|
|
55
|
+
* The family-level "most named" was the first venue's signature relabelled —
|
|
56
|
+
* Italian, Pizza and Mediterranean all claimed "Turkish Poached Eggs" — and
|
|
57
|
+
* the count used to print three times with two values. One count, under the
|
|
58
|
+
* name; the overflow tile says only how many photographs it stands for.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
|
|
62
|
+
export type MenuFamilyBlockLinkProps = MenuLinkProps;
|
|
63
|
+
|
|
64
|
+
function DefaultLink({ href, ...props }: MenuFamilyBlockLinkProps) {
|
|
65
|
+
return <a href={href} {...props} />;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type MenuFamilyBlockLabels = {
|
|
69
|
+
/** Unit under the family name, e.g. "247 kitchens". Pluralised by the caller. */
|
|
70
|
+
venueUnit: (count: number) => string;
|
|
71
|
+
/** The overflow pill over the mosaic, e.g. "+311 more". */
|
|
72
|
+
more: (count: number) => string;
|
|
73
|
+
/**
|
|
74
|
+
* Price-band caption, e.g. "kitchen price band".
|
|
75
|
+
*
|
|
76
|
+
* Never drawn: the band reads as a wallet and a range, and a caption on a
|
|
77
|
+
* pill that short is longer than the fact. It names the pill for a screen
|
|
78
|
+
* reader, which would otherwise be read two numbers and a currency.
|
|
79
|
+
*/
|
|
80
|
+
priceBand: string;
|
|
81
|
+
/** Main action, e.g. "Browse Italian". Sits on the photography — keep it short. */
|
|
82
|
+
browse: string;
|
|
83
|
+
/** Passed through to the venue rows. */
|
|
84
|
+
kitchens: MenuKitchenListLabels;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type MenuFamilyBlockProps = {
|
|
88
|
+
family: MenuFamilyBlockPreview;
|
|
89
|
+
labels: MenuFamilyBlockLabels;
|
|
90
|
+
formatCount?: (value: number) => string;
|
|
91
|
+
/** Finished CSS colour for the chips. */
|
|
92
|
+
hue?: string;
|
|
93
|
+
/** Finished CSS gradient — the family's colour over the mosaic. */
|
|
94
|
+
gradient?: string;
|
|
95
|
+
linkComponent?: MenuLinkComponent;
|
|
96
|
+
className?: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** 2×2 on the phone, 3×2 from `lg`. Tiles past the fourth are desktop-only. */
|
|
100
|
+
const MOSAIC_TILES = 6;
|
|
101
|
+
const PHONE_TILES = 4;
|
|
102
|
+
|
|
103
|
+
/** The card's ground is `PhotoGround`'s, so the two cannot differ. */
|
|
104
|
+
const GROUND = PHOTO_GROUND;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The ink. Absent at the top, heavier at the foot on the phone; absent on the
|
|
108
|
+
* left, full on the right from `lg`. It is the
|
|
109
|
+
* layer the contrast rests on. Literal rather than `var(--scrim)`: that token
|
|
110
|
+
* lives in the app's stylesheet, not this package's, so a DS component reading
|
|
111
|
+
* it renders nothing in Storybook and the other three consumers.
|
|
112
|
+
*/
|
|
113
|
+
const INK =
|
|
114
|
+
"bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.42)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.9)_48%,oklch(0.16_0.012_55_/_0.96)_100%)] lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.38)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.92)_46%,oklch(0.16_0.012_55_/_0.96)_100%)]";
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A second, LOCAL scrim under the name, count and action — the photo column's
|
|
118
|
+
* own, rising from its foot.
|
|
119
|
+
*
|
|
120
|
+
* The global ink is deliberately light where the photography is the subject,
|
|
121
|
+
* and on a bright, warm-tinted picture (a bakery counter under the Romanian
|
|
122
|
+
* red) 30px of white serif sat at about 2.5:1 on it. Darkening the whole ink
|
|
123
|
+
* would have dulled every photograph to fix a corner of some; this darkens
|
|
124
|
+
* only the band the type actually occupies, and leaves the top of the picture
|
|
125
|
+
* clear. Same base colour as the ink, so the two read as one shadow.
|
|
126
|
+
*/
|
|
127
|
+
const TEXT_SCRIM =
|
|
128
|
+
/* `isolate` + `before:-z-10`: a positioned pseudo-element paints ABOVE the
|
|
129
|
+
column's in-flow text unless the column is its own stacking context and
|
|
130
|
+
the layer is ordered beneath. Without `isolate`, `-z-10` would instead
|
|
131
|
+
drop it behind the card's mosaic — inside the article's context. */
|
|
132
|
+
"isolate before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:top-[30%] before:-z-10 before:bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0)_0%,oklch(0.16_0.012_55_/_0.55)_45%,oklch(0.16_0.012_55_/_0.8)_100%)] before:content-['']";
|
|
133
|
+
|
|
134
|
+
export function MenuFamilyBlock({
|
|
135
|
+
family,
|
|
136
|
+
labels,
|
|
137
|
+
formatCount = String,
|
|
138
|
+
hue,
|
|
139
|
+
gradient,
|
|
140
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
141
|
+
className,
|
|
142
|
+
}: MenuFamilyBlockProps) {
|
|
143
|
+
const photos = (family.photoUrls ?? []).slice(0, MOSAIC_TILES);
|
|
144
|
+
const overflow = Math.max(family.venueCount - photos.length, 0);
|
|
145
|
+
const initial = (family.name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
|
|
146
|
+
const venues = family.venues ?? [];
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<article
|
|
150
|
+
className={cn(
|
|
151
|
+
/* `isolate` confines the blend and the backdrop-filter to this card.
|
|
152
|
+
`dark` scopes every token inside to its dark-theme value. */
|
|
153
|
+
"dark relative isolate overflow-hidden rounded-[14px] text-foreground",
|
|
154
|
+
GROUND,
|
|
155
|
+
className
|
|
156
|
+
)}
|
|
157
|
+
>
|
|
158
|
+
{/* ── Layer 1: the photographs, wearing the family's colour ──────── */}
|
|
159
|
+
{/* A 2×2 on the phone, 3×2 from `lg`: at 1110px a four-tile grid makes
|
|
160
|
+
each photograph too big to read as texture. With no photographs at
|
|
161
|
+
all, one slot with the family's initial. */}
|
|
162
|
+
<PhotoGround
|
|
163
|
+
photos={photos.length > 0 ? photos : [undefined]}
|
|
164
|
+
initials={initial}
|
|
165
|
+
mosaicClassName={
|
|
166
|
+
photos.length > 0 ? "grid-cols-2 grid-rows-2 lg:grid-cols-3" : "grid-cols-1"
|
|
167
|
+
}
|
|
168
|
+
phoneTiles={PHONE_TILES}
|
|
169
|
+
gradient={gradient}
|
|
170
|
+
/>
|
|
171
|
+
|
|
172
|
+
{/* ── Layer 2: the ink ────────────────────────────────────────────── */}
|
|
173
|
+
<span aria-hidden className={cn("absolute inset-0", INK)} />
|
|
174
|
+
|
|
175
|
+
{/* ── The content, floating ───────────────────────────────────────── */}
|
|
176
|
+
<div className="relative grid grid-cols-1 lg:grid-cols-[38%_1fr]">
|
|
177
|
+
{/* The photo column. On the phone it is the card's head; on desktop
|
|
178
|
+
the left third, with its stack anchored to the foot. */}
|
|
179
|
+
<div className={cn("relative flex flex-col p-3.5 lg:min-h-[320px] lg:justify-end lg:p-5", TEXT_SCRIM)}>
|
|
180
|
+
{overflow > 0 && (
|
|
181
|
+
<span className="mb-16 self-end rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white lg:mb-3 lg:self-start">
|
|
182
|
+
{labels.more(overflow)}
|
|
183
|
+
</span>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<h3 className="text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[30px]">
|
|
187
|
+
{family.name}
|
|
188
|
+
</h3>
|
|
189
|
+
<span className="mt-0.5 block text-[13px] font-medium tabular-nums text-white/70">
|
|
190
|
+
{labels.venueUnit(family.venueCount)}
|
|
191
|
+
</span>
|
|
192
|
+
|
|
193
|
+
{/* The action, as a translucent pill on the photography. `bg-white/16`
|
|
194
|
+
on the ink rather than `bg-accent`: ten filled accent buttons on
|
|
195
|
+
one page is ten primary actions, and the pill reads as part of the
|
|
196
|
+
picture rather than as a control laid on top of it.
|
|
197
|
+
|
|
198
|
+
No glyph. An arrow read as "leaves the site" beside a label that
|
|
199
|
+
already says where it goes, and a chevron would have made the pill
|
|
200
|
+
one more row: every venue row to its right ends in exactly that
|
|
201
|
+
chevron. The shape and the label are the whole affordance. */}
|
|
202
|
+
<LinkComponent
|
|
203
|
+
href={family.href}
|
|
204
|
+
className="mt-3.5 inline-flex h-10 w-max items-center rounded-full border border-white/20 bg-white/[0.16] px-4 text-[13.5px] font-bold text-white transition-colors hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white motion-reduce:transition-none"
|
|
205
|
+
>
|
|
206
|
+
{labels.browse}
|
|
207
|
+
</LinkComponent>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{/* The inked column: chips, then the rows. */}
|
|
211
|
+
<div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
|
|
212
|
+
<ul className="flex flex-wrap items-center gap-1.5">
|
|
213
|
+
{family.priceBandLabel && (
|
|
214
|
+
/* No hue: a fact about money, not about the family. In the dark
|
|
215
|
+
scope `HueChip`'s `--muted` fallback is the dark pill the design
|
|
216
|
+
draws. */
|
|
217
|
+
<HueChip
|
|
218
|
+
as="li"
|
|
219
|
+
className="font-bold tabular-nums"
|
|
220
|
+
aria-label={`${labels.priceBand}: ${family.priceBandLabel}`}
|
|
221
|
+
icon={<Wallet className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />}
|
|
222
|
+
>
|
|
223
|
+
{family.priceBandLabel}
|
|
224
|
+
</HueChip>
|
|
225
|
+
)}
|
|
226
|
+
{family.chips?.map((chip) => (
|
|
227
|
+
/* Label only. The counts are VENUES beside a dish group's name,
|
|
228
|
+
which every reader takes for dishes. */
|
|
229
|
+
<HueChip key={chip.slug} as="li" hue={hue}>
|
|
230
|
+
{chip.label}
|
|
231
|
+
</HueChip>
|
|
232
|
+
))}
|
|
233
|
+
</ul>
|
|
234
|
+
|
|
235
|
+
{venues.length > 0 && (
|
|
236
|
+
<>
|
|
237
|
+
<Separator className="my-3" />
|
|
238
|
+
<MenuKitchenList
|
|
239
|
+
kitchens={venues}
|
|
240
|
+
labels={labels.kitchens}
|
|
241
|
+
linkComponent={LinkComponent}
|
|
242
|
+
/>
|
|
243
|
+
</>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</article>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─── Skeleton ───────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The block's own loading state, at the block's footprint.
|
|
255
|
+
*
|
|
256
|
+
* The ground is the card's own, so the placeholder is already the dark
|
|
257
|
+
* surface the photographs will land on — nothing flashes from paper to ink.
|
|
258
|
+
* `venues` matches the rows the caller will render, so a block showing three
|
|
259
|
+
* does not settle from a placeholder showing one.
|
|
260
|
+
*/
|
|
261
|
+
export function MenuFamilyBlockSkeleton({
|
|
262
|
+
venues = 3,
|
|
263
|
+
className,
|
|
264
|
+
}: {
|
|
265
|
+
venues?: number;
|
|
266
|
+
className?: string;
|
|
267
|
+
}) {
|
|
268
|
+
return (
|
|
269
|
+
<div
|
|
270
|
+
className={cn("dark relative overflow-hidden rounded-[14px]", GROUND, className)}
|
|
271
|
+
aria-hidden
|
|
272
|
+
>
|
|
273
|
+
<div className="grid grid-cols-1 lg:grid-cols-[38%_1fr]">
|
|
274
|
+
<div className="flex flex-col p-3.5 lg:min-h-[320px] lg:justify-end lg:p-5">
|
|
275
|
+
<Skeleton className="mb-16 h-6 w-[72px] self-end rounded-full lg:mb-3 lg:self-start" />
|
|
276
|
+
<Skeleton className="h-7 w-[60%]" />
|
|
277
|
+
<Skeleton className="mt-2 h-3.5 w-[90px]" />
|
|
278
|
+
<Skeleton className="mt-3.5 h-10 w-[160px] rounded-full" />
|
|
279
|
+
</div>
|
|
280
|
+
<div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
|
|
281
|
+
<div className="flex gap-1.5">
|
|
282
|
+
<Skeleton className="h-7 w-[86px] rounded-full" />
|
|
283
|
+
<Skeleton className="h-7 w-[74px] rounded-full" />
|
|
284
|
+
<Skeleton className="h-7 w-[66px] rounded-full" />
|
|
285
|
+
</div>
|
|
286
|
+
<Separator className="my-3" />
|
|
287
|
+
{Array.from({ length: venues }, (_, index) => (
|
|
288
|
+
<div key={index}>
|
|
289
|
+
{index > 0 && <Separator />}
|
|
290
|
+
<div className="flex items-center gap-3 py-2.5">
|
|
291
|
+
<Skeleton className="h-[46px] w-[46px] rounded-[11px]" />
|
|
292
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
293
|
+
<Skeleton className="h-3.5 w-[46%]" />
|
|
294
|
+
<Skeleton className="h-3 w-[58%]" />
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
))}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|