@bearmenu/ui 0.8.24 → 0.8.25
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-5LTILG3L.js → chunk-5HHRVJOT.js} +14 -1
- package/dist/{chunk-SDWN6L4O.js → chunk-AHHDC7ML.js} +1 -1
- package/dist/{chunk-I4RUOAU7.js → chunk-AHL45DD2.js} +11 -38
- package/dist/{chunk-5XREWOBB.js → chunk-IEIJP73R.js} +14 -1
- package/dist/{chunk-BKKQ7GCZ.js → chunk-QG6I2ILT.js} +1 -1
- package/dist/{chunk-TIQNM4XD.js → chunk-VEGFQSZA.js} +17 -1
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/booking-row.d.ts +60 -0
- package/dist/components/booking-row.js +103 -0
- package/dist/components/booking-summary-card.d.ts +38 -0
- package/dist/components/booking-summary-card.js +67 -0
- package/dist/components/button.d.ts +2 -2
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.d.ts +17 -3
- package/dist/components/calendar.js +100 -12
- package/dist/components/carousel.js +1 -1
- package/dist/components/coach-marks.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/concept-convergence.js +2 -2
- package/dist/components/drift-rail.d.ts +2 -29
- package/dist/components/drift-rail.js +1 -3
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.js +1 -1
- package/dist/components/event-list-row-wide.js +2 -2
- package/dist/components/event-list-row.js +1 -1
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/hold-countdown.d.ts +43 -0
- package/dist/components/hold-countdown.js +90 -0
- package/dist/components/kitchen-dossier.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.js +1 -1
- package/dist/components/menu-index-list.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/motion-icon-set.d.ts +2 -1
- package/dist/components/motion-icon-set.js +27 -1
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/party-counter.d.ts +36 -0
- package/dist/components/party-counter.js +61 -0
- package/dist/components/place-card.js +2 -2
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/poster-tape.d.ts +2 -15
- package/dist/components/poster-tape.js +3 -12
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/time-slot.d.ts +73 -0
- package/dist/components/time-slot.js +106 -0
- package/dist/components/toggle-group.js +15 -2
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/toggle.js +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/dist/components/weekday-chip.d.ts +33 -0
- package/dist/components/weekday-chip.js +48 -0
- package/package.json +1 -1
- package/src/components/badge.stories.tsx +17 -0
- package/src/components/badge.tsx +15 -0
- package/src/components/booking-primitives.test.tsx +180 -0
- package/src/components/booking-row.stories.tsx +66 -0
- package/src/components/booking-row.tsx +168 -0
- package/src/components/booking-summary-card.stories.tsx +41 -0
- package/src/components/booking-summary-card.tsx +109 -0
- package/src/components/button.stories.tsx +23 -0
- package/src/components/button.tsx +13 -0
- package/src/components/calendar.stories.tsx +27 -0
- package/src/components/calendar.tsx +110 -3
- package/src/components/drift-rail.stories.tsx +1 -29
- package/src/components/drift-rail.tsx +4 -71
- package/src/components/hold-countdown.stories.tsx +68 -0
- package/src/components/hold-countdown.tsx +140 -0
- package/src/components/motion-icon-set.stories.tsx +2 -0
- package/src/components/motion-icon-set.tsx +42 -0
- package/src/components/party-counter.stories.tsx +57 -0
- package/src/components/party-counter.tsx +93 -0
- package/src/components/poster-tape.stories.tsx +0 -29
- package/src/components/poster-tape.tsx +5 -21
- package/src/components/time-slot.stories.tsx +85 -0
- package/src/components/time-slot.tsx +186 -0
- package/src/components/toggle-group.stories.tsx +27 -0
- package/src/components/toggle-group.tsx +14 -1
- package/src/components/toggle.tsx +16 -0
- package/src/components/weekday-chip.stories.tsx +54 -0
- package/src/components/weekday-chip.tsx +75 -0
- package/src/components/drift-rail.test.tsx +0 -125
- package/src/components/poster-tape.test.tsx +0 -42
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { act, render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { PartyCounter } from "./party-counter";
|
|
5
|
+
import { TimeSlot, TimeSlotGroups, TimeSlotGroupsSkeleton } from "./time-slot";
|
|
6
|
+
import { WeekdayChip } from "./weekday-chip";
|
|
7
|
+
import { HoldCountdown } from "./hold-countdown";
|
|
8
|
+
import { BookingSummaryCard } from "./booking-summary-card";
|
|
9
|
+
import { BookingRow, BookingRowList } from "./booking-row";
|
|
10
|
+
import { Calendar } from "./calendar";
|
|
11
|
+
|
|
12
|
+
const counterLabels = {
|
|
13
|
+
decrease: "Fewer people",
|
|
14
|
+
increase: "More people",
|
|
15
|
+
unit: (count: number) => (count === 1 ? "person" : "people"),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("PartyCounter", () => {
|
|
19
|
+
it("steps the count within its range", async () => {
|
|
20
|
+
const user = userEvent.setup();
|
|
21
|
+
const onValueChange = vi.fn();
|
|
22
|
+
render(<PartyCounter value={2} min={1} max={8} onValueChange={onValueChange} labels={counterLabels} />);
|
|
23
|
+
await user.click(screen.getByRole("button", { name: "More people" }));
|
|
24
|
+
expect(onValueChange).toHaveBeenLastCalledWith(3);
|
|
25
|
+
await user.click(screen.getByRole("button", { name: "Fewer people" }));
|
|
26
|
+
expect(onValueChange).toHaveBeenLastCalledWith(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("disables the end it has reached, keeping the value in place", () => {
|
|
30
|
+
render(<PartyCounter value={1} min={1} max={8} onValueChange={() => {}} labels={counterLabels} />);
|
|
31
|
+
expect(screen.getByRole("button", { name: "Fewer people" })).toBeDisabled();
|
|
32
|
+
expect(screen.getByRole("button", { name: "More people" })).toBeEnabled();
|
|
33
|
+
expect(screen.getByRole("status")).toHaveTextContent("1person");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("TimeSlot", () => {
|
|
38
|
+
it("is a pressed button when selected and reports the pick", async () => {
|
|
39
|
+
const user = userEvent.setup();
|
|
40
|
+
const onSelect = vi.fn();
|
|
41
|
+
render(<TimeSlot time="19:30" meta="Popular" onSelect={onSelect} />);
|
|
42
|
+
const slot = screen.getByRole("button", { name: /19:30/ });
|
|
43
|
+
expect(slot).toHaveAttribute("aria-pressed", "false");
|
|
44
|
+
await user.click(slot);
|
|
45
|
+
expect(onSelect).toHaveBeenCalledOnce();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("cannot be picked when unavailable", async () => {
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
const onSelect = vi.fn();
|
|
51
|
+
render(<TimeSlot time="18:00" unavailable onSelect={onSelect} />);
|
|
52
|
+
await user.click(screen.getByRole("button", { name: "18:00" }));
|
|
53
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("labels each group so the grid reads as sections", () => {
|
|
57
|
+
render(
|
|
58
|
+
<TimeSlotGroups
|
|
59
|
+
groups={[
|
|
60
|
+
{ key: "early", label: "Early", slots: [{ key: "17:00", time: "17:00" }] },
|
|
61
|
+
{ key: "late", label: "Late", slots: [{ key: "21:30", time: "21:30", selected: true }] },
|
|
62
|
+
]}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
expect(screen.getByRole("region", { name: "Early" })).toBeInTheDocument();
|
|
66
|
+
expect(screen.getByRole("button", { name: "21:30" })).toHaveAttribute("aria-pressed", "true");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("announces what is being checked while times load", () => {
|
|
70
|
+
render(<TimeSlotGroupsSkeleton label="Checking availability for 4…" />);
|
|
71
|
+
expect(screen.getByRole("status")).toHaveTextContent("Checking availability for 4…");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("WeekdayChip", () => {
|
|
76
|
+
it("carries the full date for assistive tech and its pressed state", () => {
|
|
77
|
+
render(<WeekdayChip weekday="Fri" day="14" selected aria-label="Friday 14 August" />);
|
|
78
|
+
expect(screen.getByRole("button", { name: "Friday 14 August" })).toHaveAttribute("aria-pressed", "true");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("cannot be picked when the venue is closed", () => {
|
|
82
|
+
render(<WeekdayChip weekday="Tue" day="11" unavailable />);
|
|
83
|
+
expect(screen.getByRole("button")).toBeDisabled();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("HoldCountdown", () => {
|
|
88
|
+
afterEach(() => vi.useRealTimers());
|
|
89
|
+
|
|
90
|
+
const labels = {
|
|
91
|
+
held: "Held for you",
|
|
92
|
+
explanation: "then it goes to the next person",
|
|
93
|
+
remaining: (m: number, s: number) => `${m} minutes ${s} seconds left`,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
it("counts down and calls onExpire once at zero", () => {
|
|
97
|
+
vi.useFakeTimers();
|
|
98
|
+
let now = 0;
|
|
99
|
+
const onExpire = vi.fn();
|
|
100
|
+
render(<HoldCountdown expiresAt={2000} now={() => now} labels={labels} onExpire={onExpire} />);
|
|
101
|
+
expect(screen.getByRole("timer")).toHaveTextContent("00:02");
|
|
102
|
+
act(() => {
|
|
103
|
+
now = 2500;
|
|
104
|
+
vi.advanceTimersByTime(1000);
|
|
105
|
+
});
|
|
106
|
+
expect(screen.getByRole("timer")).toHaveTextContent("00:00");
|
|
107
|
+
act(() => {
|
|
108
|
+
now = 4000;
|
|
109
|
+
vi.advanceTimersByTime(2000);
|
|
110
|
+
});
|
|
111
|
+
expect(onExpire).toHaveBeenCalledOnce();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("never shows a negative time", () => {
|
|
115
|
+
render(<HoldCountdown expiresAt={0} now={() => 60_000} labels={labels} />);
|
|
116
|
+
expect(screen.getByRole("timer")).toHaveTextContent("00:00");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("BookingSummaryCard", () => {
|
|
121
|
+
it("leaves out rows it has nothing for", () => {
|
|
122
|
+
render(
|
|
123
|
+
<BookingSummaryCard
|
|
124
|
+
venueName="Sălcii Bistro"
|
|
125
|
+
date="Fri 14 August"
|
|
126
|
+
timeAndParty="19:30 · 2 people"
|
|
127
|
+
code="BM-8421"
|
|
128
|
+
labels={{ code: "Booking code" }}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
expect(screen.getByText("BM-8421")).toBeInTheDocument();
|
|
132
|
+
expect(screen.queryByText(/requested/)).not.toBeInTheDocument();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("BookingRow", () => {
|
|
137
|
+
it("is one link to the booking, with its status and code", () => {
|
|
138
|
+
render(
|
|
139
|
+
<BookingRowList aria-label="Upcoming bookings">
|
|
140
|
+
<BookingRow
|
|
141
|
+
venueName="Kane"
|
|
142
|
+
venueType="Bistro"
|
|
143
|
+
when="Sun 30 Aug · 18:30"
|
|
144
|
+
party="3 people"
|
|
145
|
+
status="pending"
|
|
146
|
+
statusLabel="Pending"
|
|
147
|
+
code="BM-6403"
|
|
148
|
+
href="/bookings/BM-6403"
|
|
149
|
+
/>
|
|
150
|
+
</BookingRowList>
|
|
151
|
+
);
|
|
152
|
+
const list = screen.getByRole("list", { name: "Upcoming bookings" });
|
|
153
|
+
const link = screen.getByRole("link");
|
|
154
|
+
expect(list).toContainElement(link);
|
|
155
|
+
expect(link).toHaveAttribute("href", "/bookings/BM-6403");
|
|
156
|
+
expect(screen.getAllByText("Pending").length).toBeGreaterThan(0);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("Calendar booking appearance", () => {
|
|
161
|
+
it("marks a closed day and does not let it be picked", async () => {
|
|
162
|
+
const user = userEvent.setup();
|
|
163
|
+
const onSelect = vi.fn();
|
|
164
|
+
const closed = new Date(2026, 7, 25);
|
|
165
|
+
render(
|
|
166
|
+
<Calendar
|
|
167
|
+
appearance="booking"
|
|
168
|
+
mode="single"
|
|
169
|
+
defaultMonth={new Date(2026, 7, 1)}
|
|
170
|
+
onSelect={onSelect}
|
|
171
|
+
disabled={[closed]}
|
|
172
|
+
modifiers={{ closed: [closed] }}
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
const day = screen.getByRole("button", { name: /August 25/ });
|
|
176
|
+
expect(day).toHaveAttribute("data-closed", "true");
|
|
177
|
+
await user.click(day);
|
|
178
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { BookingRow, BookingRowList } from "./booking-row";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof BookingRow> = {
|
|
5
|
+
title: "Booking/BookingRow",
|
|
6
|
+
component: BookingRow,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
parameters: {
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
"A booking in My bookings. Three stacked lines on mobile; one line with the status, the code and a chevron on desktop, where the next booking up is tinted. The whole row is one link.",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof BookingRow>;
|
|
20
|
+
|
|
21
|
+
const rows = (
|
|
22
|
+
<BookingRowList aria-label="Upcoming bookings">
|
|
23
|
+
<BookingRow
|
|
24
|
+
venueName="Sălcii Bistro"
|
|
25
|
+
venueType="Bistro"
|
|
26
|
+
when="Fri 14 Aug · 19:30"
|
|
27
|
+
party="2 people"
|
|
28
|
+
status="confirmed"
|
|
29
|
+
statusLabel="Confirmed"
|
|
30
|
+
code="BM-8421"
|
|
31
|
+
href="#bm-8421"
|
|
32
|
+
highlighted
|
|
33
|
+
/>
|
|
34
|
+
<BookingRow
|
|
35
|
+
venueName="Vatra"
|
|
36
|
+
venueType="Cocktail bar"
|
|
37
|
+
when="Thu 20 Aug · 20:00"
|
|
38
|
+
party="4 people"
|
|
39
|
+
status="confirmed"
|
|
40
|
+
statusLabel="Confirmed"
|
|
41
|
+
code="BM-7712"
|
|
42
|
+
href="#bm-7712"
|
|
43
|
+
/>
|
|
44
|
+
<BookingRow
|
|
45
|
+
venueName="Kane"
|
|
46
|
+
venueType="Bistro"
|
|
47
|
+
when="Sun 30 Aug · 18:30"
|
|
48
|
+
party="3 people"
|
|
49
|
+
status="pending"
|
|
50
|
+
statusLabel="Pending"
|
|
51
|
+
code="BM-6403"
|
|
52
|
+
href="#bm-6403"
|
|
53
|
+
/>
|
|
54
|
+
</BookingRowList>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** B6 mobile. */
|
|
58
|
+
export const Mobile: Story = {
|
|
59
|
+
render: () => <div className="w-[343px]">{rows}</div>,
|
|
60
|
+
parameters: { viewport: { defaultViewport: "mobile1" } },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** B6 desktop. */
|
|
64
|
+
export const Desktop: Story = {
|
|
65
|
+
render: () => <div className="w-[467px]">{rows}</div>,
|
|
66
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { ChevronRight } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { Badge } from "./badge";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* BookingRow and BookingRowList — the guest's bookings in "My bookings".
|
|
8
|
+
*
|
|
9
|
+
* Measured from the booking design (`.bkrow`, and the desktop list):
|
|
10
|
+
*
|
|
11
|
+
* Mobile (below `md`): rows 14px tall-padded, 12px gap, divided by a
|
|
12
|
+
* `line-soft` hairline. A 50px thumb with an 11px radius. Line one: the venue
|
|
13
|
+
* at 14px bold, the code chip pushed right. Line two: the venue type at 11.5px
|
|
14
|
+
* muted. Line three, 7px gaps: when at 12.5px medium, the party at 12px muted,
|
|
15
|
+
* then the status pill.
|
|
16
|
+
*
|
|
17
|
+
* Desktop (`md` and up): one card (14px radius, no inset), rows 14px × 18px
|
|
18
|
+
* with 14px gaps, divided by the same hairline. A 46px thumb, then the venue
|
|
19
|
+
* (bold) with its type inline and muted over "when · party" in the secondary
|
|
20
|
+
* ink, then the status pill, the code chip and a muted chevron. The row that
|
|
21
|
+
* is next up gets a 5% accent wash.
|
|
22
|
+
*
|
|
23
|
+
* The whole row is one link. Strings arrive formatted and localised; the thumb
|
|
24
|
+
* arrives rendered, so the app picks its own image component.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type BookingRowStatus = "confirmed" | "pending";
|
|
28
|
+
|
|
29
|
+
export type BookingRowLinkProps = {
|
|
30
|
+
href: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function DefaultLink({ href, ...props }: BookingRowLinkProps) {
|
|
36
|
+
return <a href={href} {...props} />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type BookingRowProps = {
|
|
40
|
+
venueName: string;
|
|
41
|
+
/** "Bistro", "Cocktail bar" */
|
|
42
|
+
venueType?: string;
|
|
43
|
+
/** "Fri 14 Aug · 19:30" */
|
|
44
|
+
when: string;
|
|
45
|
+
/** "2 people" */
|
|
46
|
+
party: string;
|
|
47
|
+
status: BookingRowStatus;
|
|
48
|
+
/** "Confirmed", "Pending" — already localised. */
|
|
49
|
+
statusLabel: string;
|
|
50
|
+
code: string;
|
|
51
|
+
href: string;
|
|
52
|
+
/** The venue photo, rendered by the app; a quiet placeholder when absent. */
|
|
53
|
+
thumbnail?: React.ReactNode;
|
|
54
|
+
/** The next booking up — tinted on desktop. */
|
|
55
|
+
highlighted?: boolean;
|
|
56
|
+
linkComponent?: React.ComponentType<BookingRowLinkProps>;
|
|
57
|
+
className?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function Thumb({ children, className }: { children?: React.ReactNode; className?: string }) {
|
|
61
|
+
return (
|
|
62
|
+
<span
|
|
63
|
+
aria-hidden
|
|
64
|
+
className={cn(
|
|
65
|
+
// Dark: `surface-2` sits a notch from the card and vanishes; the
|
|
66
|
+
// accent-tinted step reads as a photo slot.
|
|
67
|
+
"block shrink-0 overflow-hidden rounded-[11px] bg-surface-2 dark:bg-accent-subtle [&_img]:size-full [&_img]:object-cover",
|
|
68
|
+
className
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{children}
|
|
72
|
+
</span>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function BookingRow({
|
|
77
|
+
venueName,
|
|
78
|
+
venueType,
|
|
79
|
+
when,
|
|
80
|
+
party,
|
|
81
|
+
status,
|
|
82
|
+
statusLabel,
|
|
83
|
+
code,
|
|
84
|
+
href,
|
|
85
|
+
thumbnail,
|
|
86
|
+
highlighted,
|
|
87
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
88
|
+
className,
|
|
89
|
+
}: BookingRowProps) {
|
|
90
|
+
const statusPill = (
|
|
91
|
+
<Badge as="span" variant={status}>
|
|
92
|
+
{statusLabel}
|
|
93
|
+
</Badge>
|
|
94
|
+
);
|
|
95
|
+
const codeChip = (
|
|
96
|
+
<Badge as="span" variant="code">
|
|
97
|
+
{code}
|
|
98
|
+
</Badge>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<LinkComponent
|
|
103
|
+
href={href}
|
|
104
|
+
className={cn(
|
|
105
|
+
"flex gap-3 py-3.5 leading-[1.2] text-foreground",
|
|
106
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
107
|
+
"md:items-center md:gap-3.5 md:px-[18px] md:hover:bg-accent/[0.03]",
|
|
108
|
+
highlighted && "md:bg-accent/5 md:hover:bg-accent/5",
|
|
109
|
+
className
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
<Thumb className="size-[50px] md:size-[46px]">{thumbnail}</Thumb>
|
|
113
|
+
|
|
114
|
+
{/* Mobile: three stacked lines. */}
|
|
115
|
+
<div className="min-w-0 flex-1 md:hidden">
|
|
116
|
+
<div className="flex items-center justify-between gap-2">
|
|
117
|
+
<b className="truncate text-sm font-bold">{venueName}</b>
|
|
118
|
+
{codeChip}
|
|
119
|
+
</div>
|
|
120
|
+
{venueType && <div className="text-[11.5px] text-muted-foreground">{venueType}</div>}
|
|
121
|
+
<div className="mt-2 flex flex-wrap items-center gap-[7px]">
|
|
122
|
+
<span className="text-[12.5px] font-medium">{when}</span>
|
|
123
|
+
<span className="text-xs text-muted-foreground">{party}</span>
|
|
124
|
+
{statusPill}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Desktop: one line. */}
|
|
129
|
+
<div className="hidden min-w-0 flex-1 md:block">
|
|
130
|
+
<div className="truncate">
|
|
131
|
+
<b className="text-sm font-bold">{venueName}</b>
|
|
132
|
+
{venueType && <span className="ml-1.5 text-xs text-muted-foreground">{venueType}</span>}
|
|
133
|
+
</div>
|
|
134
|
+
<div className="mt-0.5 truncate text-[12.5px] text-ink-secondary">
|
|
135
|
+
{when} · {party}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<span className="hidden md:contents">
|
|
139
|
+
{statusPill}
|
|
140
|
+
{codeChip}
|
|
141
|
+
<ChevronRight aria-hidden className="size-[15px] shrink-0 text-muted-foreground" />
|
|
142
|
+
</span>
|
|
143
|
+
</LinkComponent>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type BookingRowListProps = {
|
|
148
|
+
children: React.ReactNode;
|
|
149
|
+
/** "Upcoming bookings" — names the list for assistive tech. */
|
|
150
|
+
"aria-label"?: string;
|
|
151
|
+
className?: string;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** The list around the rows: hairlines between them on mobile, one card on desktop. */
|
|
155
|
+
export function BookingRowList({ children, "aria-label": ariaLabel, className }: BookingRowListProps) {
|
|
156
|
+
return (
|
|
157
|
+
<ul
|
|
158
|
+
aria-label={ariaLabel}
|
|
159
|
+
className={cn(
|
|
160
|
+
"divide-y divide-line-soft",
|
|
161
|
+
"md:overflow-hidden md:rounded-[14px] md:border md:border-border md:bg-card",
|
|
162
|
+
className
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
{React.Children.map(children, (child) => (child ? <li>{child}</li> : null))}
|
|
166
|
+
</ul>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { BookingSummaryCard } from "./booking-summary-card";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof BookingSummaryCard> = {
|
|
5
|
+
title: "Booking/BookingSummaryCard",
|
|
6
|
+
component: BookingSummaryCard,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
args: {
|
|
9
|
+
venueName: "Sălcii Bistro",
|
|
10
|
+
date: "Fri 14 August",
|
|
11
|
+
timeAndParty: "19:30 · 2 people",
|
|
12
|
+
seating: "Terrace (requested)",
|
|
13
|
+
address: "Strada Iuliu Maniu 12",
|
|
14
|
+
code: "BM-8421",
|
|
15
|
+
labels: { code: "Booking code" },
|
|
16
|
+
},
|
|
17
|
+
decorators: [
|
|
18
|
+
(Story) => (
|
|
19
|
+
<div className="w-[343px]">
|
|
20
|
+
<Story />
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
],
|
|
24
|
+
parameters: {
|
|
25
|
+
docs: {
|
|
26
|
+
description: {
|
|
27
|
+
component:
|
|
28
|
+
"The booking at a glance on the confirmation screen: venue, when, party, seating asked for, address, and the code to give at the door.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default meta;
|
|
35
|
+
type Story = StoryObj<typeof BookingSummaryCard>;
|
|
36
|
+
|
|
37
|
+
/** B3. */
|
|
38
|
+
export const Confirmed: Story = {};
|
|
39
|
+
|
|
40
|
+
/** No seating preference: the row is left out, not shown empty. */
|
|
41
|
+
export const NoPreference: Story = { args: { seating: undefined } };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { CalendarDays, Clock, MapPin } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* BookingSummaryCard — the booking at a glance on the confirmation screen
|
|
7
|
+
* ("Your table is booked"): the venue, then when, how many, where you asked
|
|
8
|
+
* to sit and the address, then the booking code.
|
|
9
|
+
*
|
|
10
|
+
* Measured from the booking design (`.cardx` + `.rowi`): a card with a 1px
|
|
11
|
+
* hairline, 14px radius, 15px inset and a 1px/3px shadow. The venue at 16px
|
|
12
|
+
* bold. Rows 11px below it, 7px apart: a 14px icon (1.8 stroke, muted) in a
|
|
13
|
+
* 16px column, 10px gap, text at 13px in the secondary ink. Then a
|
|
14
|
+
* `line-soft` divider 13px below with 13px above the code row: "BOOKING CODE"
|
|
15
|
+
* at 10.5px uppercase, muted, and the code in mono at 15px bold, pushed right.
|
|
16
|
+
*
|
|
17
|
+
* Every string arrives formatted and localised; the card only lays them out.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type BookingSummaryCardLabels = {
|
|
21
|
+
/** "Booking code" */
|
|
22
|
+
code: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type BookingSummaryCardProps = {
|
|
26
|
+
venueName: string;
|
|
27
|
+
/** "Fri 14 August" */
|
|
28
|
+
date: string;
|
|
29
|
+
/** "19:30 · 2 people" */
|
|
30
|
+
timeAndParty: string;
|
|
31
|
+
/** "Terrace (requested)" — omitted when there is no preference. */
|
|
32
|
+
seating?: string;
|
|
33
|
+
/** "Strada Iuliu Maniu 12" */
|
|
34
|
+
address?: string;
|
|
35
|
+
/** "BM-8421" */
|
|
36
|
+
code: string;
|
|
37
|
+
labels: BookingSummaryCardLabels;
|
|
38
|
+
className?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function Row({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex items-center gap-2.5 text-[13px] leading-4 text-ink-secondary [&+&]:mt-[7px]">
|
|
44
|
+
<span className="flex w-4 shrink-0 justify-center text-muted-foreground" aria-hidden>
|
|
45
|
+
{icon}
|
|
46
|
+
</span>
|
|
47
|
+
<span>{children}</span>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ICON = "size-3.5 [stroke-width:1.8]";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The design's seating glyph, a low bench-sofa. Not in lucide (Armchair and
|
|
56
|
+
* Sofa both read as a different object), so drawn from the design's own paths.
|
|
57
|
+
*/
|
|
58
|
+
function SeatIcon({ className }: { className?: string }) {
|
|
59
|
+
return (
|
|
60
|
+
<svg
|
|
61
|
+
viewBox="0 0 24 24"
|
|
62
|
+
fill="none"
|
|
63
|
+
stroke="currentColor"
|
|
64
|
+
strokeLinecap="round"
|
|
65
|
+
strokeLinejoin="round"
|
|
66
|
+
className={className}
|
|
67
|
+
aria-hidden
|
|
68
|
+
>
|
|
69
|
+
<path d="M5 11V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4" />
|
|
70
|
+
<path d="M5 11a2 2 0 0 0 0 4h14a2 2 0 0 0 0-4" />
|
|
71
|
+
<path d="M6 15v3M18 15v3M7 15h10" />
|
|
72
|
+
</svg>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function BookingSummaryCard({
|
|
77
|
+
venueName,
|
|
78
|
+
date,
|
|
79
|
+
timeAndParty,
|
|
80
|
+
seating,
|
|
81
|
+
address,
|
|
82
|
+
code,
|
|
83
|
+
labels,
|
|
84
|
+
className,
|
|
85
|
+
}: BookingSummaryCardProps) {
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
data-slot="booking-summary-card"
|
|
89
|
+
className={cn(
|
|
90
|
+
"rounded-[14px] border border-border bg-card p-[15px] leading-[1.2] shadow-[0_1px_3px_oklch(0.5_0_0/0.1)]",
|
|
91
|
+
className
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
<b className="text-base leading-5 font-bold text-foreground">{venueName}</b>
|
|
95
|
+
<div className="mt-[11px]">
|
|
96
|
+
<Row icon={<CalendarDays className={ICON} />}>{date}</Row>
|
|
97
|
+
<Row icon={<Clock className={ICON} />}>{timeAndParty}</Row>
|
|
98
|
+
{seating && <Row icon={<SeatIcon className={ICON} />}>{seating}</Row>}
|
|
99
|
+
{address && <Row icon={<MapPin className={ICON} />}>{address}</Row>}
|
|
100
|
+
</div>
|
|
101
|
+
<div className="mt-[13px] flex items-center justify-between border-t border-line-soft pt-[13px]">
|
|
102
|
+
<span className="text-[10.5px] tracking-[0.05em] text-muted-foreground uppercase">
|
|
103
|
+
{labels.code}
|
|
104
|
+
</span>
|
|
105
|
+
<b className="font-mono text-[15px] font-bold tabular-nums text-foreground">{code}</b>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -127,6 +127,29 @@ export const AllVariants: Story = {
|
|
|
127
127
|
),
|
|
128
128
|
};
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The committing action of the booking flow, on the brand gradient. `cta` in
|
|
132
|
+
* the footers ("Continue · 19:30", "Confirm booking"), `cta-sm` inline
|
|
133
|
+
* ("Confirm the table" beside the hold countdown). Beside it, the quiet
|
|
134
|
+
* `outline` for Back / Cancel.
|
|
135
|
+
*/
|
|
136
|
+
export const Cta: Story = {
|
|
137
|
+
render: () => (
|
|
138
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
139
|
+
<Button variant="outline">Back</Button>
|
|
140
|
+
<Button variant="cta" size="cta">
|
|
141
|
+
Continue · 19:30
|
|
142
|
+
</Button>
|
|
143
|
+
<Button variant="cta" size="cta-sm">
|
|
144
|
+
Confirm the table
|
|
145
|
+
</Button>
|
|
146
|
+
<Button variant="cta" size="cta" disabled>
|
|
147
|
+
Continue
|
|
148
|
+
</Button>
|
|
149
|
+
</div>
|
|
150
|
+
),
|
|
151
|
+
};
|
|
152
|
+
|
|
130
153
|
export const AllSizes: Story = {
|
|
131
154
|
render: () => (
|
|
132
155
|
<div className="flex items-center gap-4">
|
|
@@ -62,6 +62,15 @@ const buttonVariants = cva(
|
|
|
62
62
|
/** An ON state that is not the primary — a toggled mode, a guide running. */
|
|
63
63
|
warning:
|
|
64
64
|
"bg-warning text-warning-foreground shadow-sm hover:bg-warning/90 hover:shadow",
|
|
65
|
+
/**
|
|
66
|
+
* The committing action of a flow: "Continue · 19:30", "Confirm
|
|
67
|
+
* booking", "Reserve", "See booking". The booking design draws every
|
|
68
|
+
* one of them on `--gradient-primary` with white type, in both themes —
|
|
69
|
+
* the gradient is warm in dark too, so the ink never inverts.
|
|
70
|
+
* `bg-[image:…]`, not `bg-[…]`: the token is a gradient, and only
|
|
71
|
+
* `background-image` accepts it. Pair with `size="cta"` / `"cta-sm"`.
|
|
72
|
+
*/
|
|
73
|
+
cta: "bg-[image:var(--gradient-primary)] text-white hover:brightness-[0.94] active:brightness-90",
|
|
65
74
|
},
|
|
66
75
|
size: {
|
|
67
76
|
xs: "h-8 rounded-lg px-3 text-xs [&_svg]:size-3.5",
|
|
@@ -70,6 +79,10 @@ const buttonVariants = cva(
|
|
|
70
79
|
lg: "h-12 rounded-2xl px-8",
|
|
71
80
|
/** The `ink` action's shape. Owns the radius so no variant fights it. */
|
|
72
81
|
pill: "h-11 rounded-full px-5 text-[13.5px] font-bold",
|
|
82
|
+
/** The booking design's action: 46px, 12px radius, 15px semibold. */
|
|
83
|
+
cta: "h-[46px] rounded-xl px-5 text-[15px] font-semibold",
|
|
84
|
+
/** Its compact form, in dense footers and inline rows: 38px, 10px radius. */
|
|
85
|
+
"cta-sm": "h-[38px] rounded-[10px] px-4 text-[13.5px] font-semibold",
|
|
73
86
|
icon: "h-11 w-11 rounded-full",
|
|
74
87
|
"icon-xs": "h-8 w-8 rounded-full [&_svg]:size-3.5",
|
|
75
88
|
"icon-sm": "h-9 w-9 rounded-full",
|
|
@@ -45,3 +45,30 @@ function CalendarRangeDemo() {
|
|
|
45
45
|
export const Range: Story = {
|
|
46
46
|
render: () => <CalendarRangeDemo />,
|
|
47
47
|
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The booking flow's month grid (B1 desktop): 32px round cells in mono,
|
|
51
|
+
* the chosen day on the brand gradient, past days faded, a closed day struck
|
|
52
|
+
* through. Frozen on August 2026 so it matches the design frame.
|
|
53
|
+
*/
|
|
54
|
+
function BookingCalendarDemo() {
|
|
55
|
+
const [date, setDate] = useState<Date | undefined>(new Date(2026, 7, 14));
|
|
56
|
+
return (
|
|
57
|
+
<div className="w-[270px]">
|
|
58
|
+
<Calendar
|
|
59
|
+
appearance="booking"
|
|
60
|
+
mode="single"
|
|
61
|
+
weekStartsOn={1}
|
|
62
|
+
defaultMonth={new Date(2026, 7, 1)}
|
|
63
|
+
selected={date}
|
|
64
|
+
onSelect={setDate}
|
|
65
|
+
disabled={[{ before: new Date(2026, 7, 6) }, new Date(2026, 7, 25)]}
|
|
66
|
+
modifiers={{ closed: [new Date(2026, 7, 25)] }}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const Booking: Story = {
|
|
73
|
+
render: () => <BookingCalendarDemo />,
|
|
74
|
+
};
|