@bearmenu/ui 0.1.1 → 0.5.1

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.
Files changed (118) hide show
  1. package/dist/{chunk-2CUFS5LF.js → chunk-2BMJRXQX.js} +1 -1
  2. package/dist/chunk-HLQ2IRE7.js +279 -0
  3. package/dist/chunk-IQASCH2V.js +25 -0
  4. package/dist/{chunk-UXAYXRFN.js → chunk-QJ54Z7TC.js} +15 -1
  5. package/dist/{chunk-X6GATUI2.js → chunk-UISA4CIG.js} +1 -0
  6. package/dist/components/alert-dialog.js +1 -1
  7. package/dist/components/alert.d.ts +1 -1
  8. package/dist/components/badge.d.ts +1 -1
  9. package/dist/components/button.d.ts +1 -1
  10. package/dist/components/button.js +1 -1
  11. package/dist/components/calendar.d.ts +9 -7
  12. package/dist/components/calendar.js +153 -39
  13. package/dist/components/card.d.ts +2 -1
  14. package/dist/components/card.js +1 -1
  15. package/dist/components/carousel.js +1 -1
  16. package/dist/components/checkbox.js +3 -25
  17. package/dist/components/command.d.ts +13 -13
  18. package/dist/components/dropdown-menu.js +1 -1
  19. package/dist/components/empty-state.js +1 -1
  20. package/dist/components/haptic-button.js +1 -1
  21. package/dist/components/link-button.js +1 -1
  22. package/dist/components/multi-select-combobox.js +1 -1
  23. package/dist/components/multi-select-menu.d.ts +26 -0
  24. package/dist/components/multi-select-menu.js +71 -0
  25. package/dist/components/pagination.js +1 -1
  26. package/dist/components/phone-frame.d.ts +17 -0
  27. package/dist/components/phone-frame.js +41 -0
  28. package/dist/components/place-about-tab.d.ts +17 -0
  29. package/dist/components/place-about-tab.js +6 -0
  30. package/dist/components/place-card.d.ts +17 -0
  31. package/dist/components/place-card.js +145 -0
  32. package/dist/components/place-details-mobile.d.ts +18 -0
  33. package/dist/components/place-details-mobile.js +187 -0
  34. package/dist/components/place-types.d.ts +145 -0
  35. package/dist/components/place-types.js +1 -0
  36. package/dist/components/search-bar.js +1 -1
  37. package/dist/components/searchable-select.js +1 -1
  38. package/dist/components/skeleton-card.js +1 -1
  39. package/dist/components/skeleton.js +1 -1
  40. package/dist/components/sliding-panels.js +1 -1
  41. package/dist/components/sonner.js +11 -2
  42. package/dist/components/tabs.js +1 -1
  43. package/dist/components/text.d.ts +3 -3
  44. package/dist/components/toaster.js +1 -1
  45. package/package.json +28 -9
  46. package/src/components/accordion.stories.tsx +51 -0
  47. package/src/components/alert-dialog.stories.tsx +45 -0
  48. package/src/components/alert.stories.tsx +85 -0
  49. package/src/components/aspect-ratio.stories.tsx +47 -0
  50. package/src/components/avatar.stories.tsx +66 -0
  51. package/src/components/badge.stories.tsx +60 -0
  52. package/src/components/breadcrumb.stories.tsx +63 -0
  53. package/src/components/button.stories.tsx +115 -0
  54. package/src/components/button.tsx +1 -1
  55. package/src/components/calendar.stories.tsx +47 -0
  56. package/src/components/calendar.tsx +160 -40
  57. package/src/components/card.stories.tsx +72 -0
  58. package/src/components/card.tsx +13 -1
  59. package/src/components/carousel.stories.tsx +62 -0
  60. package/src/components/checkbox.stories.tsx +54 -0
  61. package/src/components/checkbox.tsx +1 -1
  62. package/src/components/collapsible.stories.tsx +34 -0
  63. package/src/components/command.stories.tsx +64 -0
  64. package/src/components/currency-input.stories.tsx +50 -0
  65. package/src/components/dialog.stories.tsx +75 -0
  66. package/src/components/drawer.stories.tsx +47 -0
  67. package/src/components/dropdown-menu.stories.tsx +64 -0
  68. package/src/components/dropdown-menu.tsx +1 -1
  69. package/src/components/empty-state.stories.tsx +67 -0
  70. package/src/components/form.stories.tsx +73 -0
  71. package/src/components/haptic-button.stories.tsx +41 -0
  72. package/src/components/input-otp.stories.tsx +42 -0
  73. package/src/components/input.stories.tsx +41 -0
  74. package/src/components/label.stories.tsx +35 -0
  75. package/src/components/markdown-renderer.stories.tsx +45 -0
  76. package/src/components/multi-select-combobox.stories.tsx +78 -0
  77. package/src/components/multi-select-menu.tsx +96 -0
  78. package/src/components/pagination.stories.tsx +48 -0
  79. package/src/components/phone-frame.stories.tsx +51 -0
  80. package/src/components/phone-frame.test.tsx +82 -0
  81. package/src/components/phone-frame.tsx +59 -0
  82. package/src/components/place-about-tab.stories.tsx +164 -0
  83. package/src/components/place-about-tab.test.tsx +325 -0
  84. package/src/components/place-about-tab.tsx +474 -0
  85. package/src/components/place-card.stories.tsx +120 -0
  86. package/src/components/place-card.test.tsx +187 -0
  87. package/src/components/place-card.tsx +187 -0
  88. package/src/components/place-details-mobile.tsx +251 -0
  89. package/src/components/place-types.ts +127 -0
  90. package/src/components/popover.stories.tsx +42 -0
  91. package/src/components/progress.stories.tsx +35 -0
  92. package/src/components/radio-group.stories.tsx +46 -0
  93. package/src/components/rating.stories.tsx +45 -0
  94. package/src/components/scroll-area.stories.tsx +48 -0
  95. package/src/components/search-bar.stories.tsx +92 -0
  96. package/src/components/searchable-select.stories.tsx +55 -0
  97. package/src/components/select.stories.tsx +92 -0
  98. package/src/components/separator.stories.tsx +33 -0
  99. package/src/components/sheet.stories.tsx +95 -0
  100. package/src/components/skeleton-card.stories.tsx +53 -0
  101. package/src/components/skeleton.stories.tsx +42 -0
  102. package/src/components/skeleton.tsx +1 -0
  103. package/src/components/slider.stories.tsx +27 -0
  104. package/src/components/sliding-panels.stories.tsx +68 -0
  105. package/src/components/sonner.tsx +17 -2
  106. package/src/components/sticky-container.stories.tsx +33 -0
  107. package/src/components/switch.stories.tsx +35 -0
  108. package/src/components/table.stories.tsx +60 -0
  109. package/src/components/tabs.stories.tsx +53 -0
  110. package/src/components/tabs.tsx +1 -1
  111. package/src/components/text.stories.tsx +82 -0
  112. package/src/components/textarea.stories.tsx +35 -0
  113. package/src/components/toast.stories.tsx +63 -0
  114. package/src/components/toggle-group.stories.tsx +60 -0
  115. package/src/components/toggle.stories.tsx +48 -0
  116. package/src/components/tooltip.stories.tsx +71 -0
  117. package/src/globals.css +21 -2
  118. package/src/test/setup.ts +9 -0
