@bearmenu/ui 0.8.8 → 0.8.10
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-2XCZJWTV.js +121 -0
- package/dist/chunk-5RWTOT5Y.js +72 -0
- package/dist/chunk-62ES6N32.js +27 -0
- package/dist/chunk-AKQ57NXY.js +58 -0
- 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-MZL7CGCL.js +109 -0
- package/dist/chunk-Q6QH52LJ.js +38 -0
- package/dist/chunk-SY6RV476.js +48 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-UDN7UHJW.js +60 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- 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 +110 -0
- package/dist/components/concept-compare.js +238 -0
- 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/date-input.d.ts +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.d.ts +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +472 -175
- 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/glass-ground.d.ts +66 -0
- package/dist/components/glass-ground.js +3 -0
- package/dist/components/hub-surface.d.ts +27 -0
- package/dist/components/hub-surface.js +30 -0
- package/dist/components/hue-chip.d.ts +70 -0
- package/dist/components/hue-chip.js +3 -0
- package/dist/components/kitchen-dossier.d.ts +88 -0
- package/dist/components/kitchen-dossier.js +187 -0
- 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 +99 -0
- package/dist/components/menu-family-block.js +155 -0
- package/dist/components/menu-family-card.d.ts +65 -0
- package/dist/components/menu-family-card.js +79 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +93 -0
- 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.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -0
- 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-intent-tiles.d.ts +31 -0
- package/dist/components/menu-intent-tiles.js +50 -0
- package/dist/components/menu-item.d.ts +127 -0
- package/dist/components/menu-item.js +5 -0
- 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 +90 -0
- package/dist/components/menu-mocks.js +504 -0
- package/dist/components/menu-types.d.ts +421 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-controls.js +2 -2
- package/dist/components/motion-icon-set.js +21 -9
- package/dist/components/motion-icon.d.ts +22 -1
- package/dist/components/motion-icon.js +1 -1
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- 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 +2 -2
- package/dist/components/place-types.d.ts +112 -1
- 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/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/snap-rail.d.ts +48 -5
- package/dist/components/snap-rail.js +3 -51
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.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 +38 -0
- package/dist/components/venue-grade-dimensions.js +3 -0
- package/dist/components/venue-quotes.d.ts +32 -0
- package/dist/components/venue-quotes.js +3 -0
- package/dist/components/venue-wall.d.ts +41 -0
- package/dist/components/venue-wall.js +118 -0
- package/dist/components/weekday-theme-banner.d.ts +70 -0
- package/dist/components/weekday-theme-banner.js +127 -0
- package/dist/components/weekend-panel.js +2 -2
- package/package.json +1 -1
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare-rail.tsx +131 -0
- package/src/components/concept-compare.stories.tsx +172 -0
- package/src/components/concept-compare.tsx +443 -0
- 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/event-compact-row.tsx +1 -1
- package/src/components/event-detail-panel.stories.tsx +205 -97
- package/src/components/event-detail-panel.test.tsx +181 -0
- package/src/components/event-detail-panel.tsx +703 -185
- package/src/components/explainer-steps.stories.tsx +46 -0
- package/src/components/explainer-steps.tsx +74 -0
- package/src/components/glass-ground.tsx +129 -0
- package/src/components/hub-surface.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +50 -0
- 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 +322 -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 +314 -0
- package/src/components/menu-family-card.stories.tsx +120 -0
- package/src/components/menu-family-card.tsx +191 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- 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.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -0
- 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-intent-tiles.stories.tsx +54 -0
- package/src/components/menu-intent-tiles.tsx +76 -0
- package/src/components/menu-item.tsx +416 -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 +592 -0
- package/src/components/menu-types.ts +442 -0
- package/src/components/motion-icon-controls.tsx +11 -11
- package/src/components/motion-icon-set.tsx +36 -8
- package/src/components/motion-icon.test.tsx +23 -1
- package/src/components/motion-icon.tsx +20 -0
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/photo-ground.tsx +147 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/qa-block.stories.tsx +43 -0
- package/src/components/qa-block.tsx +60 -0
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/snap-rail.tsx +133 -9
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- 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.stories.tsx +83 -0
- package/src/components/venue-grade-dimensions.tsx +91 -0
- package/src/components/venue-quotes.stories.tsx +59 -0
- package/src/components/venue-quotes.tsx +64 -0
- package/src/components/venue-wall.stories.tsx +78 -0
- package/src/components/venue-wall.tsx +185 -0
- package/src/components/weekday-theme-banner.stories.tsx +113 -0
- package/src/components/weekday-theme-banner.tsx +231 -0
- package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { mockVenues } from "./menu-mocks";
|
|
3
|
+
import { OpenNowList } from "./open-now-list";
|
|
4
|
+
|
|
5
|
+
const metaLabels = {
|
|
6
|
+
"the-copper-yard": "pub · Centru",
|
|
7
|
+
"salt-and-stone": "restaurant · Centru",
|
|
8
|
+
"the-quiet-hours": "coffee · Centru",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const summaryLabels = {
|
|
12
|
+
"the-copper-yard": "Wings, truffle fries, ribs",
|
|
13
|
+
"salt-and-stone": "Pasta, antipasti, tiramisu",
|
|
14
|
+
"the-quiet-hours": "Filter, flat white, pastries",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const priceBandLabels = {
|
|
18
|
+
"the-copper-yard": "19—62 lei",
|
|
19
|
+
"salt-and-stone": "38—84 lei",
|
|
20
|
+
"the-quiet-hours": "8—26 lei",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const meta: Meta<typeof OpenNowList> = {
|
|
24
|
+
title: "Menu/OpenNowList",
|
|
25
|
+
component: OpenNowList,
|
|
26
|
+
tags: ["autodocs"],
|
|
27
|
+
decorators: [
|
|
28
|
+
(Story) => (
|
|
29
|
+
<div className="w-[620px] bg-background p-4">
|
|
30
|
+
<Story />
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
parameters: {
|
|
35
|
+
docs: {
|
|
36
|
+
description: {
|
|
37
|
+
component:
|
|
38
|
+
"Who is still serving, and until when. At half past ten this beats every other sort, which is why it is its own surface rather than a filter chip: the question is not \"what is good\" but \"what is possible\", and every other module answers the first. The closing time arrives pre-formatted because a trading day crossing midnight needs the venue's timezone and the reader's clock. The live dot is never the only signal — the pill carries the same fact in words.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default meta;
|
|
45
|
+
type Story = StoryObj<typeof OpenNowList>;
|
|
46
|
+
|
|
47
|
+
export const Default: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
venues: mockVenues.slice(0, 3),
|
|
50
|
+
untilLabel: "until",
|
|
51
|
+
metaLabels,
|
|
52
|
+
summaryLabels,
|
|
53
|
+
priceBandLabels,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Without the summary line — a tighter row for a narrow column. */
|
|
58
|
+
export const Compact: Story = {
|
|
59
|
+
args: { venues: mockVenues.slice(0, 3), untilLabel: "until", metaLabels, priceBandLabels },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** A venue past closing has no pill; the row still lists. */
|
|
63
|
+
export const MixedHours: Story = {
|
|
64
|
+
args: { venues: mockVenues, untilLabel: "until", metaLabels, priceBandLabels },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const Mobile: Story = {
|
|
68
|
+
args: { venues: mockVenues.slice(0, 3), untilLabel: "until", metaLabels, summaryLabels },
|
|
69
|
+
decorators: [
|
|
70
|
+
(Story) => (
|
|
71
|
+
<div className="w-[390px] bg-background p-3">
|
|
72
|
+
<Story />
|
|
73
|
+
</div>
|
|
74
|
+
),
|
|
75
|
+
],
|
|
76
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { ChevronRight, Clock } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { Separator } from "./separator";
|
|
4
|
+
import type { MenuVenuePreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* OpenNowList — who is still serving, and until when.
|
|
8
|
+
*
|
|
9
|
+
* At half past ten this beats every other sort, which is why it is its own
|
|
10
|
+
* surface rather than a filter chip: the question is not "what is good" but
|
|
11
|
+
* "what is possible", and every other module on a hub answers the first one.
|
|
12
|
+
*
|
|
13
|
+
* The closing time arrives pre-formatted on the view model (`closes_at_label`)
|
|
14
|
+
* because the venue's own trading day can cross midnight — a bar closing at
|
|
15
|
+
* 04:00 closes on the following date — and resolving that needs the venue's
|
|
16
|
+
* timezone and the reader's clock, neither of which the DS has.
|
|
17
|
+
*
|
|
18
|
+
* The live dot is never the only signal that a venue is open: the closing pill
|
|
19
|
+
* carries the same fact in words, so the row reads without colour.
|
|
20
|
+
*
|
|
21
|
+
* Server-safe.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
|
|
25
|
+
export type OpenNowListLinkProps = MenuLinkProps;
|
|
26
|
+
|
|
27
|
+
function DefaultLink({ href, ...props }: OpenNowListLinkProps) {
|
|
28
|
+
return <a href={href} {...props} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type OpenNowListProps = {
|
|
32
|
+
venues: MenuVenuePreview[];
|
|
33
|
+
/** Prefix on the closing pill, e.g. "until". */
|
|
34
|
+
untilLabel: string;
|
|
35
|
+
/** Composed meta line per venue, keyed by place slug, e.g. "pub · Centru". */
|
|
36
|
+
metaLabels?: Record<string, string>;
|
|
37
|
+
/** What the kitchen is still sending out, keyed by place slug. */
|
|
38
|
+
summaryLabels?: Record<string, string>;
|
|
39
|
+
/** Composed price band per venue, keyed by place slug, e.g. "19—62 lei". */
|
|
40
|
+
priceBandLabels?: Record<string, string>;
|
|
41
|
+
linkComponent?: MenuLinkComponent;
|
|
42
|
+
className?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function OpenNowList({
|
|
46
|
+
venues,
|
|
47
|
+
untilLabel,
|
|
48
|
+
metaLabels,
|
|
49
|
+
summaryLabels,
|
|
50
|
+
priceBandLabels,
|
|
51
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
52
|
+
className,
|
|
53
|
+
}: OpenNowListProps) {
|
|
54
|
+
return (
|
|
55
|
+
<ul className={cn("flex flex-col", className)}>
|
|
56
|
+
{venues.map((venue, index) => {
|
|
57
|
+
const { place } = venue;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<li key={place.slug}>
|
|
61
|
+
{index > 0 && <Separator />}
|
|
62
|
+
<LinkComponent
|
|
63
|
+
href={venue.href}
|
|
64
|
+
className="flex flex-wrap items-center gap-x-3 gap-y-1.5 py-3 transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
65
|
+
>
|
|
66
|
+
{/* 56px, not 40. At 40 the venue photograph was too small to be
|
|
67
|
+
anything — a smudge of colour rather than a room you might
|
|
68
|
+
recognise — which is the only reason it is on the row. */}
|
|
69
|
+
<span className="relative h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-[repeating-linear-gradient(135deg,oklch(0.78_0.018_55)_0_8px,oklch(0.84_0.012_58)_8px_16px)]">
|
|
70
|
+
{place.thumbnail_url && (
|
|
71
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
72
|
+
<img
|
|
73
|
+
src={place.thumbnail_url}
|
|
74
|
+
alt=""
|
|
75
|
+
className="h-full w-full object-cover"
|
|
76
|
+
loading="lazy"
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
<i
|
|
80
|
+
aria-hidden
|
|
81
|
+
className="absolute right-1 top-1 h-[7px] w-[7px] rounded-full bg-[var(--free,currentColor)] ring-2 ring-white/80"
|
|
82
|
+
/>
|
|
83
|
+
</span>
|
|
84
|
+
|
|
85
|
+
<span className="min-w-0 flex-1">
|
|
86
|
+
<span className="block text-sm font-bold leading-snug tracking-[-0.01em]">
|
|
87
|
+
{place.name}
|
|
88
|
+
</span>
|
|
89
|
+
{metaLabels?.[place.slug] && (
|
|
90
|
+
<span className="mt-px block truncate text-[11.5px] text-muted-foreground">
|
|
91
|
+
{metaLabels[place.slug]}
|
|
92
|
+
</span>
|
|
93
|
+
)}
|
|
94
|
+
</span>
|
|
95
|
+
|
|
96
|
+
{summaryLabels?.[place.slug] && (
|
|
97
|
+
<span className="order-last w-full truncate text-[12.5px] text-muted-foreground lg:order-none lg:w-auto lg:flex-1">
|
|
98
|
+
{summaryLabels[place.slug]}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{/* Price over hours on the phone — side by side they squeezed a
|
|
103
|
+
three-line venue name; one row again from lg. */}
|
|
104
|
+
{(venue.closes_at_label || priceBandLabels?.[place.slug]) && (
|
|
105
|
+
<span className="flex shrink-0 flex-col items-end gap-1 lg:flex-row lg:items-center lg:gap-3">
|
|
106
|
+
{priceBandLabels?.[place.slug] && (
|
|
107
|
+
<span className="text-[12.5px] tabular-nums text-muted-foreground">
|
|
108
|
+
{priceBandLabels[place.slug]}
|
|
109
|
+
</span>
|
|
110
|
+
)}
|
|
111
|
+
{venue.closes_at_label && (
|
|
112
|
+
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-[var(--free-subtle,var(--accent-subtle))] px-2.5 text-[11.5px] font-semibold tabular-nums">
|
|
113
|
+
<Clock className="h-[11px] w-[11px]" aria-hidden />
|
|
114
|
+
{`${untilLabel} ${venue.closes_at_label}`}
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
</span>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
121
|
+
</LinkComponent>
|
|
122
|
+
</li>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</ul>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PhotoGround — venue photography as the ground of a card.
|
|
5
|
+
*
|
|
6
|
+
* Three cards are built on photographs rather than on panels: the hub's family
|
|
7
|
+
* card, the cuisines index's family block and the concept card. Each had grown
|
|
8
|
+
* its own copy of the same stack — a dark ground, a grid of `<img>`s with an
|
|
9
|
+
* initial for the missing ones, a hue layer, an overlay — and the one rule that
|
|
10
|
+
* matters for all of them (the tint has to be `mix-blend-color`, the ground
|
|
11
|
+
* has to be a fixed literal) was written three times. This is the stack, once.
|
|
12
|
+
*
|
|
13
|
+
* ## What it owns and what it leaves to the card
|
|
14
|
+
*
|
|
15
|
+
* It owns the photographs and their colour: the ground, the tiles, the fallback
|
|
16
|
+
* initial, the family's hue and an optional uniform dark overlay. It does NOT
|
|
17
|
+
* own the glass — the scrims and frosted bands that carry a card's type run in
|
|
18
|
+
* a different direction on every card (down the family block's phone, across
|
|
19
|
+
* its desktop, in from both ends of the concept card) and are the card's own.
|
|
20
|
+
*
|
|
21
|
+
* ## Two layouts
|
|
22
|
+
*
|
|
23
|
+
* `mosaic` tiles every photograph into a grid the caller shapes; `stack` shows
|
|
24
|
+
* ONE of them, `active`, and cross-fades to the next — a card whose ground
|
|
25
|
+
* follows a swipe. Same slots, same fallback, same tint either way.
|
|
26
|
+
*
|
|
27
|
+
* ## Why the ground is a literal, and why the tint is `color`
|
|
28
|
+
*
|
|
29
|
+
* The ground is the backdrop a blend or blur samples before a lazy image lands
|
|
30
|
+
* and the tile an empty slot shows, so it must not change with the theme —
|
|
31
|
+
* photography is not themed, and a card that flashed paper-white behind its
|
|
32
|
+
* photographs in light mode was wrong in exactly the mode most people use.
|
|
33
|
+
* `mix-blend-mode: color` takes hue and chroma from the tint layer and
|
|
34
|
+
* luminance from the photograph, so the room keeps its structure and the
|
|
35
|
+
* family keeps its colour; a plain overlay would have muddied both.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** The ground under the photography. Fixed, and deliberately not a token. */
|
|
39
|
+
export const PHOTO_GROUND = "bg-[oklch(0.21_0.012_55)]";
|
|
40
|
+
|
|
41
|
+
/** The uniform dark overlay a card may put over every photograph. */
|
|
42
|
+
const OVERLAY = "absolute inset-0 bg-[oklch(0.16_0.012_55_/_0.5)]";
|
|
43
|
+
|
|
44
|
+
export type PhotoGroundProps = {
|
|
45
|
+
/** One per slot. An absent one draws the slot's initial on the ground. */
|
|
46
|
+
photos: (string | undefined)[];
|
|
47
|
+
/** The fallback glyph per slot — a venue's first letter. One is reused for all. */
|
|
48
|
+
initials: string | string[];
|
|
49
|
+
/** `mosaic` tiles every slot; `stack` shows `active` and cross-fades. */
|
|
50
|
+
mode?: "mosaic" | "stack";
|
|
51
|
+
/** `stack` only: which slot is showing. */
|
|
52
|
+
active?: number;
|
|
53
|
+
/** `mosaic` only: the grid template, e.g. `"grid-cols-2 grid-rows-2 lg:grid-cols-3"`. */
|
|
54
|
+
mosaicClassName?: string;
|
|
55
|
+
/** `mosaic` only: slots from this index on are desktop-only tiles. */
|
|
56
|
+
phoneTiles?: number;
|
|
57
|
+
/** Finished CSS gradient — the family's colour, laid over the photographs. */
|
|
58
|
+
gradient?: string;
|
|
59
|
+
/** A uniform dark overlay on every photograph. */
|
|
60
|
+
overlay?: boolean;
|
|
61
|
+
/** Extra classes on the overlay, e.g. a hover that lifts it. */
|
|
62
|
+
overlayClassName?: string;
|
|
63
|
+
/** How eagerly the first photograph loads. `stack` grounds are above the fold. */
|
|
64
|
+
loading?: "lazy" | "eager";
|
|
65
|
+
className?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function PhotoGround({
|
|
69
|
+
photos,
|
|
70
|
+
initials,
|
|
71
|
+
mode = "mosaic",
|
|
72
|
+
active = 0,
|
|
73
|
+
mosaicClassName,
|
|
74
|
+
phoneTiles,
|
|
75
|
+
gradient,
|
|
76
|
+
overlay,
|
|
77
|
+
overlayClassName,
|
|
78
|
+
loading = "lazy",
|
|
79
|
+
className,
|
|
80
|
+
}: PhotoGroundProps) {
|
|
81
|
+
const initialOf = (index: number) =>
|
|
82
|
+
typeof initials === "string" ? initials : (initials[index] ?? initials[0] ?? "");
|
|
83
|
+
const hasAnyPhoto = photos.some(Boolean);
|
|
84
|
+
|
|
85
|
+
const slot = (index: number, extra?: string) => {
|
|
86
|
+
const src = photos[index];
|
|
87
|
+
return (
|
|
88
|
+
<span key={index} className={cn("relative block overflow-hidden", extra)}>
|
|
89
|
+
{src ? (
|
|
90
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
91
|
+
<img
|
|
92
|
+
src={src}
|
|
93
|
+
alt=""
|
|
94
|
+
loading={index === 0 ? loading : "lazy"}
|
|
95
|
+
decoding="async"
|
|
96
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
97
|
+
/>
|
|
98
|
+
) : (
|
|
99
|
+
/* Never a grey box: the ground with the venue's initial. A missing
|
|
100
|
+
photograph is not a broken image, and drawing one as a placeholder
|
|
101
|
+
makes a real city look unfinished. */
|
|
102
|
+
<span className="absolute inset-0 flex items-center justify-center">
|
|
103
|
+
<span className="font-serif text-[64px] font-semibold leading-none text-white/15">
|
|
104
|
+
{initialOf(index)}
|
|
105
|
+
</span>
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</span>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<span aria-hidden className={cn("absolute inset-0 block", PHOTO_GROUND, className)}>
|
|
114
|
+
{mode === "mosaic" ? (
|
|
115
|
+
<span className={cn("absolute inset-0 grid gap-px", mosaicClassName)}>
|
|
116
|
+
{photos.map((_, index) =>
|
|
117
|
+
slot(index, phoneTiles !== undefined && index >= phoneTiles ? "hidden lg:block" : undefined)
|
|
118
|
+
)}
|
|
119
|
+
</span>
|
|
120
|
+
) : (
|
|
121
|
+
/* Stacked, not swapped, so the change is a cross-fade and not a flash
|
|
122
|
+
of ground between two images. */
|
|
123
|
+
photos.map((_, index) =>
|
|
124
|
+
slot(
|
|
125
|
+
index,
|
|
126
|
+
cn(
|
|
127
|
+
"absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none",
|
|
128
|
+
index === active ? "opacity-100" : "opacity-0"
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* The family's colour, one layer over the whole ground. Skipped when
|
|
135
|
+
there is nothing to colour — a flat tint over the bare ground reads as
|
|
136
|
+
a mistake rather than as a family. */}
|
|
137
|
+
{gradient && hasAnyPhoto && (
|
|
138
|
+
<span
|
|
139
|
+
className="absolute inset-0 opacity-[0.85] mix-blend-color"
|
|
140
|
+
style={{ backgroundImage: gradient }}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{overlay && <span className={cn(OVERLAY, overlayClassName)} />}
|
|
145
|
+
</span>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -31,6 +31,13 @@ export type PlacePreview = {
|
|
|
31
31
|
landmarks?: Array<{ name: string; distance_meters?: number }>
|
|
32
32
|
areas?: Array<{ name: string }>
|
|
33
33
|
busyness?: Array<{ hour: number; score: number }>
|
|
34
|
+
/**
|
|
35
|
+
* The review distillation. Drives every menu-hub surface that speaks for a
|
|
36
|
+
* venue — `<KitchenDossier>`, `<MenuKitchenList>`, `<OpenNowList>` —
|
|
37
|
+
* because the signature dish, the pull-quotes and the graded dimensions all
|
|
38
|
+
* live here rather than on the menu itself.
|
|
39
|
+
*/
|
|
40
|
+
reviews_digest?: PlaceReviewsDigest
|
|
34
41
|
type?: string
|
|
35
42
|
business_status?: 'operational' | 'closed_temporarily' | 'closed_permanently'
|
|
36
43
|
phone?: string
|
|
@@ -60,6 +67,111 @@ export type PlaceSchedule = Record<WeekdayKey, PlaceHourBlock[]>
|
|
|
60
67
|
export type PlacePriceRange = {
|
|
61
68
|
price_range_low?: number
|
|
62
69
|
price_range_high?: number
|
|
70
|
+
/** Median price across the place's menu, in the local currency. */
|
|
71
|
+
median_price?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Menu items are priced per size; a single-size item still ships one entry.
|
|
76
|
+
*
|
|
77
|
+
* Defined here rather than in `menu-types.ts` because `PlaceMustTry` carries
|
|
78
|
+
* it, and `menu-types.ts` already imports from this file — keeping the
|
|
79
|
+
* definition here leaves that import one-directional. `menu-types.ts`
|
|
80
|
+
* re-exports it, so menu code never reaches into place types for it.
|
|
81
|
+
*/
|
|
82
|
+
export type MenuSizeAndPrice = {
|
|
83
|
+
/** Grams or millilitres. */
|
|
84
|
+
size: number
|
|
85
|
+
price: number
|
|
86
|
+
discounted_price?: number
|
|
87
|
+
discount_percentage?: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A digest string with the reviews that back it. `review_ids.length` is the mention count. */
|
|
91
|
+
export type CitedText = { text: string; review_ids?: string[] }
|
|
92
|
+
|
|
93
|
+
/** A digest enum value with the reviews that back it. The value is a language-neutral code. */
|
|
94
|
+
export type CitedValue = { value: string; review_ids?: string[] }
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One dish or drink the reviews single out.
|
|
98
|
+
*
|
|
99
|
+
* `mentions` and `score` come from the item census and are only served by the
|
|
100
|
+
* place DETAIL endpoint — a listing carries the item without them. Surfaces
|
|
101
|
+
* that need a count from a list fall back to `review_ids.length`, which is
|
|
102
|
+
* present in both. `strength`, `why` and `review_ids` are absent for census
|
|
103
|
+
* items the prose digest never mentioned, so none of them can be assumed.
|
|
104
|
+
*/
|
|
105
|
+
export type PlaceMustTry = {
|
|
106
|
+
name: string
|
|
107
|
+
why?: string
|
|
108
|
+
strength?: 'signature' | 'frequent' | 'occasional'
|
|
109
|
+
/** Deep-link slug. Present only when the item matched a live menu row. */
|
|
110
|
+
slug?: string
|
|
111
|
+
sizes_and_prices?: MenuSizeAndPrice[]
|
|
112
|
+
review_ids?: string[]
|
|
113
|
+
/** 0–100 census sentiment. Detail endpoint only; 3+ mentions required. */
|
|
114
|
+
score?: number
|
|
115
|
+
/** Censused review mentions of this item. Detail endpoint only. */
|
|
116
|
+
mentions?: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Per-dimension 0–100 review scores.
|
|
121
|
+
*
|
|
122
|
+
* A dimension the reviews never mention is ABSENT, not zero — so a surface
|
|
123
|
+
* drawing these must render what it is given rather than a fixed row of seven.
|
|
124
|
+
* Exactly one of `food` / `coffee_quality` / `drinks_quality` is scored per
|
|
125
|
+
* place: a coffee bar has no food score and a restaurant has no coffee one.
|
|
126
|
+
*/
|
|
127
|
+
export type PlaceGradeDimensions = {
|
|
128
|
+
food?: number
|
|
129
|
+
coffee_quality?: number
|
|
130
|
+
drinks_quality?: number
|
|
131
|
+
value?: number
|
|
132
|
+
service?: number
|
|
133
|
+
cleanliness?: number
|
|
134
|
+
ambiance?: number
|
|
135
|
+
portion?: number
|
|
136
|
+
consistency?: number
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type PlaceGrade = {
|
|
140
|
+
/** 1.0–10.0. */
|
|
141
|
+
overall?: number
|
|
142
|
+
/**
|
|
143
|
+
* Backend recommendation to show the number at all. When false the caller
|
|
144
|
+
* should draw a "building confidence" state instead — the DS renders what it
|
|
145
|
+
* is told, it does not apply the rule.
|
|
146
|
+
*/
|
|
147
|
+
show_number?: boolean
|
|
148
|
+
/** Locale-resolved band label, e.g. "Foarte bun". Already translated upstream. */
|
|
149
|
+
descriptor?: string
|
|
150
|
+
confidence?: 'low' | 'medium' | 'high'
|
|
151
|
+
capped?: boolean
|
|
152
|
+
dimensions?: PlaceGradeDimensions
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type PlaceReviewsDigest = {
|
|
156
|
+
grade?: PlaceGrade
|
|
157
|
+
grade_note?: string
|
|
158
|
+
tagline?: CitedText
|
|
159
|
+
verdict?: CitedText
|
|
160
|
+
caveat?: CitedText
|
|
161
|
+
/** 1–3 standout positives. The pull-quotes on `<KitchenDossier>`. */
|
|
162
|
+
highlights?: CitedText[]
|
|
163
|
+
must_try?: PlaceMustTry[]
|
|
164
|
+
/** Vibe codes (cozy, quiet, family_friendly, …). Localised by the caller. */
|
|
165
|
+
tags?: CitedValue[]
|
|
166
|
+
dietary?: CitedValue[]
|
|
167
|
+
/** small | moderate | generous */
|
|
168
|
+
portion_size?: CitedValue
|
|
169
|
+
/** cheap | fair | mid_range | splurge */
|
|
170
|
+
value?: CitedValue
|
|
171
|
+
/** quiet | moderate | loud */
|
|
172
|
+
noise_level?: CitedValue
|
|
173
|
+
/** Text reviews behind the digest. */
|
|
174
|
+
review_count?: number
|
|
63
175
|
}
|
|
64
176
|
|
|
65
177
|
/** Labels passed to `<PlaceCard>`. All translation happens in the caller. */
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { QaBlock } from "./qa-block";
|
|
3
|
+
import { mockQa } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof QaBlock> = {
|
|
6
|
+
title: "Menu/QaBlock",
|
|
7
|
+
component: QaBlock,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
args: { items: mockQa, heading: "Questions people ask" },
|
|
10
|
+
decorators: [
|
|
11
|
+
(Story) => (
|
|
12
|
+
<div className="w-[1100px] bg-background p-4">
|
|
13
|
+
<Story />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
parameters: {
|
|
18
|
+
docs: {
|
|
19
|
+
description: {
|
|
20
|
+
component:
|
|
21
|
+
"Questions answered from the numbers, as static prose — **never an accordion**. This block is the visible half of the page's `FAQPage` markup, and structured data must mirror content the reader can actually see; collapsing it hides every answer from the crawler's first paint and buys nothing, since three short answers are cheaper to scroll past than to expand. The caller renders the JSON-LD from the same array it passes here, so the markup and the visible answers cannot drift.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
type Story = StoryObj<typeof QaBlock>;
|
|
29
|
+
|
|
30
|
+
export const Default: Story = {};
|
|
31
|
+
|
|
32
|
+
/** Under a page that already supplies its own section heading. */
|
|
33
|
+
export const NoHeading: Story = { args: { heading: undefined } };
|
|
34
|
+
|
|
35
|
+
export const Mobile: Story = {
|
|
36
|
+
decorators: [
|
|
37
|
+
(Story) => (
|
|
38
|
+
<div className="w-[390px] bg-background p-3">
|
|
39
|
+
<Story />
|
|
40
|
+
</div>
|
|
41
|
+
),
|
|
42
|
+
],
|
|
43
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
import type { QaItem } from "./menu-types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* QaBlock — questions answered from the numbers, as static prose.
|
|
6
|
+
*
|
|
7
|
+
* ## Never an accordion
|
|
8
|
+
*
|
|
9
|
+
* This block exists to be crawled as much as read: it is the visible half of
|
|
10
|
+
* the page's `FAQPage` markup, and the rule is that structured data must mirror
|
|
11
|
+
* content the reader can actually see. Collapsing it hides every answer from
|
|
12
|
+
* the crawler's first paint and buys nothing — three short answers are cheaper
|
|
13
|
+
* to scroll past than to expand.
|
|
14
|
+
*
|
|
15
|
+
* The caller renders the JSON-LD from the SAME array it passes here, so the
|
|
16
|
+
* markup and the visible answers cannot drift.
|
|
17
|
+
*
|
|
18
|
+
* ## Three across, then one
|
|
19
|
+
*
|
|
20
|
+
* Answers are two or three lines each, so a single column wastes the desktop
|
|
21
|
+
* measure and a two-column grid leaves an orphan. On the phone it is one
|
|
22
|
+
* column, in order.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type QaBlockProps = {
|
|
26
|
+
items: QaItem[];
|
|
27
|
+
/** Section heading, e.g. "Questions people ask". Omit where the page supplies one. */
|
|
28
|
+
heading?: string;
|
|
29
|
+
/** Heading level for the questions. Defaults to `h3`, under a page's `h2`. */
|
|
30
|
+
questionAs?: "h3" | "h4";
|
|
31
|
+
className?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function QaBlock({ items, heading, questionAs: Question = "h3", className }: QaBlockProps) {
|
|
35
|
+
return (
|
|
36
|
+
<section className={cn(className)}>
|
|
37
|
+
{heading && (
|
|
38
|
+
<h2 className="text-[15px] font-bold leading-tight tracking-[-0.01em]">{heading}</h2>
|
|
39
|
+
)}
|
|
40
|
+
|
|
41
|
+
<div
|
|
42
|
+
className={cn(
|
|
43
|
+
"grid grid-cols-1 gap-[18px] lg:grid-cols-3 lg:gap-[26px]",
|
|
44
|
+
heading && "mt-3.5"
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
{items.map((item) => (
|
|
48
|
+
<div key={item.question}>
|
|
49
|
+
<Question className="text-sm font-bold leading-[1.35] tracking-[-0.01em] lg:text-[15px]">
|
|
50
|
+
{item.question}
|
|
51
|
+
</Question>
|
|
52
|
+
<p className="mt-[7px] text-[13px] leading-[1.65] text-muted-foreground [text-wrap:pretty] lg:text-[13.5px]">
|
|
53
|
+
{item.answer}
|
|
54
|
+
</p>
|
|
55
|
+
</div>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
</section>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Badge } from "./badge";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* "Signature" / "Mentioned" — the marker for a menu item the review digest
|
|
6
|
+
* backs, shared by `<MenuItem>` and the dish/drink cards so the same claim
|
|
7
|
+
* never renders two different ways.
|
|
8
|
+
*
|
|
9
|
+
* When `onEvidence` is supplied the chip becomes a button that opens the
|
|
10
|
+
* reviews behind it, and takes a dashed outline to read as interactive. That is
|
|
11
|
+
* the whole differentiator: every competitor badges items ("Nou", "NEW"), none
|
|
12
|
+
* can show you why. Without evidence it degrades to a plain label rather than
|
|
13
|
+
* offering a tap that would reveal nothing.
|
|
14
|
+
*
|
|
15
|
+
* `stopPropagation` matters on every host: the row is a `role="button"` and the
|
|
16
|
+
* cards carry a stretched crawlable `<a>` overlay, so an un-stopped click would
|
|
17
|
+
* also open the item drawer. On the cards it must additionally out-rank that
|
|
18
|
+
* overlay's `z-[1]`, hence `relative z-[2]` from the caller.
|
|
19
|
+
*
|
|
20
|
+
* `surface="image"` is for card covers, where the chip sits over the photo
|
|
21
|
+
* rather than in the caption block. The accent-on-canvas palette has no
|
|
22
|
+
* contrast guarantee over an arbitrary photo, so it swaps to the same frosted
|
|
23
|
+
* fill the place badge uses there and keeps the dashed edge — which is what
|
|
24
|
+
* says "there is evidence behind this" — in the inverted colour.
|
|
25
|
+
*
|
|
26
|
+
* The label is passed in: the DS owns no translation keys.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type SignatureChipProps = {
|
|
30
|
+
/** Already translated. `strong` → "Signature", `weak` → "Mentioned". */
|
|
31
|
+
label: string;
|
|
32
|
+
onEvidence?: () => void;
|
|
33
|
+
/** `image` = over a card cover; `canvas` = in a caption or row. */
|
|
34
|
+
surface?: "canvas" | "image";
|
|
35
|
+
className?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function SignatureChip({
|
|
39
|
+
label,
|
|
40
|
+
onEvidence,
|
|
41
|
+
surface = "canvas",
|
|
42
|
+
className,
|
|
43
|
+
}: SignatureChipProps) {
|
|
44
|
+
const shared = "h-4 shrink-0 px-1.5 text-[10px] font-medium uppercase tracking-wider";
|
|
45
|
+
const onImage = surface === "image";
|
|
46
|
+
|
|
47
|
+
if (!onEvidence) {
|
|
48
|
+
return (
|
|
49
|
+
<Badge variant={onImage ? "overlay" : "secondary"} className={cn(shared, className)}>
|
|
50
|
+
{label}
|
|
51
|
+
</Badge>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={(event) => {
|
|
59
|
+
event.stopPropagation();
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
onEvidence();
|
|
62
|
+
}}
|
|
63
|
+
className={cn(
|
|
64
|
+
shared,
|
|
65
|
+
"inline-flex items-center rounded-full border border-dashed",
|
|
66
|
+
onImage
|
|
67
|
+
? "border-background/70 bg-foreground/70 text-background backdrop-blur-sm hover:bg-foreground/85"
|
|
68
|
+
: "border-accent/40 text-accent hover:bg-accent/10",
|
|
69
|
+
"focus-visible:outline-none focus-visible:ring-2",
|
|
70
|
+
onImage ? "focus-visible:ring-background/70" : "focus-visible:ring-accent/40",
|
|
71
|
+
className
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{label}
|
|
75
|
+
</button>
|
|
76
|
+
);
|
|
77
|
+
}
|