@wix/agent-skills 1.8.0 → 1.10.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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +2 -1
  3. package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +14 -0
  4. package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +11 -6
  5. package/skills/wix-app/references/editor-react-component/REACT-PATTERNS.md +3 -17
  6. package/skills/wix-app/references/editor-react-component/SSR.md +119 -0
  7. package/skills/wix-design-system/SKILL.md +1 -1
  8. package/skills/wix-headless/references/BUILD-astro.md +5 -7
  9. package/skills/wix-headless/references/DESIGN_SYSTEM.md +6 -16
  10. package/skills/wix-headless/references/SETUP.md +1 -1
  11. package/skills/wix-headless/references/astro/bookings/COMPONENTS.md +41 -333
  12. package/skills/wix-headless/references/astro/bookings/SERVICES_PAGES.md +77 -428
  13. package/skills/wix-headless/references/astro/cms/FAQ_KNOWLEDGE_BASE.md +2 -2
  14. package/skills/wix-headless/references/astro/designer/INSTRUCTIONS.md +7 -10
  15. package/skills/wix-headless/references/astro/ecom/CART_WIRING.md +2 -2
  16. package/skills/wix-headless/references/astro/ecom/COMPONENTS_CSS.md +3 -3
  17. package/skills/wix-headless/references/astro/gift-cards/COMPONENTS.md +2 -2
  18. package/skills/wix-headless/references/astro/stores/BACK_IN_STOCK.md +7 -7
  19. package/skills/wix-headless/references/astro/stores/COMPONENTS_CSS.md +4 -4
  20. package/skills/wix-headless/references/astro/stores/PRODUCT_PAGES.md +2 -2
  21. package/skills/wix-headless/references/astro/templates/Footer.astro +4 -4
  22. package/skills/wix-headless/references/astro/templates/Navigation.astro +4 -4
  23. package/skills/wix-headless/references/astro/templates/bookings/AvailabilityCalendar.tsx +298 -142
  24. package/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx +104 -162
  25. package/skills/wix-headless/references/astro/templates/bookings/ServiceBookingFlow.tsx +35 -14
  26. package/skills/wix-headless/references/astro/templates/bookings/ServiceCard.astro +11 -2
  27. package/skills/wix-headless/references/astro/templates/bookings/booking-confirmation.astro +23 -85
  28. package/skills/wix-headless/references/astro/templates/bookings/bookingDriver.ts +245 -0
  29. package/skills/wix-headless/references/astro/templates/bookings/components-bookings.css +209 -198
  30. package/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro +114 -19
  31. package/skills/wix-headless/references/astro/templates/bookings/services/index.astro +131 -15
  32. package/skills/wix-headless/references/astro/templates/ecom/CartView.tsx +3 -3
  33. package/skills/wix-headless/references/astro/templates/ecom/cart.astro +3 -3
  34. package/skills/wix-headless/references/astro/templates/ecom/components-ecom.css +17 -17
  35. package/skills/wix-headless/references/astro/templates/ecom/thank-you.astro +3 -3
  36. package/skills/wix-headless/references/astro/templates/gift-cards/_home-teaser-snippet.astro +4 -4
  37. package/skills/wix-headless/references/astro/templates/gift-cards/components-gift-cards.css +19 -18
  38. package/skills/wix-headless/references/astro/templates/gift-cards/gift-cards.astro +3 -3
  39. package/skills/wix-headless/references/astro/templates/global.css +17 -15
  40. package/skills/wix-headless/references/astro/templates/index.astro +4 -4
  41. package/skills/wix-headless/references/astro/templates/stores/components-stores.css +31 -31
  42. package/skills/wix-headless/references/astro/templates/stores/products/[slug].astro +4 -4
  43. package/skills/wix-headless/references/bookings/FLOW.md +246 -0
  44. package/skills/wix-headless/references/bookings/INSTRUCTIONS.md +65 -76
  45. package/skills/wix-headless/references/bookings/SERVICES_DATA.md +60 -0
  46. package/skills/wix-headless/references/custom/bookings/WIRING.md +169 -60
  47. package/skills/wix-headless/references/shared/DESIGN_MD.md +2 -3
  48. package/skills/wix-headless/references/shared/IMPLEMENTER.md +4 -4
  49. package/skills/wix-headless/references/shared/STYLING.md +15 -18
  50. package/skills/wix-headless/references/stores/INSTRUCTIONS.md +2 -2
  51. package/skills/wix-headless/references/verticals/bookings.md +9 -9
  52. package/skills/wix-headless/scripts/compose.mjs +4 -19
  53. package/skills/wix-headless/scripts/emit-design-tokens.mjs +0 -3
  54. package/skills/wix-manage/SKILL.md +12 -0
  55. package/skills/wix-manage/references/app-installation/install-wix-apps.md +3 -27
  56. package/skills/wix-manage/references/bookings/create-appointment-service.md +254 -0
  57. package/skills/wix-manage/references/bookings/create-booking-service-from-prompt.md +37 -0
  58. package/skills/wix-manage/references/bookings/create-class-service.md +233 -0
  59. package/skills/wix-manage/references/bookings/create-course-service.md +237 -0
  60. package/skills/wix-manage/references/cms/cms-data-items-crud.md +2 -1
  61. package/skills/wix-manage/references/cms/cms-schema-management.md +1 -1
  62. package/skills/wix-manage/references/stores/create-product-from-image.md +154 -2
  63. package/skills/wix-headless/references/astro/templates/bookings/ManageBooking.tsx +0 -84
  64. package/skills/wix-headless/references/astro/templates/bookings/api/confirm-booking.ts +0 -34
  65. package/skills/wix-headless/references/astro/templates/bookings/api/waitlist.ts +0 -74
  66. package/skills/wix-headless/references/astro/templates/bookings/manage-booking.astro +0 -76
