@bearmenu/ui 0.8.53 → 0.8.54

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/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.53",
4
- "description": "BearMenu design system — shared UI tokens, primitives, and components",
3
+ "version": "0.8.54",
4
+ "packageManager": "pnpm@10.28.1",
5
+ "description": "BearMenu design system \u2014 shared UI tokens, primitives, and components",
5
6
  "license": "UNLICENSED",
6
7
  "repository": {
7
8
  "type": "git",
@@ -16,20 +17,226 @@
16
17
  "src"
17
18
  ],
18
19
  "exports": {
20
+ "./components/accordion": "./src/components/accordion.tsx",
21
+ "./components/action-bar": "./src/components/action-bar.tsx",
22
+ "./components/activity-row": "./src/components/activity-row.tsx",
23
+ "./components/alert": "./src/components/alert.tsx",
24
+ "./components/alert-dialog": "./src/components/alert-dialog.tsx",
25
+ "./components/aspect-ratio": "./src/components/aspect-ratio.tsx",
26
+ "./components/avatar": "./src/components/avatar.tsx",
27
+ "./components/badge": "./src/components/badge.tsx",
28
+ "./components/banner": "./src/components/banner.tsx",
29
+ "./components/booking-row": "./src/components/booking-row.tsx",
30
+ "./components/booking-summary-card": "./src/components/booking-summary-card.tsx",
31
+ "./components/breadcrumb": "./src/components/breadcrumb.tsx",
32
+ "./components/button": "./src/components/button.tsx",
33
+ "./components/button-group": "./src/components/button-group.tsx",
34
+ "./components/calendar": "./src/components/calendar.tsx",
35
+ "./components/card": "./src/components/card.tsx",
36
+ "./components/carousel": "./src/components/carousel.tsx",
37
+ "./components/category-deck": "./src/components/category-deck.tsx",
38
+ "./components/checkbox": "./src/components/checkbox.tsx",
39
+ "./components/circular-progress": "./src/components/circular-progress.tsx",
40
+ "./components/coach-marks": "./src/components/coach-marks.tsx",
41
+ "./components/collapsible": "./src/components/collapsible.tsx",
42
+ "./components/combobox": "./src/components/combobox.tsx",
43
+ "./components/command": "./src/components/command.tsx",
44
+ "./components/concept-card": "./src/components/concept-card.tsx",
45
+ "./components/concept-compare": "./src/components/concept-compare.tsx",
46
+ "./components/concept-compare-rail": "./src/components/concept-compare-rail.tsx",
47
+ "./components/concept-convergence": "./src/components/concept-convergence.tsx",
48
+ "./components/convergence": "./src/components/convergence.tsx",
49
+ "./components/coverflow": "./src/components/coverflow.tsx",
50
+ "./components/currency-input": "./src/components/currency-input.tsx",
51
+ "./components/date-input": "./src/components/date-input.tsx",
52
+ "./components/day-rail-chip": "./src/components/day-rail-chip.tsx",
53
+ "./components/description-list": "./src/components/description-list.tsx",
54
+ "./components/dialog": "./src/components/dialog.tsx",
55
+ "./components/doorway-tiles": "./src/components/doorway-tiles.tsx",
56
+ "./components/drawer": "./src/components/drawer.tsx",
57
+ "./components/drift-rail": "./src/components/drift-rail.tsx",
58
+ "./components/dropdown-menu": "./src/components/dropdown-menu.tsx",
59
+ "./components/empty-state": "./src/components/empty-state.tsx",
60
+ "./components/event-card": "./src/components/event-card.tsx",
61
+ "./components/event-compact-row": "./src/components/event-compact-row.tsx",
62
+ "./components/event-day-header": "./src/components/event-day-header.tsx",
63
+ "./components/event-day-separator": "./src/components/event-day-separator.tsx",
64
+ "./components/event-detail-panel": "./src/components/event-detail-panel.tsx",
65
+ "./components/event-hub-hero": "./src/components/event-hub-hero.tsx",
66
+ "./components/event-index-row": "./src/components/event-index-row.tsx",
67
+ "./components/event-list-row": "./src/components/event-list-row.tsx",
68
+ "./components/event-list-row-wide": "./src/components/event-list-row-wide.tsx",
69
+ "./components/event-poster-fallback": "./src/components/event-poster-fallback.tsx",
70
+ "./components/event-tonight-strip": "./src/components/event-tonight-strip.tsx",
71
+ "./components/event-types": "./src/components/event-types.ts",
72
+ "./components/events-empty-state": "./src/components/events-empty-state.tsx",
73
+ "./components/explainer-steps": "./src/components/explainer-steps.tsx",
74
+ "./components/facet-group": "./src/components/facet-group.tsx",
75
+ "./components/featured-rail-item": "./src/components/featured-rail-item.tsx",
76
+ "./components/field": "./src/components/field.tsx",
77
+ "./components/filter-category-row": "./src/components/filter-category-row.tsx",
78
+ "./components/form": "./src/components/form.tsx",
79
+ "./components/glass-ground": "./src/components/glass-ground.tsx",
80
+ "./components/grade-atom": "./src/components/grade-atom.tsx",
81
+ "./components/haptic-switch": "./src/components/haptic-switch.tsx",
82
+ "./components/hold-countdown": "./src/components/hold-countdown.tsx",
83
+ "./components/hub-surface": "./src/components/hub-surface.tsx",
84
+ "./components/hue-chip": "./src/components/hue-chip.tsx",
85
+ "./components/icon": "./src/components/icon.tsx",
86
+ "./components/input": "./src/components/input.tsx",
87
+ "./components/input-group": "./src/components/input-group.tsx",
88
+ "./components/input-otp": "./src/components/input-otp.tsx",
89
+ "./components/item": "./src/components/item.tsx",
90
+ "./components/kbd": "./src/components/kbd.tsx",
91
+ "./components/label": "./src/components/label.tsx",
92
+ "./components/kitchen-dossier": "./src/components/kitchen-dossier.tsx",
93
+ "./components/lazy-markdown": "./src/components/lazy-markdown.tsx",
94
+ "./components/link-button": "./src/components/link-button.tsx",
95
+ "./components/markdown-renderer": "./src/components/markdown-renderer.tsx",
96
+ "./components/menu-dead-end": "./src/components/menu-dead-end.tsx",
97
+ "./components/menu-family-block": "./src/components/menu-family-block.tsx",
98
+ "./components/menu-family-card": "./src/components/menu-family-card.tsx",
99
+ "./components/menu-field": "./src/components/menu-field.tsx",
100
+ "./components/menu-filter-chips": "./src/components/menu-filter-chips.tsx",
101
+ "./components/menu-group-rows": "./src/components/menu-group-rows.tsx",
102
+ "./components/menu-hub-hero": "./src/components/menu-hub-hero.tsx",
103
+ "./components/menu-index-header": "./src/components/menu-index-header.tsx",
104
+ "./components/menu-index-list": "./src/components/menu-index-list.tsx",
105
+ "./components/menu-intent-hero": "./src/components/menu-intent-hero.tsx",
106
+ "./components/menu-item": "./src/components/menu-item.tsx",
107
+ "./components/menu-pick-row": "./src/components/menu-pick-row.tsx",
108
+ "./components/menu-kitchen-list": "./src/components/menu-kitchen-list.tsx",
109
+ "./components/menu-mocks": "./src/components/menu-mocks.ts",
110
+ "./components/menu-types": "./src/components/menu-types.ts",
111
+ "./components/motion-icon": "./src/components/motion-icon.tsx",
112
+ "./components/motion-icon-actions": "./src/components/motion-icon-actions.tsx",
113
+ "./components/motion-icon-controls": "./src/components/motion-icon-controls.tsx",
114
+ "./components/motion-icon-set": "./src/components/motion-icon-set.tsx",
115
+ "./components/multi-select-combobox": "./src/components/multi-select-combobox.tsx",
116
+ "./components/multi-select-menu": "./src/components/multi-select-menu.tsx",
117
+ "./components/native-select": "./src/components/native-select.tsx",
118
+ "./components/open-now-list": "./src/components/open-now-list.tsx",
119
+ "./components/pagination": "./src/components/pagination.tsx",
120
+ "./components/pan-zoom-viewport": "./src/components/pan-zoom-viewport.tsx",
121
+ "./components/party-counter": "./src/components/party-counter.tsx",
122
+ "./components/phone-frame": "./src/components/phone-frame.tsx",
123
+ "./components/photo-ground": "./src/components/photo-ground.tsx",
124
+ "./components/place-about-tab": "./src/components/place-about-tab.tsx",
125
+ "./components/place-card": "./src/components/place-card.tsx",
126
+ "./components/place-details-mobile": "./src/components/place-details-mobile.tsx",
127
+ "./components/place-schedule-week": "./src/components/place-schedule-week.tsx",
128
+ "./components/place-types": "./src/components/place-types.ts",
129
+ "./components/popover": "./src/components/popover.tsx",
130
+ "./components/poster-stack": "./src/components/poster-stack.tsx",
131
+ "./components/poster-tape": "./src/components/poster-tape.tsx",
132
+ "./components/poster-wall": "./src/components/poster-wall.tsx",
133
+ "./components/progress": "./src/components/progress.tsx",
134
+ "./components/qa-block": "./src/components/qa-block.tsx",
135
+ "./components/qr-panel": "./src/components/qr-panel.tsx",
136
+ "./components/quiet-stretch": "./src/components/quiet-stretch.tsx",
137
+ "./components/radio-group": "./src/components/radio-group.tsx",
138
+ "./components/reconcile-bar": "./src/components/reconcile-bar.tsx",
139
+ "./components/responsive-overlay": "./src/components/responsive-overlay.tsx",
140
+ "./components/rating": "./src/components/rating.tsx",
141
+ "./components/scroll-area": "./src/components/scroll-area.tsx",
142
+ "./components/scroll-fade": "./src/components/scroll-fade.tsx",
143
+ "./components/search-bar": "./src/components/search-bar.tsx",
144
+ "./components/searchable-select": "./src/components/searchable-select.tsx",
145
+ "./components/section-header": "./src/components/section-header.tsx",
146
+ "./components/segmented-nav": "./src/components/segmented-nav.tsx",
147
+ "./components/select": "./src/components/select.tsx",
148
+ "./components/selection-bar": "./src/components/selection-bar.tsx",
149
+ "./components/separator": "./src/components/separator.tsx",
150
+ "./components/sheet": "./src/components/sheet.tsx",
151
+ "./components/signature-chip": "./src/components/signature-chip.tsx",
152
+ "./components/skeleton": "./src/components/skeleton.tsx",
153
+ "./components/skeleton-card": "./src/components/skeleton-card.tsx",
154
+ "./components/slider": "./src/components/slider.tsx",
155
+ "./components/sliding-panels": "./src/components/sliding-panels.tsx",
156
+ "./components/sonner": "./src/components/sonner.tsx",
157
+ "./components/sort-option-list": "./src/components/sort-option-list.tsx",
158
+ "./components/spinner": "./src/components/spinner.tsx",
159
+ "./components/split-share-row": "./src/components/split-share-row.tsx",
160
+ "./components/stat-banner": "./src/components/stat-banner.tsx",
161
+ "./components/stepper": "./src/components/stepper.tsx",
162
+ "./components/sticky-container": "./src/components/sticky-container.tsx",
163
+ "./components/story": "./src/components/story.tsx",
164
+ "./components/switch": "./src/components/switch.tsx",
165
+ "./components/table": "./src/components/table.tsx",
166
+ "./components/table-ledger-card": "./src/components/table-ledger-card.tsx",
167
+ "./components/table-trigger-band": "./src/components/table-trigger-band.tsx",
168
+ "./components/tabs": "./src/components/tabs.tsx",
169
+ "./components/tag": "./src/components/tag.tsx",
170
+ "./components/tag-group": "./src/components/tag-group.tsx",
171
+ "./components/text": "./src/components/text.tsx",
172
+ "./components/textarea": "./src/components/textarea.tsx",
173
+ "./components/time-slot": "./src/components/time-slot.tsx",
174
+ "./components/timeline-day": "./src/components/timeline-day.tsx",
175
+ "./components/timeline-hour": "./src/components/timeline-hour.tsx",
176
+ "./components/timeline-hour-puck": "./src/components/timeline-hour-puck.tsx",
177
+ "./components/toggle": "./src/components/toggle.tsx",
178
+ "./components/toggle-group": "./src/components/toggle-group.tsx",
179
+ "./components/tooltip": "./src/components/tooltip.tsx",
180
+ "./components/top-comparison": "./src/components/top-comparison.tsx",
181
+ "./components/top-cross-link": "./src/components/top-cross-link.tsx",
182
+ "./components/top-ranking-entry": "./src/components/top-ranking-entry.tsx",
183
+ "./components/top-ranking-hero": "./src/components/top-ranking-hero.tsx",
184
+ "./components/top-ranking-legend": "./src/components/top-ranking-legend.tsx",
185
+ "./components/top-types": "./src/components/top-types.ts",
186
+ "./components/use-mobile": "./src/components/use-mobile.tsx",
187
+ "./components/venue-events-block": "./src/components/venue-events-block.tsx",
188
+ "./components/venue-grade-dimensions": "./src/components/venue-grade-dimensions.tsx",
189
+ "./components/venue-quotes": "./src/components/venue-quotes.tsx",
190
+ "./components/venue-wall": "./src/components/venue-wall.tsx",
191
+ "./components/weekday-chip": "./src/components/weekday-chip.tsx",
192
+ "./components/weekday-theme-banner": "./src/components/weekday-theme-banner.tsx",
193
+ "./components/weekend-panel": "./src/components/weekend-panel.tsx",
19
194
  "./globals.css": "./src/globals.css",
20
- "./components/*": {
21
- "types": "./dist/components/*.d.ts",
22
- "import": "./dist/components/*.js"
23
- },
24
- "./hooks/*": {
25
- "types": "./dist/hooks/*.d.ts",
26
- "import": "./dist/hooks/*.js"
27
- },
28
- "./lib/*": {
29
- "types": "./dist/lib/*.d.ts",
30
- "import": "./dist/lib/*.js"
195
+ "./hooks/use-drag-swipe": "./src/hooks/use-drag-swipe.ts",
196
+ "./hooks/use-haptic": "./src/hooks/use-haptic.ts",
197
+ "./hooks/use-media-query": "./src/hooks/use-media-query.ts",
198
+ "./hooks/use-mobile": "./src/hooks/use-mobile.ts",
199
+ "./hooks/use-mounted-theme": "./src/hooks/use-mounted-theme.ts",
200
+ "./hooks/use-sticky": "./src/hooks/use-sticky.ts",
201
+ "./lib/card-hover": "./src/lib/card-hover.ts",
202
+ "./lib/grade": "./src/lib/grade.ts",
203
+ "./lib/icon-depth": "./src/lib/icon-depth.ts",
204
+ "./lib/icon-registry": "./src/lib/icon-registry.ts",
205
+ "./lib/motion": "./src/lib/motion.ts",
206
+ "./lib/overlay-pill": "./src/lib/overlay-pill.ts",
207
+ "./lib/utils": "./src/lib/utils.ts",
208
+ "./components/filter-option-chip": "./src/components/filter-option-chip.tsx",
209
+ "./components/filter-option-group": "./src/components/filter-option-group.tsx",
210
+ "./components/filter-panel-footer": "./src/components/filter-panel-footer.tsx"
211
+ },
212
+ "publishConfig": {
213
+ "exports": {
214
+ "./globals.css": "./src/globals.css",
215
+ "./components/*": {
216
+ "types": "./dist/components/*.d.ts",
217
+ "import": "./dist/components/*.js"
218
+ },
219
+ "./hooks/*": {
220
+ "types": "./dist/hooks/*.d.ts",
221
+ "import": "./dist/hooks/*.js"
222
+ },
223
+ "./lib/*": {
224
+ "types": "./dist/lib/*.d.ts",
225
+ "import": "./dist/lib/*.js"
226
+ }
31
227
  }
32
228
  },
229
+ "scripts": {
230
+ "storybook": "storybook dev -p 6006",
231
+ "build-storybook": "storybook build",
232
+ "build": "tsup",
233
+ "dev": "tsup --watch",
234
+ "typecheck": "tsc --noEmit",
235
+ "test": "vitest run",
236
+ "test:watch": "vitest",
237
+ "clean": "rm -rf dist",
238
+ "prepublishOnly": "pnpm run build"
239
+ },
33
240
  "peerDependencies": {
34
241
  "@hookform/resolvers": "^3.0.0 || ^5.0.0",
35
242
  "@radix-ui/react-accordion": "*",
@@ -138,15 +345,5 @@
138
345
  "vaul": "^1.1.2",
139
346
  "vitest": "^4.1.5",
140
347
  "zod": "^3.25.67"
141
- },
142
- "scripts": {
143
- "storybook": "storybook dev -p 6006",
144
- "build-storybook": "storybook build",
145
- "build": "tsup",
146
- "dev": "tsup --watch",
147
- "typecheck": "tsc --noEmit",
148
- "test": "vitest run",
149
- "test:watch": "vitest",
150
- "clean": "rm -rf dist"
151
348
  }
152
- }
349
+ }
@@ -283,6 +283,22 @@ export const Loading: Story = {
283
283
  decorators: SideSheet.decorators,
284
284
  };
