@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,187 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { PlaceCard } from "./place-card";
4
+ import type { PlaceCardLabels, PlacePreview } from "./place-types";
5
+
6
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
7
+
8
+ const LABELS: PlaceCardLabels = {
9
+ open: "Open",
10
+ closed: "Closed",
11
+ until: "until",
12
+ opensAt: "opens at",
13
+ currency: "RON",
14
+ };
15
+
16
+ function makePlace(overrides: Partial<PlacePreview> = {}): PlacePreview {
17
+ return {
18
+ id: "1",
19
+ slug: "test-place",
20
+ name: "Test Place",
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ // ─── Tests ────────────────────────────────────────────────────────────────────
26
+
27
+ describe("PlaceCard", () => {
28
+ // ─── Happy path ─────────────────────────────────────────────────────────────
29
+
30
+ it("renders place name", () => {
31
+ render(<PlaceCard place={makePlace()} labels={LABELS} />);
32
+ expect(screen.getByText("Test Place")).toBeInTheDocument();
33
+ });
34
+
35
+ it("renders rating when provided", () => {
36
+ render(
37
+ <PlaceCard
38
+ place={makePlace({ rating: { rating: 4.5, count: 120 } })}
39
+ labels={LABELS}
40
+ />
41
+ );
42
+ expect(screen.getByText("4.5")).toBeInTheDocument();
43
+ expect(screen.getByText("(120)")).toBeInTheDocument();
44
+ });
45
+
46
+ it("does not render rating when omitted", () => {
47
+ render(<PlaceCard place={makePlace()} labels={LABELS} />);
48
+ // No star-rating text should appear
49
+ expect(screen.queryByText(/^\d+\.\d+$/)).not.toBeInTheDocument();
50
+ });
51
+
52
+ // ─── onClick / interactive behaviour ────────────────────────────────────────
53
+
54
+ it("renders a <button> with cursor-pointer class when onClick is provided", () => {
55
+ const onClick = vi.fn();
56
+ render(<PlaceCard place={makePlace()} labels={LABELS} onClick={onClick} />);
57
+ const button = screen.getByRole("button");
58
+ expect(button).toBeInTheDocument();
59
+ expect(button.className).toContain("cursor-pointer");
60
+ });
61
+
62
+ it("renders a <div> (no button role) when onClick is omitted", () => {
63
+ render(<PlaceCard place={makePlace()} labels={LABELS} />);
64
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
65
+ });
66
+
67
+ it("fires onClick with the place object when clicked", () => {
68
+ const onClick = vi.fn();
69
+ const place = makePlace({ name: "Clickable Place" });
70
+ render(<PlaceCard place={place} labels={LABELS} onClick={onClick} />);
71
+ fireEvent.click(screen.getByRole("button"));
72
+ expect(onClick).toHaveBeenCalledTimes(1);
73
+ expect(onClick).toHaveBeenCalledWith(place);
74
+ });
75
+
76
+ // ─── Thumbnail fallback ──────────────────────────────────────────────────────
77
+
78
+ it("renders UtensilsCrossed icon when thumbnail_url is missing", () => {
79
+ render(<PlaceCard place={makePlace()} labels={LABELS} />);
80
+ // The icon is aria-hidden but we can check that no <img> is rendered
81
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
82
+ // Verify the container still renders (icon placeholder)
83
+ const heading = screen.getByText("Test Place");
84
+ expect(heading).toBeInTheDocument();
85
+ });
86
+
87
+ it("renders image when thumbnail_url is provided", () => {
88
+ render(
89
+ <PlaceCard
90
+ place={makePlace({ thumbnail_url: "https://example.com/thumb.jpg" })}
91
+ labels={LABELS}
92
+ />
93
+ );
94
+ expect(screen.getByRole("img")).toBeInTheDocument();
95
+ });
96
+
97
+ it("falls back to UtensilsCrossed icon when image errors", () => {
98
+ render(
99
+ <PlaceCard
100
+ place={makePlace({ thumbnail_url: "https://example.com/thumb.jpg" })}
101
+ labels={LABELS}
102
+ />
103
+ );
104
+ const img = screen.getByRole("img");
105
+ expect(img).toBeInTheDocument();
106
+
107
+ // Trigger the error handler
108
+ fireEvent.error(img);
109
+
110
+ // Image should be replaced by the fallback (no <img> in DOM)
111
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
112
+ });
113
+
114
+ // ─── Open / Closed status labels ────────────────────────────────────────────
115
+
116
+ it("shows labels.open text when place.open_now is true", () => {
117
+ render(
118
+ <PlaceCard place={makePlace({ open_now: true })} labels={LABELS} />
119
+ );
120
+ expect(screen.getByText("Open")).toBeInTheDocument();
121
+ });
122
+
123
+ it("shows labels.closed text when place.open_now is false", () => {
124
+ render(
125
+ <PlaceCard place={makePlace({ open_now: false })} labels={LABELS} />
126
+ );
127
+ expect(screen.getByText("Closed")).toBeInTheDocument();
128
+ });
129
+
130
+ it("uses labels.until with closes_at hour when open_now is true", () => {
131
+ render(
132
+ <PlaceCard
133
+ place={makePlace({
134
+ open_now: true,
135
+ today_schedule: [{ opens_at: 9, closes_at: 22 }],
136
+ })}
137
+ labels={LABELS}
138
+ />
139
+ );
140
+ // Renders "· until 22:00"
141
+ expect(screen.getByText(/until 22:00/)).toBeInTheDocument();
142
+ });
143
+
144
+ it("uses labels.opensAt with opens_at hour when open_now is false", () => {
145
+ render(
146
+ <PlaceCard
147
+ place={makePlace({
148
+ open_now: false,
149
+ today_schedule: [{ opens_at: 9, closes_at: 22 }],
150
+ })}
151
+ labels={LABELS}
152
+ />
153
+ );
154
+ expect(screen.getByText(/opens at 09:00/)).toBeInTheDocument();
155
+ });
156
+
157
+ // ─── Schedule priority: today_schedule[0] over hours[0] ─────────────────────
158
+
159
+ it("prefers today_schedule[0] over hours[0] for schedule display", () => {
160
+ render(
161
+ <PlaceCard
162
+ place={makePlace({
163
+ open_now: false,
164
+ today_schedule: [{ opens_at: 11, closes_at: 23 }],
165
+ hours: [{ opens_at: 9, closes_at: 22 }],
166
+ })}
167
+ labels={LABELS}
168
+ />
169
+ );
170
+ // today_schedule says opens at 11, hours says 9 — expect 11
171
+ expect(screen.getByText(/opens at 11:00/)).toBeInTheDocument();
172
+ expect(screen.queryByText(/09:00/)).not.toBeInTheDocument();
173
+ });
174
+
175
+ it("falls back to hours[0] when today_schedule is absent", () => {
176
+ render(
177
+ <PlaceCard
178
+ place={makePlace({
179
+ open_now: false,
180
+ hours: [{ opens_at: 9, closes_at: 22 }],
181
+ })}
182
+ labels={LABELS}
183
+ />
184
+ );
185
+ expect(screen.getByText(/opens at 09:00/)).toBeInTheDocument();
186
+ });
187
+ });
@@ -0,0 +1,187 @@
1
+ "use client";
2
+
3
+ import { memo, useState } from "react";
4
+ import { MapPin, Star, Clock, UtensilsCrossed, Wine } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Text } from "./text";
7
+ import type { PlaceCardLabels, PlacePreview } from "./place-types";
8
+
9
+ export interface PlaceCardProps {
10
+ place: PlacePreview;
11
+ labels: PlaceCardLabels;
12
+ /** Locale tag (e.g. `"en"`, `"ro"`). Reserved for future Intl number/distance formatting. */
13
+ locale?: string;
14
+ /** Optional click handler. When omitted the card renders as a non-interactive div. */
15
+ onClick?: (place: PlacePreview) => void;
16
+ className?: string;
17
+ /** Forwarded to the thumbnail's `loading` attribute (`"eager"` when true). */
18
+ priority?: boolean;
19
+ }
20
+
21
+ function formatDistance(meters?: number): string {
22
+ return meters ? `${(meters / 1000).toFixed(1)} km` : "—";
23
+ }
24
+
25
+ function formatRating(rating?: { rating: number; count: number }): string | null {
26
+ return rating?.rating ? rating.rating.toFixed(1) : null;
27
+ }
28
+
29
+ export const PlaceCard = memo(function PlaceCard({
30
+ place,
31
+ labels,
32
+ onClick,
33
+ className,
34
+ priority,
35
+ }: PlaceCardProps) {
36
+ const [imgError, setImgError] = useState(false);
37
+
38
+ const ratingDisplay = formatRating(place.rating);
39
+ const reviewCount = place.review_count ?? place.rating?.count;
40
+ const showHeader = !!place.thumbnail_url && !imgError;
41
+ const distanceMeters = place.location?.distance?.meters;
42
+ const schedule = place.today_schedule?.[0] ?? place.hours?.[0];
43
+ const scheduleHour = place.open_now ? schedule?.closes_at : schedule?.opens_at;
44
+ const scheduleLabel = place.open_now ? labels.until : labels.opensAt;
45
+
46
+ const sharedClasses = cn(
47
+ "glass rounded-3xl border border-transparent bg-transparent p-0 text-left text-card-foreground transition-transform duration-300 ease-in-out hover:border-border/20 h-full flex flex-col w-full",
48
+ onClick &&
49
+ "cursor-pointer [@media(hover:hover)]:hover:-translate-y-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
50
+ className
51
+ );
52
+
53
+ const content = (
54
+ <div className="p-4 flex-1 flex flex-col">
55
+ {/* SECTION 1 — HEADER */}
56
+ <div className="flex items-start gap-3 mb-3">
57
+ <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center overflow-hidden flex-shrink-0">
58
+ {showHeader ? (
59
+ // eslint-disable-next-line @next/next/no-img-element
60
+ <img
61
+ src={place.thumbnail_url}
62
+ alt={place.name}
63
+ title={place.name}
64
+ width={48}
65
+ height={48}
66
+ loading={priority ? "eager" : "lazy"}
67
+ decoding="async"
68
+ className="w-full h-full object-cover"
69
+ onError={() => setImgError(true)}
70
+ />
71
+ ) : (
72
+ <UtensilsCrossed
73
+ className="w-6 h-6 text-primary/60"
74
+ aria-hidden="true"
75
+ />
76
+ )}
77
+ </div>
78
+
79
+ <div className="flex-1 min-w-0">
80
+ <Text as="h3" weight="bold" leading="tight" color="default" className="text-lg mb-2">
81
+ {place.name}
82
+ </Text>
83
+ {ratingDisplay !== null && (
84
+ <div className="flex items-center gap-1">
85
+ <Star className="w-4 h-4 fill-primary text-primary" aria-hidden="true" />
86
+ <span className="font-medium text-sm">{ratingDisplay}</span>
87
+ {reviewCount !== undefined && reviewCount !== null && (
88
+ <span className="text-xs text-muted-foreground">({reviewCount})</span>
89
+ )}
90
+ </div>
91
+ )}
92
+ </div>
93
+ </div>
94
+
95
+ {/* SECTION 2 — PRICE RANGES */}
96
+ {(place.price_dishes || place.price_drinks) && (
97
+ <div className="mb-3">
98
+ <div className="flex items-center gap-4 text-xs">
99
+ {place.price_dishes?.price_range_low != null &&
100
+ place.price_dishes?.price_range_high != null && (
101
+ <div className="flex items-center gap-1 text-primary">
102
+ <UtensilsCrossed className="w-4 h-4" aria-hidden="true" />
103
+ <span>
104
+ {place.price_dishes.price_range_low}-{place.price_dishes.price_range_high}{" "}
105
+ {labels.currency}
106
+ </span>
107
+ </div>
108
+ )}
109
+ {place.price_drinks?.price_range_low != null &&
110
+ place.price_drinks?.price_range_high != null && (
111
+ <div className="flex items-center gap-1 text-primary">
112
+ <Wine className="w-4 h-4" aria-hidden="true" />
113
+ <span>
114
+ {place.price_drinks.price_range_low}-{place.price_drinks.price_range_high}{" "}
115
+ {labels.currency}
116
+ </span>
117
+ </div>
118
+ )}
119
+ </div>
120
+ </div>
121
+ )}
122
+
123
+ {/* SECTION 3 — LOCATION & STATUS */}
124
+ <div className="mb-3">
125
+ <div className="flex items-center gap-2 text-sm text-primary">
126
+ {distanceMeters !== undefined && distanceMeters !== null && (
127
+ <>
128
+ <div className="flex items-center gap-1">
129
+ <MapPin className="w-3 h-3" aria-hidden="true" />
130
+ <span className="text-xs">{formatDistance(distanceMeters)}</span>
131
+ </div>
132
+ <span>·</span>
133
+ </>
134
+ )}
135
+ <div className="flex items-center gap-1">
136
+ <Clock
137
+ className={cn(
138
+ "w-3 h-3",
139
+ place.open_now ? "text-green-500" : "text-muted-foreground"
140
+ )}
141
+ aria-hidden="true"
142
+ />
143
+ <span
144
+ className={cn(
145
+ "text-xs",
146
+ place.open_now ? "text-green-500" : "text-muted-foreground"
147
+ )}
148
+ >
149
+ {place.open_now ? labels.open : labels.closed}
150
+ </span>
151
+ {scheduleHour != null && (
152
+ <span
153
+ className={cn(
154
+ "text-xs",
155
+ place.open_now ? "text-green-500" : "text-muted-foreground"
156
+ )}
157
+ >
158
+ {`· ${scheduleLabel} ${String(scheduleHour).padStart(2, "0")}:00`}
159
+ </span>
160
+ )}
161
+ </div>
162
+ </div>
163
+ </div>
164
+
165
+ {/* SECTION 4 — DESCRIPTION */}
166
+ {place.description && (
167
+ <div className="mb-4">
168
+ <Text as="p" variant="caption" color="muted" className="line-clamp-2">
169
+ {place.description}
170
+ </Text>
171
+ </div>
172
+ )}
173
+ </div>
174
+ );
175
+
176
+ if (onClick) {
177
+ return (
178
+ <button type="button" className={sharedClasses} onClick={() => onClick(place)}>
179
+ {content}
180
+ </button>
181
+ );
182
+ }
183
+
184
+ return <div className={sharedClasses}>{content}</div>;
185
+ });
186
+
187
+ PlaceCard.displayName = "PlaceCard";
@@ -0,0 +1,251 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import {
5
+ CalendarDays,
6
+ Clock,
7
+ DollarSign,
8
+ Info,
9
+ MessageSquare,
10
+ Star,
11
+ UtensilsCrossed,
12
+ } from "lucide-react";
13
+ import { cn } from "../lib/utils";
14
+ import { Badge } from "./badge";
15
+ import { Text } from "./text";
16
+ import { PlaceAboutTab } from "./place-about-tab";
17
+ import type { PlaceDetailsMobileLabels, PlaceHourBlock, PlacePreview } from "./place-types";
18
+
19
+ export interface PlaceDetailsMobileProps {
20
+ place: PlacePreview;
21
+ labels: PlaceDetailsMobileLabels;
22
+ /** Pre-resolved cover image URL. When omitted or it 404s, an icon fallback renders. */
23
+ coverImageUrl?: string;
24
+ className?: string;
25
+ }
26
+
27
+ function formatHour(h: number): string {
28
+ return `${h.toString().padStart(2, "0")}:00`;
29
+ }
30
+
31
+ function formatRating(rating?: { rating: number; count: number }): string | null {
32
+ return rating?.rating ? rating.rating.toFixed(1) : null;
33
+ }
34
+
35
+ /** Mirror FE's price-level heuristic. Result is in 1..5 or null. */
36
+ function priceLevelFromRanges(place: PlacePreview): number | null {
37
+ const dish = place.price_dishes?.price_range_high ?? place.price_dishes?.price_range_low ?? 0;
38
+ const drink = place.price_drinks?.price_range_high ?? place.price_drinks?.price_range_low ?? 0;
39
+ if (dish === 0 && drink === 0) return null;
40
+ const avg = dish > 0 && drink > 0 ? Math.round((dish + drink) / 2) : dish || drink;
41
+ return Math.max(1, Math.min(5, avg));
42
+ }
43
+
44
+ function getClosingHour(hours: PlaceHourBlock[], currentHour: number): number | null {
45
+ if (!hours.length) return null;
46
+ const active = hours.find((s) => s.opens_at <= currentHour && s.closes_at > currentHour);
47
+ if (active) return active.closes_at;
48
+ const last = hours[hours.length - 1];
49
+ return last ? last.closes_at : null;
50
+ }
51
+
52
+ function getNextOpeningToday(hours: PlaceHourBlock[], currentHour: number): number | null {
53
+ if (!hours.length) return null;
54
+ const later = hours.find((s) => s.opens_at > currentHour);
55
+ return later ? later.opens_at : null;
56
+ }
57
+
58
+ /**
59
+ * Read-only mobile preview of a place's details page with the About tab selected.
60
+ * Used in admin's live profile preview (inside `<PhoneFrame>`). Pure presentational
61
+ * — all i18n via `labels`, no router/analytics. Tabs are visual only.
62
+ */
63
+ export function PlaceDetailsMobile({
64
+ place,
65
+ labels,
66
+ coverImageUrl,
67
+ className,
68
+ }: PlaceDetailsMobileProps) {
69
+ const [imgError, setImgError] = useState(false);
70
+
71
+ const ratingDisplay = formatRating(place.rating);
72
+ const reviewCount = place.review_count ?? place.rating?.count;
73
+ const priceLevel = priceLevelFromRanges(place);
74
+ const hours = place.hours ?? [];
75
+ const currentHour = new Date().getHours();
76
+ const closingHour = place.open_now ? getClosingHour(hours, currentHour) : null;
77
+ const nextOpening = !place.open_now ? getNextOpeningToday(hours, currentHour) : null;
78
+ const showCover = !!coverImageUrl && !imgError;
79
+
80
+ return (
81
+ <div className={cn("flex flex-col bg-background", className)}>
82
+ <div className="px-4 pt-4">
83
+ <div className="rounded-[22px] overflow-hidden relative">
84
+ <div className="h-44 flex items-center justify-center relative overflow-hidden">
85
+ {/* Status badge — bottom right */}
86
+ <div className="absolute bottom-2 right-2 z-20 flex items-center gap-1.5 bg-surface border border-border px-2.5 h-7 rounded-[12px] text-[11px] font-medium text-foreground">
87
+ <span
88
+ className={cn(
89
+ "w-1.5 h-1.5 rounded-full flex-shrink-0",
90
+ place.open_now ? "bg-green-500" : "bg-muted-foreground"
91
+ )}
92
+ />
93
+ <span>
94
+ {place.open_now ? labels.statusBadge.openNow : labels.statusBadge.closed}
95
+ {place.open_now && closingHour != null && (
96
+ <span className="text-muted-foreground">
97
+ {" · "}
98
+ {labels.statusBadge.closesAt} {formatHour(closingHour)}
99
+ </span>
100
+ )}
101
+ {!place.open_now && nextOpening != null && (
102
+ <span className="text-muted-foreground">
103
+ {" · "}
104
+ {labels.statusBadge.opensAt} {formatHour(nextOpening)}
105
+ </span>
106
+ )}
107
+ {!place.open_now && nextOpening == null && hours[0] && (
108
+ <span className="text-muted-foreground">
109
+ {" · "}
110
+ {labels.statusBadge.opensTomorrow} {formatHour(hours[0].opens_at)}
111
+ </span>
112
+ )}
113
+ </span>
114
+ </div>
115
+
116
+ {showCover ? (
117
+ // eslint-disable-next-line @next/next/no-img-element
118
+ <img
119
+ src={coverImageUrl}
120
+ alt={place.name}
121
+ className="absolute inset-0 w-full h-full object-cover"
122
+ onError={() => setImgError(true)}
123
+ />
124
+ ) : (
125
+ <>
126
+ <div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-primary/10 to-primary/5" />
127
+ <div className="relative z-10 flex flex-col items-center justify-center gap-2 h-full">
128
+ <div className="w-16 h-16 rounded-full bg-primary/20 flex items-center justify-center">
129
+ <UtensilsCrossed
130
+ className="w-8 h-8 text-primary"
131
+ strokeWidth={1.5}
132
+ aria-hidden
133
+ />
134
+ </div>
135
+ <Text as="p" variant="caption" color="muted" weight="medium">
136
+ {labels.noImageAvailable}
137
+ </Text>
138
+ </div>
139
+ </>
140
+ )}
141
+ </div>
142
+ </div>
143
+
144
+ <div className="pt-4 px-1">
145
+ <h1 className="text-[26px] leading-[1.05] font-bold tracking-tight text-foreground break-words">
146
+ {place.name}
147
+ </h1>
148
+
149
+ <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
150
+ {ratingDisplay && (
151
+ <div className="flex items-center gap-1">
152
+ <Star className="w-3 h-3 text-primary fill-primary" aria-hidden />
153
+ <span className="text-foreground font-semibold">{ratingDisplay}</span>
154
+ {reviewCount != null && (
155
+ <span className="text-muted-foreground">
156
+ {labels.reviewCount(reviewCount)}
157
+ </span>
158
+ )}
159
+ </div>
160
+ )}
161
+
162
+ {priceLevel != null && (
163
+ <div className="flex items-center -space-x-0.5" aria-label={`Price level ${priceLevel} of 5`}>
164
+ {Array.from({ length: 5 }, (_, i) => (
165
+ <DollarSign
166
+ key={i}
167
+ className={cn(
168
+ "w-3 h-3",
169
+ i < priceLevel ? "text-foreground" : "text-muted-foreground/30"
170
+ )}
171
+ strokeWidth={2.5}
172
+ aria-hidden
173
+ />
174
+ ))}
175
+ </div>
176
+ )}
177
+
178
+ <div className="flex items-center gap-1">
179
+ <Clock className="w-3.5 h-3.5" aria-hidden />
180
+ <span
181
+ className={cn(
182
+ place.open_now ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
183
+ )}
184
+ >
185
+ {place.open_now ? labels.statusBadge.openNow : labels.statusBadge.closed}
186
+ </span>
187
+ </div>
188
+ </div>
189
+
190
+ {place.cuisines && place.cuisines.length > 0 && (
191
+ <div className="mt-3 flex flex-wrap gap-1.5">
192
+ {place.cuisines.map((cuisine) => (
193
+ <Badge key={cuisine} variant="outline" className="capitalize text-[11px]">
194
+ {cuisine.replace(/-/g, " ")}
195
+ </Badge>
196
+ ))}
197
+ </div>
198
+ )}
199
+ </div>
200
+ </div>
201
+
202
+ {/* Tab strip — visual mirror of FE's MenuTabs, About selected, non-interactive */}
203
+ <div className="mt-5 px-4">
204
+ <div className="border-b border-border">
205
+ <div className="flex w-full">
206
+ <MobileTabPlaceholder icon={<UtensilsCrossed className="w-3.5 h-3.5" />} label={labels.tabs.menu} />
207
+ <MobileTabPlaceholder
208
+ icon={<Info className="w-3.5 h-3.5" />}
209
+ label={labels.tabs.about}
210
+ active
211
+ />
212
+ <MobileTabPlaceholder icon={<MessageSquare className="w-3.5 h-3.5" />} label={labels.tabs.reviews} />
213
+ <MobileTabPlaceholder icon={<CalendarDays className="w-3.5 h-3.5" />} label={labels.tabs.events} />
214
+ </div>
215
+ </div>
216
+ </div>
217
+
218
+ {/* About content */}
219
+ <div className="pb-6">
220
+ <PlaceAboutTab place={place} labels={labels} />
221
+ </div>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ function MobileTabPlaceholder({
227
+ icon,
228
+ label,
229
+ active,
230
+ }: {
231
+ icon: React.ReactNode;
232
+ label: string;
233
+ active?: boolean;
234
+ }) {
235
+ return (
236
+ <div
237
+ className={cn(
238
+ "flex flex-1 min-w-0 items-center justify-center gap-1 px-1 py-2.5 text-[12px] font-medium border-b-2 -mb-px",
239
+ active
240
+ ? "border-primary text-foreground"
241
+ : "border-transparent text-muted-foreground"
242
+ )}
243
+ aria-hidden
244
+ >
245
+ <span className="flex-shrink-0" aria-hidden>
246
+ {icon}
247
+ </span>
248
+ <span className="truncate">{label}</span>
249
+ </div>
250
+ );
251
+ }