@@ -1,213 +1,369 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
- import { createClient, OAuthStrategy } from "@wix/sdk";
3
- import { availabilityTimeSlots, eventTimeSlots, bookings } from "@wix/bookings";
4
- // Browser client ID comes from astro:env/client (NOT import.meta.env, which is
5
- // undefined in the browser bundle → OAuthStrategy posts an empty client_id and
6
- // oauth2/token 400s).
7
- import { WIX_CLIENT_ID } from "astro:env/client";
8
-
9
- // AvailabilityCalendar.tsx — client:only="react" island. Date navigator + slot
10
- // grid. Branches on serviceType:
11
- // APPOINTMENT → availabilityTimeSlots.listAvailabilityTimeSlots() (1-on-1;
12
- // slots carry scheduleId + startDate).
13
- // CLASS → eventTimeSlots.listEventTimeSlots() (group sessions; slots
14
- // carry eventInfo.eventId + capacity; NO scheduleId).
15
- // Adapt styling/copy; keep the SDK wiring + slot normalization.
16
-
17
- export interface SelectedSlot {
18
- serviceType: "APPOINTMENT" | "CLASS";
19
- localStartDate: string;
20
- localEndDate: string;
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;
2
+ import { availabilityTimeSlots, eventTimeSlots } from "@wix/bookings";
3
+ import type { SelectedSlot } from "./bookingDriver";
4
+ import { STAFF_MEMBER_RESOURCE_TYPE_ID } from "./bookingDriver";
5
+
6
+ // AvailabilityCalendar.tsx — client:only="react" island. A week calendar: a
7
+ // 7-day strip with week navigation, then the picked day's time slots. This is
8
+ // the usable shape — a flat list of every slot with time-only labels leaves the
9
+ // visitor unable to tell which day a slot is on.
10
+ //
11
+ // SDK calls run ambiently (the @wix/astro visitor client), like the ecom
12
+ // CartView island — no createClient/OAuthStrategy here. On an own/own-build SPA
13
+ // acquire a visitor client (OAuthStrategy) and call the same functions on it.
14
+ //
15
+ // Branches on serviceType:
16
+ // APPOINTMENT → availabilityTimeSlots.listAvailabilityTimeSlots() (serviceId is
17
+ // a single GUID string; slots carry scheduleId).
18
+ // CLASS → eventTimeSlots.listEventTimeSlots() (serviceIds is an array;
19
+ // slots carry eventInfo.eventId and NO scheduleId).
20
+ //
21
+ // LOCATION SCOPING (multi-location): listAvailabilityTimeSlots returns one slot
22
+ // PER LOCATION per time — "if locations is not specified, returns time slots for
23
+ // all locations where the service is available". A service offered at >1 location
24
+ // therefore yields DUPLICATE same-time rows unless the call is scoped to a single
25
+ // location. So availability is ALWAYS scoped to one business location: the picker
26
+ // below defaults to the carried/first location and the call always passes a
27
+ // single-element `locations` filter. (One location per availability call — the
28
+ // same single-location guarantee the location-first SoT gets from its selector.)
29
+ //
30
+ // STAFF: a single slot carries multiple staff in availableResources, so staff is
31
+ // a per-slot dropdown — it never multiplies rows. Only location multiplies rows.
32
+ // Re-export SelectedSlot from bookingDriver so the form/driver share one shape.
33
+ export type { SelectedSlot } from "./bookingDriver";
34
+
35
+ // A staff member as carried on the service (service.staffMemberDetails.staffMembers[]),
36
+ // fetched via conditionalFields:["STAFF_MEMBER_DETAILS"]. NOTE: the id field is
37
+ // `staffMemberId` which — despite the name — IS the resource id (it matches the
38
+ // service's staffMemberIds and the Staff Members API resourceId). That id is what
39
+ // filters availability and books the resource.
40
+ export interface StaffMemberOption {
41
+ staffMemberId?: string;
42
+ name?: string;
43
+ }
44
+
45
+ // A business location the service is offered at (derived from service.locations[]).
46
+ export interface LocationOption {
47
+ _id: string;
48
+ name?: string;
35
49
  }
36
50
 
37
51
  interface Props {
38
52
  serviceId: string;
39
53
  serviceName: string;
40
54
  serviceType: "APPOINTMENT" | "CLASS";
55
+ staffMembers?: StaffMemberOption[];
56
+ locations?: LocationOption[]; // business locations the service offers
57
+ locationId?: string; // the location chosen on the catalog (the default scope), if any
41
58
  onSlotSelected: (slot: SelectedSlot) => void;
42
59
  }
43
60
 
44
- const wixClient = createClient({
45
- modules: { availabilityTimeSlots, eventTimeSlots, bookings },
46
- auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }),
47
- });
61
+ interface FetchOpts {
62
+ resourceId?: string; // a staff resource id to filter by (omit for "any staff")
63
+ locationId?: string; // a business location id to scope to (omit only when the service has none)
64
+ }
65
+
66
+ const DAY_MS = 86_400_000;
67
+ const HORIZON_WEEKS = 13; // how far "check next availability" probes forward
68
+ const ANY_STAFF = ""; // the "any staff member" sentinel for the select value
48
69
 