@@ -0,0 +1,51 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { PhoneFrame } from "./phone-frame";
3
+
4
+ const meta = {
5
+ title: "Components/PhoneFrame",
6
+ component: PhoneFrame,
7
+ tags: ["autodocs"],
8
+ argTypes: {
9
+ width: { control: { type: "range", min: 160, max: 360, step: 10 } },
10
+ },
11
+ } satisfies Meta<typeof PhoneFrame>;
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof meta>;
15
+
16
+ export const Default: Story = {
17
+ args: { width: 220 },
18
+ render: (args) => (
19
+ <PhoneFrame {...args}>
20
+ <div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-amber-100 to-amber-300 text-sm font-medium text-amber-950">
21
+ Mobile preview
22
+ </div>
23
+ </PhoneFrame>
24
+ ),
25
+ };
26
+
27
+ export const Wide: Story = {
28
+ args: { width: 320 },
29
+ render: (args) => (
30
+ <PhoneFrame {...args}>
31
+ <div className="flex h-full w-full items-center justify-center bg-slate-900 text-white">
32
+ 320px wide
33
+ </div>
34
+ </PhoneFrame>
35
+ ),
36
+ };
37
+
38
+ export const ScrollableContent: Story = {
39
+ args: { width: 240 },
40
+ render: (args) => (
41
+ <PhoneFrame {...args}>
42
+ <div className="h-full w-full overflow-y-auto bg-white p-3">
43
+ {Array.from({ length: 20 }, (_, i) => (
44
+ <div key={i} className="border-b py-2 text-xs text-slate-700">
45
+ Item {i + 1}
46
+ </div>
47
+ ))}
48
+ </div>
49
+ </PhoneFrame>
50
+ ),
51
+ };
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { PhoneFrame } from "./phone-frame";
4
+
5
+ // ─── Tests ────────────────────────────────────────────────────────────────────
6
+
7
+ describe("PhoneFrame", () => {
8
+ it("renders children inside the frame", () => {
9
+ render(
10
+ <PhoneFrame>
11
+ <div data-testid="inner-content">Page content</div>
12
+ </PhoneFrame>
13
+ );
14
+ expect(screen.getByTestId("inner-content")).toBeInTheDocument();
15
+ expect(screen.getByText("Page content")).toBeInTheDocument();
16
+ });
17
+
18
+ it("applies the default width of 220 when width prop is omitted", () => {
19
+ const { container } = render(
20
+ <PhoneFrame>
21
+ <span>child</span>
22
+ </PhoneFrame>
23
+ );
24
+ // The outermost div receives the inline style width
25
+ const outerDiv = container.firstChild as HTMLElement;
26
+ expect(outerDiv.style.width).toBe("220px");
27
+ });
28
+
29
+ it("applies a custom width when width prop is provided", () => {
30
+ const { container } = render(
31
+ <PhoneFrame width={320}>
32
+ <span>child</span>
33
+ </PhoneFrame>
34
+ );
35
+ const outerDiv = container.firstChild as HTMLElement;
36
+ expect(outerDiv.style.width).toBe("320px");
37
+ });
38
+
39
+ it("calculates screen height from iPhone 17 Pro Max aspect ratio", () => {
40
+ // aspect = 956/440 ≈ 2.1727; width=220 → height = round(220 * 956/440) = round(477.8) = 478
41
+ const { container } = render(
42
+ <PhoneFrame width={220}>
43
+ <span>child</span>
44
+ </PhoneFrame>
45
+ );
46
+ // The inner screen div has the computed height as inline style
47
+ const screenDiv = container.querySelector<HTMLElement>(".rounded-\\[2rem\\]");
48
+ expect(screenDiv).not.toBeNull();
49
+ expect(screenDiv!.style.height).toBe("478px");
50
+ });
51
+
52
+ it("does not assign an ARIA role — the frame is purely presentational", () => {
53
+ const { container } = render(
54
+ <PhoneFrame>
55
+ <span>child</span>
56
+ </PhoneFrame>
57
+ );
58
+ const outerDiv = container.firstChild as HTMLElement;
59
+ expect(outerDiv.getAttribute("role")).toBeNull();
60
+ });
61
+
62
+ it("applies a custom className to the outer container", () => {
63
+ const { container } = render(
64
+ <PhoneFrame className="my-custom-class">
65
+ <span>child</span>
66
+ </PhoneFrame>
67
+ );
68
+ const outerDiv = container.firstChild as HTMLElement;
69
+ expect(outerDiv.className).toContain("my-custom-class");
70
+ });
71
+
72
+ it("renders multiple children correctly", () => {
73
+ render(
74
+ <PhoneFrame>
75
+ <div data-testid="a">A</div>
76
+ <div data-testid="b">B</div>
77
+ </PhoneFrame>
78
+ );
79
+ expect(screen.getByTestId("a")).toBeInTheDocument();
80
+ expect(screen.getByTestId("b")).toBeInTheDocument();
81
+ });
82
+ });
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { cn } from "../lib/utils";
4
+
5
+ const SCREEN_ASPECT = 956 / 440; // iPhone 17 Pro Max
6
+
7
+ export interface PhoneFrameProps {
8
+ children: React.ReactNode;
9
+ className?: string;
10
+ /** Outer frame width in pixels. Height derives from iPhone 17 Pro Max aspect. Default 220. */
11
+ width?: number;
12
+ /**
13
+ * When set, children are rendered at this virtual viewport width and CSS-scaled
14
+ * to fit the actual frame. Use this to render mobile UIs at native sizing
15
+ * (e.g. 390 for iPhone 13/14/15) while displaying inside a smaller frame.
16
+ */
17
+ mobileViewportWidth?: number;
18
+ }
19
+
20
+ const FRAME_PADDING = 6; // matches `p-1.5` (1.5 * 4 = 6px each side)
21
+
22
+ export function PhoneFrame({
23
+ children,
24
+ className,
25
+ width = 220,
26
+ mobileViewportWidth,
27
+ }: PhoneFrameProps) {
28
+ const screenHeight = Math.round(width * SCREEN_ASPECT);
29
+ const innerWidth = width - FRAME_PADDING * 2;
30
+ const scale = mobileViewportWidth ? innerWidth / mobileViewportWidth : 1;
31
+ const viewportHeight = mobileViewportWidth ? screenHeight / scale : screenHeight;
32
+
33
+ return (
34
+ <div className={cn("relative mx-auto shrink-0", className)} style={{ width }}>
35
+ <div className="rounded-[2.5rem] border-[1.5px] border-white/15 bg-black/80 p-1.5 shadow-2xl shadow-black/40">
36
+ <div className="absolute top-[10px] left-1/2 -translate-x-1/2 w-[68px] h-[20px] bg-black rounded-full z-10" />
37
+ <div
38
+ className="rounded-[2rem] overflow-hidden bg-background relative"
39
+ style={{ height: screenHeight }}
40
+ >
41
+ {mobileViewportWidth ? (
42
+ <div
43
+ style={{
44
+ width: mobileViewportWidth,
45
+ height: viewportHeight,
46
+ transform: `scale(${scale})`,
47
+ transformOrigin: "top left",
48
+ }}
49
+ >
50
+ {children}
51
+ </div>
52
+ ) : (
53
+ children
54
+ )}
55
+ </div>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,164 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { PlaceAboutTab } from "./place-about-tab";
3
+ import type { PlaceAboutTabLabels, PlacePreview } from "./place-types";
4
+
5
+ const labels: PlaceAboutTabLabels = {
6
+ overview: "Overview",
7
+ description: "Description",
8
+ whatPeopleSay: "What people say",
9
+ contactAndLocation: "Contact & Location",
10
+ address: "Address",
11
+ instagram: "Instagram",
12
+ facebook: "Facebook",
13
+ todayHours: "Today's hours",
14
+ statusOpen: "Open now",
15
+ statusClosed: "Closed",
16
+ typicalBusyness: "Typical busyness",
17
+ cuisines: "Cuisines",
18
+ amenities: "Amenities",
19
+ paymentOptions: "Payment options",
20
+ accessibility: "Accessibility",
21
+ parking: "Parking",
22
+ nearbyLandmarks: "Nearby landmarks",
23
+ neighborhood: "Neighborhood",
24
+ businessInformation: "Business information",
25
+ type: "Type",
26
+ status: "Status",
27
+ amenityLabels: {
28
+ wifi: "Wi-Fi",
29
+ outdoor_seating: "Outdoor seating",
30
+ dine_in: "Dine in",
31
+ delivery: "Delivery",
32
+ },
33
+ paymentLabels: {
34
+ credit_card: "Credit card",
35
+ cash: "Cash",
36
+ },
37
+ accessibilityLabels: {
38
+ wheelchair_accessible_entrance: "Wheelchair accessible entrance",
39
+ },
40
+ parkingLabels: {
41
+ free_parking: "Free parking",
42
+ },
43
+ typeLabels: {
44
+ restaurant: "Restaurant",
45
+ cafe: "Cafe",
46
+ },
47
+ statusLabels: {
48
+ operational: "Operational",
49
+ closed_temporarily: "Closed temporarily",
50
+ closed_permanently: "Closed permanently",
51
+ },
52
+ };
53
+
54
+ const richPlace: PlacePreview = {
55
+ id: "1",
56
+ slug: "casa-gri",
57
+ name: "Casa Gri",
58
+ description: "A modern bistro serving European plates with a Mediterranean accent.",
59
+ generative_summary:
60
+ "Beloved bistro known for its seasonal small plates, natural wines, and warm patio.",
61
+ review_summary:
62
+ "Diners praise the friendly service and the lamb shoulder; some note that reservations are essential on weekends.",
63
+ open_now: true,
64
+ hours: [{ opens_at: 11, closes_at: 23 }],
65
+ location: { address: "Strada Mihai Eminescu 15, București" },
66
+ phone: "+40 21 555 0123",
67
+ website_url: "https://casagri.example.com",
68
+ socials: {
69
+ instagram_url: "https://instagram.com/casagri",
70
+ facebook_url: "https://facebook.com/casagri",
71
+ },
72
+ cuisines: ["mediterranean", "european", "wine bar"],
73
+ amenities: ["wifi", "outdoor_seating", "dine_in", "delivery"],
74
+ payment_options: ["credit_card", "cash"],
75
+ accessibility_features: ["wheelchair_accessible_entrance"],
76
+ parking_options: ["free_parking"],
77
+ landmarks: [
78
+ { name: "Cișmigiu Park", distance_meters: 350 },
79
+ { name: "Piața Universității", distance_meters: 1200 },
80
+ ],
81
+ areas: [{ name: "Sector 1" }, { name: "Centru" }],
82
+ type: "restaurant",
83
+ business_status: "operational",
84
+ busyness: [
85
+ { hour: 11, score: 20 },
86
+ { hour: 12, score: 65 },
87
+ { hour: 13, score: 90 },
88
+ { hour: 14, score: 80 },
89
+ ],
90
+ };
91
+
92
+ const meta = {
93
+ title: "Components/PlaceAboutTab",
94
+ component: PlaceAboutTab,
95
+ tags: ["autodocs"],
96
+ args: { labels, place: richPlace },
97
+ } satisfies Meta<typeof PlaceAboutTab>;
98
+
99
+ export default meta;
100
+ type Story = StoryObj<typeof meta>;
101
+
102
+ export const Default: Story = {
103
+ decorators: [
104
+ (Story) => (
105
+ <div className="w-[640px] mx-auto">
106
+ <Story />
107
+ </div>
108
+ ),
109
+ ],
110
+ };
111
+
112
+ export const Minimal: Story = {
113
+ args: {
114
+ place: {
115
+ id: "2",
116
+ slug: "tiny",
117
+ name: "Tiny Place",
118
+ description: "Just enough to be discoverable.",
119
+ },
120
+ },
121
+ decorators: [
122
+ (Story) => (
123
+ <div className="w-[640px] mx-auto">
124
+ <Story />
125
+ </div>
126
+ ),
127
+ ],
128
+ };
129
+
130
+ export const WithSlots: Story = {
131
+ args: {
132
+ mapSlot: (
133
+ <div className="h-[200px] rounded-lg bg-muted flex items-center justify-center text-muted-foreground text-sm">
134
+ [Map slot]
135
+ </div>
136
+ ),
137
+ busynessSlot: (
138
+ <div className="h-[120px] rounded-lg bg-muted flex items-center justify-center text-muted-foreground text-sm">
139
+ [Busyness chart slot]
140
+ </div>
141
+ ),
142
+ onLinkClick: (kind, url) => console.log("link click", kind, url),
143
+ },
144
+ decorators: [
145
+ (Story) => (
146
+ <div className="w-[640px] mx-auto">
147
+ <Story />
148
+ </div>
149
+ ),
150
+ ],
151
+ };
152
+
153
+ export const Closed: Story = {
154
+ args: {
155
+ place: { ...richPlace, open_now: false, business_status: "closed_temporarily" },
156
+ },
157
+ decorators: [
158
+ (Story) => (
159
+ <div className="w-[640px] mx-auto">
160
+ <Story />
161
+ </div>
162
+ ),
163
+ ],
164
+ };
@@ -0,0 +1,325 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { PlaceAboutTab } from "./place-about-tab";
4
+ import type { PlaceAboutTabLabels, PlacePreview } from "./place-types";
5
+
6
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
7
+
8
+ const LABELS: PlaceAboutTabLabels = {
9
+ overview: "Overview",
10
+ description: "Description",
11
+ whatPeopleSay: "What people say",
12
+ contactAndLocation: "Contact & Location",
13
+ address: "Address",
14
+ instagram: "Instagram",
15
+ facebook: "Facebook",
16
+ todayHours: "Today's hours",
17
+ statusOpen: "Open now",
18
+ statusClosed: "Closed",
19
+ typicalBusyness: "Typical busyness",
20
+ cuisines: "Cuisines",
21
+ amenities: "Amenities",
22
+ paymentOptions: "Payment options",
23
+ accessibility: "Accessibility",
24
+ parking: "Parking",
25
+ nearbyLandmarks: "Nearby landmarks",
26
+ neighborhood: "Neighborhood",
27
+ businessInformation: "Business information",
28
+ type: "Type",
29
+ status: "Status",
30
+ amenityLabels: { wifi: "Free Wi-Fi" },
31
+ paymentLabels: {},
32
+ accessibilityLabels: {},
33
+ parkingLabels: {},
34
+ typeLabels: {},
35
+ statusLabels: {},
36
+ };
37
+
38
+ function makePlace(overrides: Partial<PlacePreview> = {}): PlacePreview {
39
+ return {
40
+ id: "1",
41
+ slug: "test-place",
42
+ name: "Test Place",
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ // ─── Tests ────────────────────────────────────────────────────────────────────
48
+
49
+ describe("PlaceAboutTab", () => {
50
+ // ─── Contact section: each field conditionally rendered ─────────────────────
51
+
52
+ it("renders phone link only when place.phone is provided", () => {
53
+ const { rerender } = render(
54
+ <PlaceAboutTab place={makePlace({ phone: "+40700000000" })} labels={LABELS} />
55
+ );
56
+ expect(screen.getByText("+40700000000")).toBeInTheDocument();
57
+
58
+ rerender(<PlaceAboutTab place={makePlace()} labels={LABELS} />);
59
+ expect(screen.queryByText("+40700000000")).not.toBeInTheDocument();
60
+ });
61
+
62
+ it("renders website link only when place.website_url is provided", () => {
63
+ render(
64
+ <PlaceAboutTab
65
+ place={makePlace({ website_url: "https://example.com" })}
66
+ labels={LABELS}
67
+ />
68
+ );
69
+ expect(screen.getByText("https://example.com")).toBeInTheDocument();
70
+ });
71
+
72
+ it("does not render website link when website_url is absent", () => {
73
+ render(<PlaceAboutTab place={makePlace()} labels={LABELS} />);
74
+ expect(screen.queryByRole("link", { name: /http/ })).not.toBeInTheDocument();
75
+ });
76
+
77
+ it("renders Instagram link only when place.socials.instagram_url is provided", () => {
78
+ render(
79
+ <PlaceAboutTab
80
+ place={makePlace({ socials: { instagram_url: "https://instagram.com/test" } })}
81
+ labels={LABELS}
82
+ />
83
+ );
84
+ expect(screen.getByText("Instagram")).toBeInTheDocument();
85
+ });
86
+
87
+ it("does not render Instagram link when socials.instagram_url is absent", () => {
88
+ render(<PlaceAboutTab place={makePlace()} labels={LABELS} />);
89
+ expect(screen.queryByText("Instagram")).not.toBeInTheDocument();
90
+ });
91
+
92
+ it("renders Facebook link only when place.socials.facebook_url is provided", () => {
93
+ render(
94
+ <PlaceAboutTab
95
+ place={makePlace({ socials: { facebook_url: "https://facebook.com/test" } })}
96
+ labels={LABELS}
97
+ />
98
+ );
99
+ expect(screen.getByText("Facebook")).toBeInTheDocument();
100
+ });
101
+
102
+ it("does not render Facebook link when socials.facebook_url is absent", () => {
103
+ render(<PlaceAboutTab place={makePlace()} labels={LABELS} />);
104
+ expect(screen.queryByText("Facebook")).not.toBeInTheDocument();
105
+ });
106
+
107
+ // ─── mapSlot ────────────────────────────────────────────────────────────────
108
+
109
+ it("renders mapSlot when prop is passed (regardless of busyness data)", () => {
110
+ render(
111
+ <PlaceAboutTab
112
+ place={makePlace()}
113
+ labels={LABELS}
114
+ mapSlot={<div data-testid="map-slot">Map</div>}
115
+ />
116
+ );
117
+ expect(screen.getByTestId("map-slot")).toBeInTheDocument();
118
+ });
119
+
120
+ it("does not render mapSlot content when prop is omitted", () => {
121
+ render(<PlaceAboutTab place={makePlace()} labels={LABELS} />);
122
+ expect(screen.queryByTestId("map-slot")).not.toBeInTheDocument();
123
+ });
124
+
125
+ // ─── busynessSlot ───────────────────────────────────────────────────────────
126
+
127
+ it("renders busynessSlot when both the prop and place.busyness entries are present", () => {
128
+ render(
129
+ <PlaceAboutTab
130
+ place={makePlace({ busyness: [{ hour: 12, score: 80 }] })}
131
+ labels={LABELS}
132
+ busynessSlot={<div data-testid="busyness-slot">Chart</div>}
133
+ />
134
+ );
135
+ expect(screen.getByTestId("busyness-slot")).toBeInTheDocument();
136
+ });
137
+
138
+ it("does NOT render busynessSlot when prop is present but place.busyness is empty", () => {
139
+ render(
140
+ <PlaceAboutTab
141
+ place={makePlace({ busyness: [] })}
142
+ labels={LABELS}
143
+ busynessSlot={<div data-testid="busyness-slot">Chart</div>}
144
+ />
145
+ );
146
+ expect(screen.queryByTestId("busyness-slot")).not.toBeInTheDocument();
147
+ });
148
+
149
+ it("does NOT render busynessSlot when prop is present but place.busyness is absent", () => {
150
+ render(
151
+ <PlaceAboutTab
152
+ place={makePlace()}
153
+ labels={LABELS}
154
+ busynessSlot={<div data-testid="busyness-slot">Chart</div>}
155
+ />
156
+ );
157
+ expect(screen.queryByTestId("busyness-slot")).not.toBeInTheDocument();
158
+ });
159
+
160
+ it("does NOT render busynessSlot when the prop is omitted even if busyness data exists", () => {
161
+ render(
162
+ <PlaceAboutTab
163
+ place={makePlace({ busyness: [{ hour: 12, score: 80 }] })}
164
+ labels={LABELS}
165
+ />
166
+ );
167
+ expect(screen.queryByTestId("busyness-slot")).not.toBeInTheDocument();
168
+ });
169
+
170
+ // ─── onLinkClick ────────────────────────────────────────────────────────────
171
+
172
+ it("fires onLinkClick with kind='phone' and correct url on phone click", () => {
173
+ const onLinkClick = vi.fn();
174
+ render(
175
+ <PlaceAboutTab
176
+ place={makePlace({ phone: "+40700000000" })}
177
+ labels={LABELS}
178
+ onLinkClick={onLinkClick}
179
+ />
180
+ );
181
+ fireEvent.click(screen.getByText("+40700000000"));
182
+ expect(onLinkClick).toHaveBeenCalledWith("phone", "+40700000000");
183
+ });
184
+
185
+ it("fires onLinkClick with kind='website' on website click", () => {
186
+ const onLinkClick = vi.fn();
187
+ render(
188
+ <PlaceAboutTab
189
+ place={makePlace({ website_url: "https://example.com" })}
190
+ labels={LABELS}
191
+ onLinkClick={onLinkClick}
192
+ />
193
+ );
194
+ fireEvent.click(screen.getByText("https://example.com"));
195
+ expect(onLinkClick).toHaveBeenCalledWith("website", "https://example.com");
196
+ });
197
+
198
+ it("fires onLinkClick with kind='instagram' on instagram click", () => {
199
+ const onLinkClick = vi.fn();
200
+ render(
201
+ <PlaceAboutTab
202
+ place={makePlace({ socials: { instagram_url: "https://instagram.com/test" } })}
203
+ labels={LABELS}
204
+ onLinkClick={onLinkClick}
205
+ />
206
+ );
207
+ fireEvent.click(screen.getByText("Instagram"));
208
+ expect(onLinkClick).toHaveBeenCalledWith(
209
+ "instagram",
210
+ "https://instagram.com/test"
211
+ );
212
+ });
213
+
214
+ it("fires onLinkClick with kind='facebook' on facebook click", () => {
215
+ const onLinkClick = vi.fn();
216
+ render(
217
+ <PlaceAboutTab
218
+ place={makePlace({ socials: { facebook_url: "https://facebook.com/test" } })}
219
+ labels={LABELS}
220
+ onLinkClick={onLinkClick}
221
+ />
222
+ );
223
+ fireEvent.click(screen.getByText("Facebook"));
224
+ expect(onLinkClick).toHaveBeenCalledWith(
225
+ "facebook",
226
+ "https://facebook.com/test"
227
+ );
228
+ });
229
+
230
+ // ─── Today's hours card ──────────────────────────────────────────────────────
231
+
232
+ it("renders today's hours card when place.hours has entries", () => {
233
+ render(
234
+ <PlaceAboutTab
235
+ place={makePlace({ hours: [{ opens_at: 9, closes_at: 22 }] })}
236
+ labels={LABELS}
237
+ />
238
+ );
239
+ expect(screen.getByText("Today's hours")).toBeInTheDocument();
240
+ expect(screen.getByText("09:00 - 22:00")).toBeInTheDocument();
241
+ });
242
+
243
+ it("does NOT render today's hours card when place.hours is empty", () => {
244
+ render(
245
+ <PlaceAboutTab place={makePlace({ hours: [] })} labels={LABELS} />
246
+ );
247
+ expect(screen.queryByText("Today's hours")).not.toBeInTheDocument();
248
+ });
249
+
250
+ it("does NOT render today's hours card when place.hours is absent", () => {
251
+ render(<PlaceAboutTab place={makePlace()} labels={LABELS} />);
252
+ expect(screen.queryByText("Today's hours")).not.toBeInTheDocument();
253
+ });
254
+
255
+ // ─── amenityLabels fallback to humanizeEnum ──────────────────────────────────
256
+
257
+ it("uses the amenityLabels map when the key is present", () => {
258
+ render(
259
+ <PlaceAboutTab
260
+ place={makePlace({ amenities: ["wifi"] })}
261
+ labels={LABELS}
262
+ />
263
+ );
264
+ // LABELS.amenityLabels.wifi = "Free Wi-Fi"
265
+ expect(screen.getByText("Free Wi-Fi")).toBeInTheDocument();
266
+ });
267
+
268
+ it("falls back to humanizeEnum (underscores → spaces) when key missing from amenityLabels", () => {
269
+ render(
270
+ <PlaceAboutTab
271
+ place={makePlace({ amenities: ["outdoor_seating"] })}
272
+ labels={LABELS}
273
+ />
274
+ );
275
+ // humanizeEnum("outdoor_seating") → "outdoor seating"
276
+ expect(screen.getByText("outdoor seating")).toBeInTheDocument();
277
+ });
278
+
279
+ // ─── Description card visibility ────────────────────────────────────────────
280
+
281
+ it("renders description card when description is set", () => {
282
+ render(
283
+ <PlaceAboutTab
284
+ place={makePlace({ description: "A great place to eat." })}
285
+ labels={LABELS}
286
+ />
287
+ );
288
+ expect(screen.getByText("A great place to eat.")).toBeInTheDocument();
289
+ });
290
+
291
+ it("renders description card when generative_summary is set", () => {
292
+ render(
293
+ <PlaceAboutTab
294
+ place={makePlace({ generative_summary: "AI summary here." })}
295
+ labels={LABELS}
296
+ />
297
+ );
298
+ expect(screen.getByText("AI summary here.")).toBeInTheDocument();
299
+ });
300
+
301
+ it("renders description card when review_summary is set", () => {
302
+ render(
303
+ <PlaceAboutTab
304
+ place={makePlace({ review_summary: "People love it." })}
305
+ labels={LABELS}
306
+ />
307
+ );
308
+ expect(screen.getByText("People love it.")).toBeInTheDocument();
309
+ });
310
+
311
+ it("hides description card when none of description/generative_summary/review_summary are set", () => {
312
+ render(<PlaceAboutTab place={makePlace()} labels={LABELS} />);
313
+ // The card header title is "Description" which is the same as labels.description.
314
+ // The description card renders labels.description as its CardTitle.
315
+ // Use a more specific query: look for the card header content.
316
+ // Since labels.contactAndLocation is always rendered, we need to count occurrences.
317
+ // "Description" appears in labels.description; if no card exists it should not appear at all.
318
+ expect(screen.queryByText("A great place to eat.")).not.toBeInTheDocument();
319
+ // Verify no card title "Description" is rendered (description card uses labels.description as title)
320
+ // Note: "Description" also equals labels.description label inside the card — use getAllByText and check count
321
+ const descTexts = screen.queryAllByText("Description");
322
+ // When description card is hidden, "Description" label should not appear
323
+ expect(descTexts).toHaveLength(0);
324
+ });
325
+ });