@wix/agent-skills 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +2 -1
- package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +14 -0
- package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +11 -6
- package/skills/wix-app/references/editor-react-component/REACT-PATTERNS.md +3 -17
- package/skills/wix-app/references/editor-react-component/SSR.md +119 -0
- package/skills/wix-design-system/SKILL.md +1 -1
- package/skills/wix-headless/references/BUILD-astro.md +5 -7
- package/skills/wix-headless/references/SETUP.md +1 -1
- package/skills/wix-headless/references/astro/bookings/COMPONENTS.md +41 -333
- package/skills/wix-headless/references/astro/bookings/SERVICES_PAGES.md +58 -428
- package/skills/wix-headless/references/astro/designer/INSTRUCTIONS.md +4 -7
- package/skills/wix-headless/references/astro/templates/bookings/AvailabilityCalendar.tsx +158 -143
- package/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx +104 -162
- package/skills/wix-headless/references/astro/templates/bookings/ServiceBookingFlow.tsx +17 -13
- package/skills/wix-headless/references/astro/templates/bookings/booking-confirmation.astro +16 -78
- package/skills/wix-headless/references/astro/templates/bookings/bookingDriver.ts +245 -0
- package/skills/wix-headless/references/astro/templates/bookings/components-bookings.css +122 -179
- package/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro +61 -9
- package/skills/wix-headless/references/astro/templates/bookings/services/index.astro +10 -2
- package/skills/wix-headless/references/bookings/FLOW.md +185 -0
- package/skills/wix-headless/references/bookings/INSTRUCTIONS.md +60 -76
- package/skills/wix-headless/references/custom/bookings/WIRING.md +123 -65
- package/skills/wix-headless/references/verticals/bookings.md +6 -8
- package/skills/wix-manage/SKILL.md +12 -0
- package/skills/wix-manage/references/app-installation/install-wix-apps.md +3 -27
- package/skills/wix-manage/references/bookings/create-appointment-service.md +266 -0
- package/skills/wix-manage/references/bookings/create-booking-service-from-prompt.md +37 -0
- package/skills/wix-manage/references/bookings/create-class-service.md +245 -0
- package/skills/wix-manage/references/bookings/create-course-service.md +249 -0
- package/skills/wix-manage/references/cms/cms-data-items-crud.md +2 -1
- package/skills/wix-manage/references/cms/cms-schema-management.md +1 -1
- package/skills/wix-manage/references/stores/create-product-from-image.md +154 -2
- package/skills/wix-headless/references/astro/templates/bookings/ManageBooking.tsx +0 -84
- package/skills/wix-headless/references/astro/templates/bookings/api/confirm-booking.ts +0 -34
- package/skills/wix-headless/references/astro/templates/bookings/api/waitlist.ts +0 -74
- package/skills/wix-headless/references/astro/templates/bookings/manage-booking.astro +0 -76
|
@@ -1,38 +1,23 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
serviceId: string;
|
|
22
|
-
timezone: string;
|
|
23
|
-
// APPOINTMENT only:
|
|
24
|
-
scheduleId?: string;
|
|
25
|
-
locationId?: string;
|
|
26
|
-
locationType?: string;
|
|
27
|
-
// CLASS only:
|
|
28
|
-
eventId?: string;
|
|
29
|
-
totalCapacity?: number;
|
|
30
|
-
remainingCapacity?: number;
|
|
31
|
-
bookableCapacity?: number;
|
|
32
|
-
isFull?: boolean; // CLASS full → waitlist
|
|
33
|
-
// resolved on select (CLASS):
|
|
34
|
-
instructorName?: string;
|
|
35
|
-
}
|
|
2
|
+
import { availabilityTimeSlots, eventTimeSlots } from "@wix/bookings";
|
|
3
|
+
import type { SelectedSlot } from "./bookingDriver";
|
|
4
|
+
|
|
5
|
+
// AvailabilityCalendar.tsx — client:only="react" island. A week calendar: a
|
|
6
|
+
// 7-day strip with week navigation, then the picked day's time slots. This is
|
|
7
|
+
// the usable shape — a flat list of every slot with time-only labels leaves the
|
|
8
|
+
// visitor unable to tell which day a slot is on.
|
|
9
|
+
//
|
|
10
|
+
// SDK calls run ambiently (the @wix/astro visitor client), like the ecom
|
|
11
|
+
// CartView island — no createClient/OAuthStrategy here. On an own/own-build SPA
|
|
12
|
+
// acquire a visitor client (OAuthStrategy) and call the same functions on it.
|
|
13
|
+
//
|
|
14
|
+
// Branches on serviceType:
|
|
15
|
+
// APPOINTMENT → availabilityTimeSlots.listAvailabilityTimeSlots() (serviceId is
|
|
16
|
+
// a single GUID string; slots carry scheduleId).
|
|
17
|
+
// CLASS → eventTimeSlots.listEventTimeSlots() (serviceIds is an array;
|
|
18
|
+
// slots carry eventInfo.eventId and NO scheduleId).
|
|
19
|
+
// Re-export SelectedSlot from bookingDriver so the form/driver share one shape.
|
|
20
|
+
export type { SelectedSlot } from "./bookingDriver";
|
|
36
21
|
|
|
37
22
|
interface Props {
|
|
38
23
|
serviceId: string;
|
|
@@ -41,173 +26,203 @@ interface Props {
|
|
|
41
26
|
onSlotSelected: (slot: SelectedSlot) => void;
|
|
42
27
|
}
|
|
43
28
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }),
|
|
47
|
-
});
|
|
29
|
+
const DAY_MS = 86_400_000;
|
|
30
|
+
const HORIZON_WEEKS = 13; // how far "check next availability" probes forward
|
|
48
31
|
|
|
49
32
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
50
|
-
// Local date string YYYY-MM-DDThh:mm:ss (no Z) —
|
|
33
|
+
// Local date string YYYY-MM-DDThh:mm:ss (no Z) — availability expects local time.
|
|
51
34
|
const localDateString = (d: Date) =>
|
|
52
35
|
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
53
36
|
const tz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
37
|
+
const dateKey = (iso: string) => iso.slice(0, 10); // group slots by calendar day
|
|
38
|
+
const timeLabel = (iso: string) =>
|
|
39
|
+
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
40
|
+
|
|
41
|
+
const startOfDay = (d: Date) => { const n = new Date(d); n.setHours(0, 0, 0, 0); return n; };
|
|
42
|
+
// Monday-start week containing `d`.
|
|
43
|
+
const mondayOf = (d: Date) => {
|
|
44
|
+
const n = startOfDay(d);
|
|
45
|
+
const day = (n.getDay() + 6) % 7; // 0 = Monday
|
|
46
|
+
n.setDate(n.getDate() - day);
|
|
47
|
+
return n;
|
|
48
|
+
};
|
|
54
49
|
|
|
55
|
-
|
|
50
|
+
// Fetch one week of availability and group the slots by calendar day.
|
|
51
|
+
const fetchWeek = async (
|
|
56
52
|
serviceId: string,
|
|
57
53
|
serviceType: "APPOINTMENT" | "CLASS",
|
|
58
|
-
|
|
59
|
-
): Promise<
|
|
60
|
-
const
|
|
61
|
-
const
|
|
54
|
+
weekStart: Date,
|
|
55
|
+
): Promise<Record<string, SelectedSlot[]>> => {
|
|
56
|
+
const from = startOfDay(weekStart);
|
|
57
|
+
const to = new Date(from.getTime() + 7 * DAY_MS);
|
|
62
58
|
const timeZone = tz();
|
|
59
|
+
let raw: any[] = [];
|
|
63
60
|
|
|
64
61
|
if (serviceType === "CLASS") {
|
|
65
|
-
const result = await
|
|
66
|
-
serviceIds: [serviceId], //
|
|
67
|
-
fromLocalDate: localDateString(
|
|
68
|
-
toLocalDate: localDateString(
|
|
62
|
+
const result = await eventTimeSlots.listEventTimeSlots({
|
|
63
|
+
serviceIds: [serviceId], // CLASS API takes an array
|
|
64
|
+
fromLocalDate: localDateString(from),
|
|
65
|
+
toLocalDate: localDateString(to),
|
|
69
66
|
timeZone,
|
|
70
|
-
includeNonBookable:
|
|
71
|
-
cursorPaging: { limit:
|
|
67
|
+
includeNonBookable: false,
|
|
68
|
+
cursorPaging: { limit: 100 },
|
|
72
69
|
});
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
serviceType: "CLASS",
|
|
70
|
+
raw = (result.timeSlots ?? []).map((s: any) => ({
|
|
71
|
+
serviceType: "CLASS" as const,
|
|
76
72
|
serviceId,
|
|
77
73
|
localStartDate: s.localStartDate,
|
|
78
74
|
localEndDate: s.localEndDate,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
75
|
+
timezone: result.timeZone ?? timeZone,
|
|
76
|
+
eventId: s.eventInfo?.eventId, // session id — no scheduleId on event slots
|
|
77
|
+
}));
|
|
78
|
+
} else {
|
|
79
|
+
const result = await availabilityTimeSlots.listAvailabilityTimeSlots({
|
|
80
|
+
serviceId, // single GUID string — NOT an array
|
|
81
|
+
fromLocalDate: localDateString(from),
|
|
82
|
+
toLocalDate: localDateString(to),
|
|
83
|
+
timeZone,
|
|
84
|
+
bookable: true,
|
|
85
|
+
cursorPaging: { limit: 100 },
|
|
86
|
+
});
|
|
87
|
+
raw = (result.timeSlots ?? []).map((s: any) => ({
|
|
88
|
+
serviceType: "APPOINTMENT" as const,
|
|
89
|
+
serviceId,
|
|
90
|
+
localStartDate: s.localStartDate,
|
|
91
|
+
localEndDate: s.localEndDate,
|
|
92
|
+
timezone: result.timeZone ?? timeZone,
|
|
93
|
+
scheduleId: s.scheduleId,
|
|
94
|
+
locationId: s.location?._id, // the id field is _id, not .id
|
|
95
|
+
locationType: s.location?.locationType,
|
|
85
96
|
}));
|
|
86
|
-
return { slots, timezone: z };
|
|
87
97
|
}
|
|
88
98
|
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
toLocalDate: localDateString(end),
|
|
94
|
-
timeZone,
|
|
95
|
-
bookable: true,
|
|
96
|
-
cursorPaging: { limit: 50 },
|
|
97
|
-
});
|
|
98
|
-
const z = result.timeZone ?? timeZone;
|
|
99
|
-
const slots: SelectedSlot[] = (result.timeSlots ?? []).map((s: any) => ({
|
|
100
|
-
serviceType: "APPOINTMENT",
|
|
101
|
-
serviceId,
|
|
102
|
-
localStartDate: s.localStartDate,
|
|
103
|
-
localEndDate: s.localEndDate,
|
|
104
|
-
scheduleId: s.scheduleId,
|
|
105
|
-
locationId: s.location?.id,
|
|
106
|
-
locationType: s.location?.locationType,
|
|
107
|
-
isFull: false,
|
|
108
|
-
timezone: z,
|
|
109
|
-
}));
|
|
110
|
-
return { slots, timezone: z };
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
// CLASS instructor lives on the event's resources, which the LIST call omits —
|
|
114
|
-
// only getEventTimeSlot returns them. Resolve for the picked slot.
|
|
115
|
-
const fetchInstructor = async (eventId: string): Promise<string | undefined> => {
|
|
116
|
-
try {
|
|
117
|
-
const { timeSlot } = await wixClient.eventTimeSlots.getEventTimeSlot(eventId);
|
|
118
|
-
const names = (timeSlot?.availableResources ?? [])
|
|
119
|
-
.flatMap((g: any) => g.resources ?? [])
|
|
120
|
-
.map((r: any) => r.name)
|
|
121
|
-
.filter(Boolean);
|
|
122
|
-
return names[0];
|
|
123
|
-
} catch {
|
|
124
|
-
return undefined;
|
|
99
|
+
const byDay: Record<string, SelectedSlot[]> = {};
|
|
100
|
+
for (const slot of raw) {
|
|
101
|
+
if (!slot.localStartDate) continue;
|
|
102
|
+
(byDay[dateKey(slot.localStartDate)] ||= []).push(slot);
|
|
125
103
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const dayLabel = (d: Date) =>
|
|
129
|
-
d.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" });
|
|
130
|
-
const timeLabel = (iso: string) =>
|
|
131
|
-
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
132
|
-
const startOfToday = () => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; };
|
|
133
|
-
|
|
134
|
-
const capacityLabel = (s: SelectedSlot): string | null => {
|
|
135
|
-
if (s.serviceType !== "CLASS") return null;
|
|
136
|
-
const left = s.bookableCapacity ?? s.remainingCapacity ?? 0;
|
|
137
|
-
if (left <= 0) return "Full · join waitlist";
|
|
138
|
-
if (left <= 3) return `${left} spot${left === 1 ? "" : "s"} left`;
|
|
139
|
-
return `${left} spots`;
|
|
104
|
+
return byDay;
|
|
140
105
|
};
|
|
141
106
|
|
|
142
107
|
export default function AvailabilityCalendar({ serviceId, serviceName, serviceType, onSlotSelected }: Props) {
|
|
143
|
-
const
|
|
144
|
-
const [
|
|
145
|
-
const [
|
|
108
|
+
const thisMonday = useMemo(() => mondayOf(new Date()), []);
|
|
109
|
+
const [weekStart, setWeekStart] = useState<Date>(thisMonday);
|
|
110
|
+
const [byDay, setByDay] = useState<Record<string, SelectedSlot[]>>({});
|
|
111
|
+
const [selectedDay, setSelectedDay] = useState<string | null>(null);
|
|
146
112
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
|
147
|
-
const
|
|
148
|
-
|
|
113
|
+
const [status, setStatus] = useState<"loading" | "ready" | "error" | "searching">("loading");
|
|
114
|
+
|
|
115
|
+
const days = useMemo(
|
|
116
|
+
() => Array.from({ length: 7 }, (_, i) => new Date(weekStart.getTime() + i * DAY_MS)),
|
|
117
|
+
[weekStart],
|
|
118
|
+
);
|
|
119
|
+
const atFirstWeek = weekStart.getTime() <= thisMonday.getTime();
|
|
149
120
|
|
|
150
|
-
const load = useCallback(async (
|
|
121
|
+
const load = useCallback(async (start: Date) => {
|
|
151
122
|
setStatus("loading"); setSelectedKey(null);
|
|
152
123
|
try {
|
|
153
|
-
const
|
|
154
|
-
|
|
124
|
+
const grouped = await fetchWeek(serviceId, serviceType, start);
|
|
125
|
+
setByDay(grouped);
|
|
126
|
+
const firstDayWithSlots = Object.keys(grouped).sort()[0] ?? null;
|
|
127
|
+
setSelectedDay(firstDayWithSlots);
|
|
128
|
+
setStatus("ready");
|
|
155
129
|
} catch (err) {
|
|
156
130
|
console.error("[availability] list failed:", err);
|
|
157
131
|
setStatus("error");
|
|
158
132
|
}
|
|
159
133
|
}, [serviceId, serviceType]);
|
|
160
134
|
|
|
161
|
-
useEffect(() => { void load(
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
135
|
+
useEffect(() => { void load(weekStart); }, [weekStart, load]);
|
|
136
|
+
|
|
137
|
+
const shiftWeek = (deltaWeeks: number) =>
|
|
138
|
+
setWeekStart((prev) => startOfDay(new Date(prev.getTime() + deltaWeeks * 7 * DAY_MS)));
|
|
139
|
+
|
|
140
|
+
// Probe forward week-by-week for the next week that has any slots.
|
|
141
|
+
const checkNextAvailability = useCallback(async () => {
|
|
142
|
+
setStatus("searching");
|
|
143
|
+
let cursor = new Date(weekStart.getTime() + 7 * DAY_MS);
|
|
144
|
+
for (let i = 0; i < HORIZON_WEEKS; i++) {
|
|
145
|
+
try {
|
|
146
|
+
const grouped = await fetchWeek(serviceId, serviceType, cursor);
|
|
147
|
+
if (Object.keys(grouped).length > 0) {
|
|
148
|
+
setWeekStart(cursor); // triggers load() via the effect
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
} catch { /* keep probing */ }
|
|
152
|
+
cursor = new Date(cursor.getTime() + 7 * DAY_MS);
|
|
153
|
+
}
|
|
154
|
+
setStatus("ready"); // nothing found within the horizon
|
|
155
|
+
}, [serviceId, serviceType, weekStart]);
|
|
167
156
|
|
|
168
|
-
const handleSelect =
|
|
169
|
-
// Guard on the type-appropriate id — CLASS slots have no scheduleId.
|
|
157
|
+
const handleSelect = (s: SelectedSlot) => {
|
|
170
158
|
const id = s.serviceType === "CLASS" ? s.eventId : s.scheduleId;
|
|
171
159
|
if (!s.localStartDate || !id) return;
|
|
172
|
-
setSelectedKey(
|
|
173
|
-
|
|
174
|
-
onSlotSelected({ ...s, instructorName });
|
|
160
|
+
setSelectedKey(`${s.localStartDate}|${id}`);
|
|
161
|
+
onSlotSelected(s);
|
|
175
162
|
};
|
|
176
163
|
|
|
164
|
+
const weekLabel = `${days[0].toLocaleDateString([], { month: "short", day: "numeric" })} – ${days[6].toLocaleDateString([], { month: "short", day: "numeric" })}`;
|
|
165
|
+
const daySlots = selectedDay ? byDay[selectedDay] ?? [] : [];
|
|
166
|
+
const weekIsEmpty = status === "ready" && Object.keys(byDay).length === 0;
|
|
167
|
+
|
|
177
168
|
return (
|
|
178
169
|
<div className="availability-calendar">
|
|
179
|
-
<div className="availability-
|
|
180
|
-
<button type="button" className="availability-nav-btn" onClick={() =>
|
|
181
|
-
<span className="availability-
|
|
182
|
-
<button type="button" className="availability-nav-btn" onClick={() =>
|
|
170
|
+
<div className="availability-week-nav">
|
|
171
|
+
<button type="button" className="availability-nav-btn" onClick={() => shiftWeek(-1)} disabled={atFirstWeek} aria-label="Previous week">← Prev week</button>
|
|
172
|
+
<span className="availability-week-label">{weekLabel}</span>
|
|
173
|
+
<button type="button" className="availability-nav-btn" onClick={() => shiftWeek(1)} aria-label="Next week">Next week →</button>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div className="availability-day-strip" role="group" aria-label="Pick a day">
|
|
177
|
+
{days.map((d) => {
|
|
178
|
+
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
179
|
+
const has = (byDay[key]?.length ?? 0) > 0;
|
|
180
|
+
const isSelected = key === selectedDay;
|
|
181
|
+
return (
|
|
182
|
+
<button
|
|
183
|
+
key={key}
|
|
184
|
+
type="button"
|
|
185
|
+
className={["availability-day", isSelected ? "availability-day--selected" : "", has ? "availability-day--has-slots" : "availability-day--empty"].filter(Boolean).join(" ")}
|
|
186
|
+
disabled={!has}
|
|
187
|
+
aria-pressed={isSelected}
|
|
188
|
+
onClick={() => { setSelectedDay(key); setSelectedKey(null); }}
|
|
189
|
+
>
|
|
190
|
+
<span className="availability-day-name">{d.toLocaleDateString([], { weekday: "short" })}</span>
|
|
191
|
+
<span className="availability-day-num">{d.getDate()}</span>
|
|
192
|
+
</button>
|
|
193
|
+
);
|
|
194
|
+
})}
|
|
183
195
|
</div>
|
|
184
196
|
|
|
185
197
|
{status === "loading" && <p className="availability-loading">Checking availability…</p>}
|
|
198
|
+
{status === "searching" && <p className="availability-loading">Searching for the next available time…</p>}
|
|
186
199
|
{status === "error" && (
|
|
187
200
|
<>
|
|
188
201
|
<p className="availability-error">Could not load availability — please try again.</p>
|
|
189
|
-
<button type="button" className="availability-nav-btn" onClick={() => void load(
|
|
202
|
+
<button type="button" className="availability-nav-btn" onClick={() => void load(weekStart)}>Retry</button>
|
|
190
203
|
</>
|
|
191
204
|
)}
|
|
192
|
-
{
|
|
193
|
-
<
|
|
205
|
+
{weekIsEmpty && (
|
|
206
|
+
<div className="availability-empty">
|
|
207
|
+
<p>No availability this week.</p>
|
|
208
|
+
<button type="button" className="availability-nav-btn" onClick={() => void checkNextAvailability()}>Check next availability</button>
|
|
209
|
+
</div>
|
|
194
210
|
)}
|
|
195
|
-
{status === "ready" &&
|
|
211
|
+
{status === "ready" && daySlots.length > 0 && (
|
|
196
212
|
<div className="availability-slots" role="group" aria-label={`Available times for ${serviceName}`}>
|
|
197
|
-
{
|
|
198
|
-
const
|
|
213
|
+
{daySlots.map((s) => {
|
|
214
|
+
const id = s.serviceType === "CLASS" ? s.eventId : s.scheduleId;
|
|
215
|
+
const key = `${s.localStartDate}|${id}`;
|
|
199
216
|
const isSelected = key === selectedKey;
|
|
200
|
-
const cap = capacityLabel(s);
|
|
201
217
|
return (
|
|
202
218
|
<button
|
|
203
219
|
key={key}
|
|
204
220
|
type="button"
|
|
205
|
-
className={["time-slot", isSelected ? "time-slot--selected" : "time-slot--available"
|
|
221
|
+
className={["time-slot", isSelected ? "time-slot--selected" : "time-slot--available"].join(" ")}
|
|
206
222
|
aria-pressed={isSelected}
|
|
207
|
-
onClick={() =>
|
|
223
|
+
onClick={() => handleSelect(s)}
|
|
208
224
|
>
|
|
209
225
|
<span className="time-slot-time">{timeLabel(s.localStartDate)}</span>
|
|
210
|
-
{cap && <span className="time-slot-capacity">{cap}</span>}
|
|
211
226
|
</button>
|
|
212
227
|
);
|
|
213
228
|
})}
|