49
70
  const pad = (n: number) => String(n).padStart(2, "0");
50
- // Local date string YYYY-MM-DDThh:mm:ss (no Z) — API expects local business time.
71
+ // Local date string YYYY-MM-DDThh:mm:ss (no Z) — availability expects local time.
51
72
  const localDateString = (d: Date) =>
52
73
  `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
53
74
  const tz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
75
+ const dateKey = (iso: string) => iso.slice(0, 10); // group slots by calendar day
76
+ const timeLabel = (iso: string) =>
77
+ new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
78
+
79
+ const startOfDay = (d: Date) => { const n = new Date(d); n.setHours(0, 0, 0, 0); return n; };
80
+ // Monday-start week containing `d`.
81
+ const mondayOf = (d: Date) => {
82
+ const n = startOfDay(d);
83
+ const day = (n.getDay() + 6) % 7; // 0 = Monday
84
+ n.setDate(n.getDate() - day);
85
+ return n;
86
+ };
54
87
 
55
- const fetchSlots = async (
88
+ // Fetch one week of availability and group the slots by calendar day.
89
+ const fetchWeek = async (
56
90
  serviceId: string,
57
91
  serviceType: "APPOINTMENT" | "CLASS",
58
- date: Date,
59
- ): Promise<{ slots: SelectedSlot[]; timezone: string }> => {
60
- const start = new Date(date); start.setHours(0, 0, 0, 0);
61
- const end = new Date(date); end.setHours(23, 59, 59, 0);
92
+ weekStart: Date,
93
+ opts: FetchOpts = {},
94
+ ): Promise<Record<string, SelectedSlot[]>> => {
95
+ const from = startOfDay(weekStart);
96
+ const to = new Date(from.getTime() + 7 * DAY_MS);
62
97
  const timeZone = tz();
98
+ let raw: any[] = [];
63
99
 
64
100
  if (serviceType === "CLASS") {
65
- const result = await wixClient.eventTimeSlots.listEventTimeSlots({
66
- serviceIds: [serviceId], // plural for the CLASS API
67
- fromLocalDate: localDateString(start),
68
- toLocalDate: localDateString(end),
101
+ // CLASS filters via eventFilter: staff by resources.id ($hasSome), location by
102
+ // location.id (a bare array, NOT a $hasSome operator).
103
+ const eventFilter: Record<string, any> = {};
104
+ if (opts.resourceId) eventFilter["resources.id"] = { $hasSome: [opts.resourceId] };
105
+ if (opts.locationId) eventFilter["location.id"] = [opts.locationId];
106
+ const result = await eventTimeSlots.listEventTimeSlots({
107
+ serviceIds: [serviceId], // CLASS API takes an array
108
+ fromLocalDate: localDateString(from),
109
+ toLocalDate: localDateString(to),
69
110
  timeZone,
70
- includeNonBookable: true, // include full sessions → waitlist CTA
71
- cursorPaging: { limit: 50 },
111
+ includeNonBookable: false,
112
+ cursorPaging: { limit: 100 },
113
+ ...(Object.keys(eventFilter).length ? { eventFilter } : {}),
72
114
  });
73
- const z = result.timeZone ?? timeZone;
74
- const slots: SelectedSlot[] = (result.timeSlots ?? []).map((s: any) => ({
75
- serviceType: "CLASS",
115
+ raw = (result.timeSlots ?? []).map((s: any) => ({
116
+ serviceType: "CLASS" as const,
76
117
  serviceId,
77
118
  localStartDate: s.localStartDate,
78
119
  localEndDate: s.localEndDate,
79
- eventId: s.eventInfo?.eventId, // session id — NO scheduleId on event slots
80
- totalCapacity: s.totalCapacity ?? undefined,
81
- remainingCapacity: s.remainingCapacity ?? undefined,
82
- bookableCapacity: s.bookableCapacity ?? undefined,
83
- isFull: (s.bookableCapacity ?? s.remainingCapacity ?? 0) <= 0,
84
- timezone: z,
120
+ timezone: result.timeZone ?? timeZone,
121
+ eventId: s.eventInfo?.eventId, // session id — no scheduleId on event slots
122
+ }));
123
+ } else {
124
+ // APPOINTMENT filters staff via resourceTypes (resourceIds), location via locations[].
125
+ // Scoping to a single location collapses the per-location duplicate rows.
126
+ const result = await availabilityTimeSlots.listAvailabilityTimeSlots({
127
+ serviceId, // single GUID string — NOT an array
128
+ fromLocalDate: localDateString(from),
129
+ toLocalDate: localDateString(to),
130
+ timeZone,
131
+ bookable: true,
132
+ cursorPaging: { limit: 100 },
133
+ ...(opts.resourceId
134
+ ? {
135
+ resourceTypes: [
136
+ { resourceTypeId: STAFF_MEMBER_RESOURCE_TYPE_ID, resourceIds: [opts.resourceId] },
137
+ ],
138
+ includeResourceTypeIds: [STAFF_MEMBER_RESOURCE_TYPE_ID],
139
+ }
140
+ : {}),
141
+ ...(opts.locationId
142
+ ? { locations: [{ _id: opts.locationId, locationType: "BUSINESS" as const }] }
143
+ : {}),
144
+ });
145
+ raw = (result.timeSlots ?? []).map((s: any) => ({
146
+ serviceType: "APPOINTMENT" as const,
147
+ serviceId,
148
+ localStartDate: s.localStartDate,
149
+ localEndDate: s.localEndDate,
150
+ timezone: result.timeZone ?? timeZone,
151
+ scheduleId: s.scheduleId,
152
+ locationId: s.location?._id, // the id field is _id, not .id
153
+ locationType: s.location?.locationType,
85
154
  }));
86
- return { slots, timezone: z };
87
155
  }
88
156
 
89
- // APPOINTMENT
90
- const result = await wixClient.availabilityTimeSlots.listAvailabilityTimeSlots({
91
- serviceId, // a single GUID STRING — NOT an array (the array form is the CLASS `serviceIds` field)
92
- fromLocalDate: localDateString(start),
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;
157
+ const byDay: Record<string, SelectedSlot[]> = {};
158
+ for (const slot of raw) {
159
+ if (!slot.localStartDate) continue;
160
+ (byDay[dateKey(slot.localStartDate)] ||= []).push(slot);
125
161
  }
162
+ return byDay;
126
163
  };
127
164
 
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`;
140
- };
141
-
142
- export default function AvailabilityCalendar({ serviceId, serviceName, serviceType, onSlotSelected }: Props) {
143
- const [date, setDate] = useState<Date>(() => startOfToday());
144
- const [slots, setSlots] = useState<SelectedSlot[]>([]);
145
- const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
165
+ export default function AvailabilityCalendar({
166
+ serviceId,
167
+ serviceName,
168
+ serviceType,
169
+ staffMembers,
170
+ locations,
171
+ locationId,
172
+ onSlotSelected,
173
+ }: Props) {
174
+ const thisMonday = useMemo(() => mondayOf(new Date()), []);
175
+ const [weekStart, setWeekStart] = useState<Date>(thisMonday);
176
+ const [byDay, setByDay] = useState<Record<string, SelectedSlot[]>>({});
177
+ const [selectedDay, setSelectedDay] = useState<string | null>(null);
146
178
  const [selectedKey, setSelectedKey] = useState<string | null>(null);
147
- const today = useMemo(() => startOfToday(), []);
148
- const atToday = date.getTime() <= today.getTime();
179
+ const [status, setStatus] = useState<"loading" | "ready" | "error" | "searching">("loading");
180
+ // The chosen staff resource id ("" = any staff member → ANY_RESOURCE fallback).
181
+ const [selectedResource, setSelectedResource] = useState<string>(ANY_STAFF);
182
+
183
+ // Staff options keyed by resource id; picker shown only when >1 staff.
184
+ const staffOptions = useMemo(
185
+ () =>
186
+ (staffMembers ?? [])
187
+ .map((s) => ({ id: s.staffMemberId ?? "", name: s.name ?? "Staff member" }))
188
+ .filter((s) => s.id),
189
+ [staffMembers],
190
+ );
191
+ const showStaffPicker = staffOptions.length > 1;
192
+
193
+ // Business-location options. Availability is always scoped to ONE of these (the
194
+ // carried location if it's one of them, else the first), so multi-location
195
+ // services don't return duplicate per-location rows. Picker shown only when >1.
196
+ const locationOptions = useMemo(
197
+ () => (locations ?? []).filter((l) => l._id),
198
+ [locations],
199
+ );
200
+ const [selectedLocation, setSelectedLocation] = useState<string>(() => {
201
+ if (locationId && locationOptions.some((l) => l._id === locationId)) return locationId;
202
+ return locationOptions[0]?._id ?? "";
203
+ });
204
+ const showLocationPicker = locationOptions.length > 1;
205
+
206
+ const days = useMemo(
207
+ () => Array.from({ length: 7 }, (_, i) => new Date(weekStart.getTime() + i * DAY_MS)),
208
+ [weekStart],
209
+ );
210
+ const atFirstWeek = weekStart.getTime() <= thisMonday.getTime();
211
+
212
+ const fetchOpts = useCallback(
213
+ (): FetchOpts => ({
214
+ resourceId: selectedResource || undefined,
215
+ locationId: selectedLocation || undefined,
216
+ }),
217
+ [selectedResource, selectedLocation],
218
+ );
149
219
 
150
- const load = useCallback(async (target: Date) => {
220
+ const load = useCallback(async (start: Date) => {
151
221
  setStatus("loading"); setSelectedKey(null);
152
222
  try {
153
- const { slots: fetched } = await fetchSlots(serviceId, serviceType, target);
154
- setSlots(fetched); setStatus("ready");
223
+ const grouped = await fetchWeek(serviceId, serviceType, start, fetchOpts());
224
+ setByDay(grouped);
225
+ const firstDayWithSlots = Object.keys(grouped).sort()[0] ?? null;
226
+ setSelectedDay(firstDayWithSlots);
227
+ setStatus("ready");
155
228
  } catch (err) {
156
229
  console.error("[availability] list failed:", err);
157
230
  setStatus("error");
158
231
  }
159
- }, [serviceId, serviceType]);
232
+ }, [serviceId, serviceType, fetchOpts]);
160
233
 
161
- useEffect(() => { void load(date); }, [date, load]);
234
+ // Reload whenever the week OR the staff/location filter changes.
235
+ useEffect(() => { void load(weekStart); }, [weekStart, load]);
162
236
 
163
- const shiftDay = (delta: number) =>
164
- setDate((prev) => { const n = new Date(prev); n.setDate(n.getDate() + delta); n.setHours(0, 0, 0, 0); return n; });
237
+ const shiftWeek = (deltaWeeks: number) =>
238
+ setWeekStart((prev) => startOfDay(new Date(prev.getTime() + deltaWeeks * 7 * DAY_MS)));
165
239
 
166
- const keyOf = (s: SelectedSlot) => `${s.localStartDate}|${s.eventId ?? s.scheduleId ?? ""}`;
240
+ // Probe forward week-by-week for the next week that has any slots.
241
+ const checkNextAvailability = useCallback(async () => {
242
+ setStatus("searching");
243
+ let cursor = new Date(weekStart.getTime() + 7 * DAY_MS);
244
+ for (let i = 0; i < HORIZON_WEEKS; i++) {
245
+ try {
246
+ const grouped = await fetchWeek(serviceId, serviceType, cursor, fetchOpts());
247
+ if (Object.keys(grouped).length > 0) {
248
+ setWeekStart(cursor); // triggers load() via the effect
249
+ return;
250
+ }
251
+ } catch { /* keep probing */ }
252
+ cursor = new Date(cursor.getTime() + 7 * DAY_MS);
253
+ }
254
+ setStatus("ready"); // nothing found within the horizon
255
+ }, [serviceId, serviceType, weekStart, fetchOpts]);
167
256
 
168
- const handleSelect = async (s: SelectedSlot) => {
169
- // Guard on the type-appropriate id — CLASS slots have no scheduleId.
257
+ const handleSelect = (s: SelectedSlot) => {
170
258
  const id = s.serviceType === "CLASS" ? s.eventId : s.scheduleId;
171
259
  if (!s.localStartDate || !id) return;
172
- setSelectedKey(keyOf(s));
173
- const instructorName = s.serviceType === "CLASS" && s.eventId ? await fetchInstructor(s.eventId) : undefined;
174
- onSlotSelected({ ...s, instructorName });
260
+ setSelectedKey(`${s.localStartDate}|${id}`);
261
+ // When a specific staff was chosen, record it on the slot so the booking books
262
+ // that resource; otherwise the driver emits the ANY_RESOURCE fallback.
263
+ const chosen = selectedResource
264
+ ? staffOptions.find((o) => o.id === selectedResource)
265
+ : null;
266
+ onSlotSelected(chosen ? { ...s, resource: { _id: chosen.id, name: chosen.name } } : s);
175
267
  };
176
268
 
269
+ const weekLabel = `${days[0].toLocaleDateString([], { month: "short", day: "numeric" })} – ${days[6].toLocaleDateString([], { month: "short", day: "numeric" })}`;
270
+ const daySlots = selectedDay ? byDay[selectedDay] ?? [] : [];
271
+ const weekIsEmpty = status === "ready" && Object.keys(byDay).length === 0;
272
+
177
273
  return (
178
274
  <div className="availability-calendar">
179
- <div className="availability-date-nav">
180
- <button type="button" className="availability-nav-btn" onClick={() => shiftDay(-1)} disabled={atToday} aria-label="Previous day">← Prev</button>
181
- <span className="availability-date-label">{dayLabel(date)}</span>
182
- <button type="button" className="availability-nav-btn" onClick={() => shiftDay(1)} aria-label="Next day">Next →</button>
275
+ {(showLocationPicker || showStaffPicker) && (
276
+ <div className="availability-filters">
277
+ {showLocationPicker && (
278
+ <div className="availability-staff">
279
+ <label className="availability-staff-label" htmlFor="location-select">Location</label>
280
+ <select
281
+ id="location-select"
282
+ className="availability-staff-select"
283
+ value={selectedLocation}
284
+ onChange={(e) => { setSelectedLocation(e.target.value); setSelectedKey(null); }}
285
+ >
286
+ {locationOptions.map((o) => (
287
+ <option key={o._id} value={o._id}>{o.name ?? "Location"}</option>
288
+ ))}
289
+ </select>
290
+ </div>
291
+ )}
292
+ {showStaffPicker && (
293
+ <div className="availability-staff">
294
+ <label className="availability-staff-label" htmlFor="staff-select">Staff member</label>
295
+ <select
296
+ id="staff-select"
297
+ className="availability-staff-select"
298
+ value={selectedResource}
299
+ onChange={(e) => { setSelectedResource(e.target.value); setSelectedKey(null); }}
300
+ >
301
+ <option value={ANY_STAFF}>Any staff member</option>
302
+ {staffOptions.map((o) => (
303
+ <option key={o.id} value={o.id}>{o.name}</option>
304
+ ))}
305
+ </select>
306
+ </div>
307
+ )}
308
+ </div>
309
+ )}
310
+
311
+ <div className="availability-week-nav">
312
+ <button type="button" className="availability-nav-btn" onClick={() => shiftWeek(-1)} disabled={atFirstWeek} aria-label="Previous week">← Prev week</button>
313
+ <span className="availability-week-label">{weekLabel}</span>
314
+ <button type="button" className="availability-nav-btn" onClick={() => shiftWeek(1)} aria-label="Next week">Next week →</button>
315
+ </div>
316
+
317
+ <div className="availability-day-strip" role="group" aria-label="Pick a day">
318
+ {days.map((d) => {
319
+ const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
320
+ const has = (byDay[key]?.length ?? 0) > 0;
321
+ const isSelected = key === selectedDay;
322
+ return (
323
+ <button
324
+ key={key}
325
+ type="button"
326
+ className={["availability-day", isSelected ? "availability-day--selected" : "", has ? "availability-day--has-slots" : "availability-day--empty"].filter(Boolean).join(" ")}
327
+ disabled={!has}
328
+ aria-pressed={isSelected}
329
+ onClick={() => { setSelectedDay(key); setSelectedKey(null); }}
330
+ >
331
+ <span className="availability-day-name">{d.toLocaleDateString([], { weekday: "short" })}</span>
332
+ <span className="availability-day-num">{d.getDate()}</span>
333
+ </button>
334
+ );
335
+ })}
183
336
  </div>
184
337
 
185
338
  {status === "loading" && <p className="availability-loading">Checking availability…</p>}
339
+ {status === "searching" && <p className="availability-loading">Searching for the next available time…</p>}
186
340
  {status === "error" && (
187
341
  <>
188
342
  <p className="availability-error">Could not load availability — please try again.</p>
189
- <button type="button" className="availability-nav-btn" onClick={() => void load(date)}>Retry</button>
343
+ <button type="button" className="availability-nav-btn" onClick={() => void load(weekStart)}>Retry</button>
190
344
  </>
191
345
  )}
192
- {status === "ready" && slots.length === 0 && (
193
- <p className="availability-empty">No availability on this date — try another day.</p>
346
+ {weekIsEmpty && (
347
+ <div className="availability-empty">
348
+ <p>No availability this week.</p>
349
+ <button type="button" className="availability-nav-btn" onClick={() => void checkNextAvailability()}>Check next availability</button>
350
+ </div>
194
351
  )}
195
- {status === "ready" && slots.length > 0 && (
352
+ {status === "ready" && daySlots.length > 0 && (
196
353
  <div className="availability-slots" role="group" aria-label={`Available times for ${serviceName}`}>
197
- {slots.map((s) => {
198
- const key = keyOf(s);
354
+ {daySlots.map((s) => {
355
+ const id = s.serviceType === "CLASS" ? s.eventId : s.scheduleId;
356
+ const key = `${s.localStartDate}|${id}`;
199
357
  const isSelected = key === selectedKey;
200
- const cap = capacityLabel(s);
201
358
  return (
202
359
  <button
203
360
  key={key}
204
361
  type="button"
205
- className={["time-slot", isSelected ? "time-slot--selected" : "time-slot--available", s.isFull ? "time-slot--full" : ""].filter(Boolean).join(" ")}
362
+ className={["time-slot", isSelected ? "time-slot--selected" : "time-slot--available"].join(" ")}
206
363
  aria-pressed={isSelected}
207
- onClick={() => void handleSelect(s)}
364
+ onClick={() => handleSelect(s)}
208
365
  >
209
366
  <span className="time-slot-time">{timeLabel(s.localStartDate)}</span>
210
- {cap && <span className="time-slot-capacity">{cap}</span>}
211
367
  </button>
212
368
  );
213
369
  })}