@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.
- package/dist/{chunk-2CUFS5LF.js → chunk-2BMJRXQX.js} +1 -1
- package/dist/chunk-HLQ2IRE7.js +279 -0
- package/dist/chunk-IQASCH2V.js +25 -0
- package/dist/{chunk-UXAYXRFN.js → chunk-QJ54Z7TC.js} +15 -1
- package/dist/{chunk-X6GATUI2.js → chunk-UISA4CIG.js} +1 -0
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/alert.d.ts +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.d.ts +9 -7
- package/dist/components/calendar.js +153 -39
- package/dist/components/card.d.ts +2 -1
- package/dist/components/card.js +1 -1
- package/dist/components/carousel.js +1 -1
- package/dist/components/checkbox.js +3 -25
- package/dist/components/command.d.ts +13 -13
- package/dist/components/dropdown-menu.js +1 -1
- package/dist/components/empty-state.js +1 -1
- package/dist/components/haptic-button.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/multi-select-menu.d.ts +26 -0
- package/dist/components/multi-select-menu.js +71 -0
- package/dist/components/pagination.js +1 -1
- package/dist/components/phone-frame.d.ts +17 -0
- package/dist/components/phone-frame.js +41 -0
- package/dist/components/place-about-tab.d.ts +17 -0
- package/dist/components/place-about-tab.js +6 -0
- package/dist/components/place-card.d.ts +17 -0
- package/dist/components/place-card.js +145 -0
- package/dist/components/place-details-mobile.d.ts +18 -0
- package/dist/components/place-details-mobile.js +187 -0
- package/dist/components/place-types.d.ts +145 -0
- package/dist/components/place-types.js +1 -0
- package/dist/components/search-bar.js +1 -1
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/skeleton-card.js +1 -1
- package/dist/components/skeleton.js +1 -1
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/sonner.js +11 -2
- package/dist/components/tabs.js +1 -1
- package/dist/components/text.d.ts +3 -3
- package/dist/components/toaster.js +1 -1
- package/package.json +28 -9
- package/src/components/accordion.stories.tsx +51 -0
- package/src/components/alert-dialog.stories.tsx +45 -0
- package/src/components/alert.stories.tsx +85 -0
- package/src/components/aspect-ratio.stories.tsx +47 -0
- package/src/components/avatar.stories.tsx +66 -0
- package/src/components/badge.stories.tsx +60 -0
- package/src/components/breadcrumb.stories.tsx +63 -0
- package/src/components/button.stories.tsx +115 -0
- package/src/components/button.tsx +1 -1
- package/src/components/calendar.stories.tsx +47 -0
- package/src/components/calendar.tsx +160 -40
- package/src/components/card.stories.tsx +72 -0
- package/src/components/card.tsx +13 -1
- package/src/components/carousel.stories.tsx +62 -0
- package/src/components/checkbox.stories.tsx +54 -0
- package/src/components/checkbox.tsx +1 -1
- package/src/components/collapsible.stories.tsx +34 -0
- package/src/components/command.stories.tsx +64 -0
- package/src/components/currency-input.stories.tsx +50 -0
- package/src/components/dialog.stories.tsx +75 -0
- package/src/components/drawer.stories.tsx +47 -0
- package/src/components/dropdown-menu.stories.tsx +64 -0
- package/src/components/dropdown-menu.tsx +1 -1
- package/src/components/empty-state.stories.tsx +67 -0
- package/src/components/form.stories.tsx +73 -0
- package/src/components/haptic-button.stories.tsx +41 -0
- package/src/components/input-otp.stories.tsx +42 -0
- package/src/components/input.stories.tsx +41 -0
- package/src/components/label.stories.tsx +35 -0
- package/src/components/markdown-renderer.stories.tsx +45 -0
- package/src/components/multi-select-combobox.stories.tsx +78 -0
- package/src/components/multi-select-menu.tsx +96 -0
- package/src/components/pagination.stories.tsx +48 -0
- package/src/components/phone-frame.stories.tsx +51 -0
- package/src/components/phone-frame.test.tsx +82 -0
- package/src/components/phone-frame.tsx +59 -0
- package/src/components/place-about-tab.stories.tsx +164 -0
- package/src/components/place-about-tab.test.tsx +325 -0
- package/src/components/place-about-tab.tsx +474 -0
- package/src/components/place-card.stories.tsx +120 -0
- package/src/components/place-card.test.tsx +187 -0
- package/src/components/place-card.tsx +187 -0
- package/src/components/place-details-mobile.tsx +251 -0
- package/src/components/place-types.ts +127 -0
- package/src/components/popover.stories.tsx +42 -0
- package/src/components/progress.stories.tsx +35 -0
- package/src/components/radio-group.stories.tsx +46 -0
- package/src/components/rating.stories.tsx +45 -0
- package/src/components/scroll-area.stories.tsx +48 -0
- package/src/components/search-bar.stories.tsx +92 -0
- package/src/components/searchable-select.stories.tsx +55 -0
- package/src/components/select.stories.tsx +92 -0
- package/src/components/separator.stories.tsx +33 -0
- package/src/components/sheet.stories.tsx +95 -0
- package/src/components/skeleton-card.stories.tsx +53 -0
- package/src/components/skeleton.stories.tsx +42 -0
- package/src/components/skeleton.tsx +1 -0
- package/src/components/slider.stories.tsx +27 -0
- package/src/components/sliding-panels.stories.tsx +68 -0
- package/src/components/sonner.tsx +17 -2
- package/src/components/sticky-container.stories.tsx +33 -0
- package/src/components/switch.stories.tsx +35 -0
- package/src/components/table.stories.tsx +60 -0
- package/src/components/tabs.stories.tsx +53 -0
- package/src/components/tabs.tsx +1 -1
- package/src/components/text.stories.tsx +82 -0
- package/src/components/textarea.stories.tsx +35 -0
- package/src/components/toast.stories.tsx +63 -0
- package/src/components/toggle-group.stories.tsx +60 -0
- package/src/components/toggle.stories.tsx +48 -0
- package/src/components/tooltip.stories.tsx +71 -0
- package/src/globals.css +21 -2
- 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
|
+
}
|