285
285
 
286
+ export const EntryAndBrowseActions: Story = {
287
+ args: {
288
+ tags: [{ label: "Party", href: "/events?tags=party" }],
289
+ entryDetails: {
290
+ label: "Entry and tickets",
291
+ description: "Check availability with the organizer.",
292
+ action: (
293
+ <Button asChild fullWidth>
294
+ <a href="https://tickets.example.com">Get tickets</a>
295
+ </Button>
296
+ ),
297
+ },
298
+ },
299
+ decorators: SideSheet.decorators,
300
+ };
301
+
286
302
  export const Error: Story = {
287
303
  render: () => (
288
304
  <EventDetailPanelError
@@ -37,6 +37,42 @@ const event: EventPreview = {
37
37
  const base = { event, labels, formatters, dateLine: "Tuesday · 17:30" };
38
38
 
39
39
  describe("EventDetailPanel", () => {
40
+ it("opens entry information and exposes a separate booking link", () => {
41
+ render(
42
+ <EventDetailPanel
43
+ {...base}
44
+ entryDetails={{
45
+ label: "Entry and tickets",
46
+ description: "Book with the organizer.",
47
+ action: <a href="https://tickets.example.com">Get tickets</a>,
48
+ }}
49
+ />,
50
+ );
51
+ expect(
52
+ screen.queryByRole("link", { name: "Get tickets" }),
53
+ ).not.toBeInTheDocument();
54
+ fireEvent.click(screen.getByRole("button", { name: "Entry and tickets" }));
55
+ expect(screen.getByText("Book with the organizer.")).toBeVisible();
56
+ expect(screen.getByRole("link", { name: "Get tickets" })).toHaveAttribute(
57
+ "href",
58
+ "https://tickets.example.com",
59
+ );
60
+ });
61
+
62
+ it("gives browse tags destinations while keeping descriptive tags out of the tab order", () => {
63
+ render(
64
+ <EventDetailPanel
65
+ {...base}
66
+ tags={["Descriptive", { label: "Party", href: "/events?tags=party" }]}
67
+ />,
68
+ );
69
+ expect(screen.getByRole("link", { name: "Party" })).toHaveAttribute(
70
+ "href",
71
+ "/events?tags=party",
72
+ );
73
+ expect(screen.getByText("Descriptive").tagName).toBe("SPAN");
74
+ });
75
+
40
76
  it("shows the status chip for live, soon and ended, and none when upcoming", () => {
41
77
  const { rerender } = render(<EventDetailPanel {...base} />);
42
78
  expect(screen.queryByText("Now")).not.toBeInTheDocument();
@@ -19,6 +19,7 @@ import { cn } from "../lib/utils";
19
19
  import { GradeAtom, type GradeAtomLabels } from "./grade-atom";
20
20
  import type { PlaceGrade } from "./place-types";
21
21
  import { Button } from "./button";
22
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
22
23
  import { EventPosterFallback } from "./event-poster-fallback";
23
24
  import { Separator } from "./separator";
24
25
  import { Skeleton } from "./skeleton";
@@ -144,8 +145,15 @@ export type EventDetailPanelProps = {
144
145
  /** The hype tier, rendered as given (the app's own badge) in the chip row. */
145
146
  hype?: React.ReactNode;
146
147
  venue?: EventDetailVenue;
147
- /** Activity tags, after status, fee and hype. */
148
- tags?: string[];
148
+ /** Activity tags. Pass a destination to make a tag a browse action. */
149
+ tags?: (string | { label: string; href: string; onClick?: () => void })[];
150
+ /** Entry information opened by the price/free-entry control. */
151
+ entryDetails?: {
152
+ label: string;
153
+ description: string;
154
+ action?: React.ReactNode;
155
+ onOpenChange?: (open: boolean) => void;
156
+ };
149
157
  /** Performer names, in the order the API gave them. */
150
158
  performers?: string[];
151
159
  /** How many performers to show before the "show all" chip. Default 5. */
@@ -197,6 +205,7 @@ export function EventDetailPanel({
197
205
  hype,
198
206
  venue,
199
207
  tags,
208
+ entryDetails,
200
209
  performers,
201
210
  performersPreview = 5,
202
211
  showAllPerformersLabel,
@@ -289,6 +298,8 @@ export function EventDetailPanel({
289
298
  soonLabel={soonLabel}
290
299
  hype={hype}
291
300
  tags={tags}
301
+ entryDetails={entryDetails}
302
+ linkComponent={LinkComponent}
292
303
  />
293
304
 
294
305
  {venue && (
@@ -675,7 +686,14 @@ export function EventDetailPanelSkeleton({
675
686
 
676
687
  export type EventDetailChipRowProps = Pick<
677
688
  EventDetailPanelProps,
678
- "event" | "formatters" | "status" | "soonLabel" | "hype" | "tags"
689
+ | "event"
690
+ | "formatters"
691
+ | "status"
692
+ | "soonLabel"
693
+ | "hype"
694
+ | "tags"
695
+ | "entryDetails"
696
+ | "linkComponent"
679
697
  > & {
680
698
  /** Only the chip strings — a page rendering the row alone need not name the section headings. */
681
699
  labels: Pick<EventDetailPanelLabels, "free" | "liveNow" | "ended">;
@@ -683,7 +701,7 @@ export type EventDetailChipRowProps = Pick<
683
701
  };
684
702
 
685
703
  /**
686
- * One wrapping row, all 24px: status, fee, hype, then activity tags. Exported
704
+ * One wrapping row: metadata, entry information, then activity links. Exported
687
705
  * so the page renders the same row above its title — it is the panel's row,
688
706
  * not a second one.
689
707
  */
@@ -695,11 +713,25 @@ export function EventDetailChipRow({
695
713
  soonLabel,
696
714
  hype,
697
715
  tags,
716
+ entryDetails,
717
+ linkComponent: LinkComponent = DefaultLink,
698
718
  className,
699
719
  }: EventDetailChipRowProps) {
700
720
  const fee = resolveEventEntryFee(event);
721
+ const priceLabel =
722
+ fee.kind === "free"
723
+ ? labels.free
724
+ : fee.kind === "paid"
725
+ ? formatters.price(fee.amountRon)
726
+ : null;
727
+ const priceIcon =
728
+ fee.kind === "free" ? (
729
+ <Gift className="h-3.5 w-3.5" aria-hidden />
730
+ ) : (
731
+ <Ticket className="h-3.5 w-3.5" aria-hidden />
732
+ );
701
733
  return (
702
- <div className={cn("flex flex-wrap gap-[7px]", className)}>
734
+ <div className={cn("flex flex-wrap items-center gap-[7px]", className)}>
703
735
  {status === "live" && (
704
736
  <span
705
737
  className={cn(
@@ -732,7 +764,33 @@ export function EventDetailChipRow({
732
764
  {labels.ended}
733
765
  </span>
734
766
  )}
735
- {fee.kind === "free" && (
767
+ {priceLabel && entryDetails ? (
768
+ <Popover onOpenChange={entryDetails.onOpenChange}>
769
+ <PopoverTrigger asChild>
770
+ <Button
771
+ variant="outline"
772
+ size="sm"
773
+ className="min-h-11 rounded-full"
774
+ aria-label={entryDetails.label}
775
+ >
776
+ {priceIcon}
777
+ {priceLabel}
778
+ <ChevronRight className="h-3.5 w-3.5" aria-hidden />
779
+ </Button>
780
+ </PopoverTrigger>
781
+ <PopoverContent
782
+ align="start"
783
+ aria-label={entryDetails.label}
784
+ className="flex max-w-[calc(100vw-2rem)] flex-col gap-3"
785
+ >
786
+ <p className="text-sm font-semibold">{priceLabel}</p>
787
+ <p className="text-sm text-muted-foreground">
788
+ {entryDetails.description}
789
+ </p>
790
+ {entryDetails.action}
791
+ </PopoverContent>
792
+ </Popover>
793
+ ) : fee.kind === "free" ? (
736
794
  <span
737
795
  className={cn(
738
796
  CHIP,
@@ -742,19 +800,33 @@ export function EventDetailChipRow({
742
800
  <Gift className="h-3 w-3" aria-hidden />
743
801
  {labels.free}
744
802
  </span>
745
- )}
746
- {fee.kind === "paid" && (
803
+ ) : fee.kind === "paid" ? (
747
804
  <span className={cn(CHIP, "text-foreground")}>
748
805
  <Ticket className="h-3 w-3" aria-hidden />
749
806
  {formatters.price(fee.amountRon)}
750
807
  </span>
751
- )}
808
+ ) : null}
752
809
  {hype}
753
- {tags?.map((tag) => (
754
- <span key={tag} className={CHIP}>
755
- {tag}
756
- </span>
757
- ))}
810
+ {tags?.map((tag) =>
811
+ typeof tag === "string" ? (
812
+ <span key={tag} className="text-xs text-muted-foreground">
813
+ {tag}
814
+ </span>
815
+ ) : (
816
+ <Button
817
+ key={tag.href}
818
+ asChild
819
+ variant="outline"
820
+ size="sm"
821
+ className="min-h-11 rounded-full"
822
+ >
823
+ <LinkComponent href={tag.href} onClick={tag.onClick}>
824
+ {tag.label}
825
+ <ChevronRight className="h-3.5 w-3.5" aria-hidden />
826
+ </LinkComponent>
827
+ </Button>
828
+ ),
829
+ )}
758
830
  </div>
759
831
  );
760
832
  }
@@ -0,0 +1,82 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MenuItem, type MenuItemLabels } from "./menu-item";
3
+ import type { MenuItemPreview } from "./menu-types";
4
+ import { mockGradients } from "./menu-mocks";
5
+
6
+ const tagliatelle: MenuItemPreview = {
7
+ id: "i1",
8
+ slug: "truffle-tagliatelle",
9
+ name: "Truffle Tagliatelle",
10
+ kind: "dish",
11
+ category_slug: "pasta",
12
+ description: "Fresh pasta, black truffle, parmesan.",
13
+ mentions: 17,
14
+ sizes_and_prices: [{ size: 320, price: 62 }],
15
+ };
16
+
17
+ const mentionLabels: MenuItemLabels = {
18
+ currency: "lei",
19
+ mentions: (count) => `Mentioned in ${count} reviews`,
20
+ signature: "SIGNATURE",
21
+ mentioned: "MENTIONED",
22
+ };
23
+
24
+ // The label and the kind move together: "Praised in" with the thumbs-up,
25
+ // "Mentioned in" with the quote glyph.
26
+ const praiseLabels: MenuItemLabels = {
27
+ ...mentionLabels,
28
+ mentions: (count) => `Praised in ${count} reviews`,
29
+ };
30
+
31
+ const meta: Meta<typeof MenuItem> = {
32
+ title: "Menu/MenuItem",
33
+ component: MenuItem,
34
+ tags: ["autodocs"],
35
+ decorators: [
36
+ (Story) => (
37
+ <div className="w-[390px] bg-background">
38
+ <Story />
39
+ </div>
40
+ ),
41
+ ],
42
+ args: {
43
+ item: tagliatelle,
44
+ labels: mentionLabels,
45
+ formatPrice: String,
46
+ markGradient: mockGradients.pasta,
47
+ onOpen: () => {},
48
+ onMentions: () => {},
49
+ },
50
+ argTypes: {
51
+ countKind: { control: "inline-radio", options: ["mentions", "praise"] },
52
+ },
53
+ parameters: {
54
+ docs: {
55
+ description: {
56
+ component:
57
+ "THE menu row. The count at the right edge is drawn with one of two glyphs, set by `countKind`. `\"mentions\"` (the default) counts every review that named the item, complaints included, and takes the quote glyph. `\"praise\"` counts only the reviews that praised it and takes a thumbs-up. A thumbs-up never sits on a count that includes complaints, so pass `\"praise\"` only when the number is praise.",
58
+ },
59
+ },
60
+ },
61
+ };
62
+
63
+ export default meta;
64
+ type Story = StoryObj<typeof MenuItem>;
65
+
66
+ /** The default: every mention, complaints included. Quote glyph. */
67
+ export const Mentions: Story = {};
68
+
69
+ /** A praise-only count. Thumbs-up, in the same slot as the quote glyph. */
70
+ export const Praise: Story = {
71
+ args: { countKind: "praise", labels: praiseLabels },
72
+ };
73
+
74
+ /** The two kinds stacked, so the glyphs can be compared in place. */
75
+ export const CountKinds: Story = {
76
+ render: (args) => (
77
+ <div className="divide-y divide-border">
78
+ <MenuItem {...args} countKind="mentions" labels={mentionLabels} />
79
+ <MenuItem {...args} countKind="praise" labels={praiseLabels} />
80
+ </div>
81
+ ),
82
+ };
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { render, screen } from "@testing-library/react";
3
- import { MenuItem, type MenuItemLabels } from "./menu-item";
3
+ import { MentionSignal, MenuItem, type MenuItemLabels } from "./menu-item";
4
4
  import type { MenuItemPreview } from "./menu-types";
5
5
 
6
6
  const wine: MenuItemPreview = {
@@ -55,3 +55,57 @@ describe("MenuItem external rating", () => {
55
55
  expect(screen.getByRole("img", { name: "Rated 3.9 on Vivino" })).toBeTruthy();
56
56
  });
57
57
  });
58
+
59
+ describe("MenuItem count glyph", () => {
60
+ const praiseLabels: MenuItemLabels = {
61
+ ...labels,
62
+ mentions: (count) => `Praised in ${count} reviews`,
63
+ };
64
+
65
+ it("draws the quote glyph when no kind is passed, so existing callers look the same", () => {
66
+ render(<MenuItem item={wine} labels={labels} formatPrice={String} />);
67
+ const count = screen.getByLabelText("Mentioned in 4 reviews");
68
+ const glyph = count.querySelector("svg");
69
+ expect(glyph).toHaveClass("lucide-message-square-quote", "h-3.5", "w-3.5", "opacity-60");
70
+ expect(count.querySelector(".lucide-thumbs-up")).toBeNull();
71
+ });
72
+
73
+ it("draws the quote glyph for raw mentions, which can include complaints", () => {
74
+ render(<MenuItem item={wine} labels={labels} formatPrice={String} countKind="mentions" />);
75
+ const count = screen.getByLabelText("Mentioned in 4 reviews");
76
+ expect(count.querySelector("svg")).toHaveClass("lucide-message-square-quote");
77
+ expect(count.querySelector(".lucide-thumbs-up")).toBeNull();
78
+ });
79
+
80
+ it("draws a thumbs-up in the quote glyph's slot when the count is praise", () => {
81
+ render(
82
+ <MenuItem
83
+ item={wine}
84
+ labels={praiseLabels}
85
+ formatPrice={String}
86
+ countKind="praise"
87
+ onMentions={() => {}}
88
+ />
89
+ );
90
+ const count = screen.getByRole("button", { name: "Praised in 4 reviews" });
91
+ const glyph = count.querySelector("svg");
92
+ expect(glyph).toHaveClass("lucide-thumbs-up", "h-3.5", "w-3.5", "opacity-60");
93
+ expect(glyph).toHaveAttribute("aria-hidden", "true");
94
+ expect(count.querySelector(".lucide-message-square-quote")).toBeNull();
95
+ expect(count.textContent).toBe("4");
96
+ });
97
+ });
98
+
99
+ describe("MentionSignal count glyph", () => {
100
+ it("draws the quote glyph by default and the thumbs-up only for praise", () => {
101
+ const { rerender } = render(<MentionSignal label="Mentioned in 9 reviews" count={9} />);
102
+ expect(screen.getByLabelText("Mentioned in 9 reviews").querySelector("svg")).toHaveClass(
103
+ "lucide-message-square-quote"
104
+ );
105
+
106
+ rerender(<MentionSignal label="Praised in 9 reviews" count={9} countKind="praise" />);
107
+ expect(screen.getByLabelText("Praised in 9 reviews").querySelector("svg")).toHaveClass(
108
+ "lucide-thumbs-up"
109
+ );
110
+ });
111
+ });