@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.
- 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/DESIGN_SYSTEM.md +6 -16
- 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 +77 -428
- package/skills/wix-headless/references/astro/cms/FAQ_KNOWLEDGE_BASE.md +2 -2
- package/skills/wix-headless/references/astro/designer/INSTRUCTIONS.md +7 -10
- package/skills/wix-headless/references/astro/ecom/CART_WIRING.md +2 -2
- package/skills/wix-headless/references/astro/ecom/COMPONENTS_CSS.md +3 -3
- package/skills/wix-headless/references/astro/gift-cards/COMPONENTS.md +2 -2
- package/skills/wix-headless/references/astro/stores/BACK_IN_STOCK.md +7 -7
- package/skills/wix-headless/references/astro/stores/COMPONENTS_CSS.md +4 -4
- package/skills/wix-headless/references/astro/stores/PRODUCT_PAGES.md +2 -2
- package/skills/wix-headless/references/astro/templates/Footer.astro +4 -4
- package/skills/wix-headless/references/astro/templates/Navigation.astro +4 -4
- package/skills/wix-headless/references/astro/templates/bookings/AvailabilityCalendar.tsx +298 -142
- package/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx +104 -162
- package/skills/wix-headless/references/astro/templates/bookings/ServiceBookingFlow.tsx +35 -14
- package/skills/wix-headless/references/astro/templates/bookings/ServiceCard.astro +11 -2
- package/skills/wix-headless/references/astro/templates/bookings/booking-confirmation.astro +23 -85
- package/skills/wix-headless/references/astro/templates/bookings/bookingDriver.ts +245 -0
- package/skills/wix-headless/references/astro/templates/bookings/components-bookings.css +209 -198
- package/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro +114 -19
- package/skills/wix-headless/references/astro/templates/bookings/services/index.astro +131 -15
- package/skills/wix-headless/references/astro/templates/ecom/CartView.tsx +3 -3
- package/skills/wix-headless/references/astro/templates/ecom/cart.astro +3 -3
- package/skills/wix-headless/references/astro/templates/ecom/components-ecom.css +17 -17
- package/skills/wix-headless/references/astro/templates/ecom/thank-you.astro +3 -3
- package/skills/wix-headless/references/astro/templates/gift-cards/_home-teaser-snippet.astro +4 -4
- package/skills/wix-headless/references/astro/templates/gift-cards/components-gift-cards.css +19 -18
- package/skills/wix-headless/references/astro/templates/gift-cards/gift-cards.astro +3 -3
- package/skills/wix-headless/references/astro/templates/global.css +17 -15
- package/skills/wix-headless/references/astro/templates/index.astro +4 -4
- package/skills/wix-headless/references/astro/templates/stores/components-stores.css +31 -31
- package/skills/wix-headless/references/astro/templates/stores/products/[slug].astro +4 -4
- package/skills/wix-headless/references/bookings/FLOW.md +246 -0
- package/skills/wix-headless/references/bookings/INSTRUCTIONS.md +65 -76
- package/skills/wix-headless/references/bookings/SERVICES_DATA.md +60 -0
- package/skills/wix-headless/references/custom/bookings/WIRING.md +169 -60
- package/skills/wix-headless/references/shared/DESIGN_MD.md +2 -3
- package/skills/wix-headless/references/shared/IMPLEMENTER.md +4 -4
- package/skills/wix-headless/references/shared/STYLING.md +15 -18
- package/skills/wix-headless/references/stores/INSTRUCTIONS.md +2 -2
- package/skills/wix-headless/references/verticals/bookings.md +9 -9
- package/skills/wix-headless/scripts/compose.mjs +4 -19
- package/skills/wix-headless/scripts/emit-design-tokens.mjs +0 -3
- 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 +254 -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 +233 -0
- package/skills/wix-manage/references/bookings/create-course-service.md +237 -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,213 +1,369 @@
|
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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) —
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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:
|
|
71
|
-
cursorPaging: { limit:
|
|
111
|
+
includeNonBookable: false,
|
|
112
|
+
cursorPaging: { limit: 100 },
|
|
113
|
+
...(Object.keys(eventFilter).length ? { eventFilter } : {}),
|
|
72
114
|
});
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
148
|
-
|
|
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 (
|
|
220
|
+
const load = useCallback(async (start: Date) => {
|
|
151
221
|
setStatus("loading"); setSelectedKey(null);
|
|
152
222
|
try {
|
|
153
|
-
const
|
|
154
|
-
|
|
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
|
-
|
|
234
|
+
// Reload whenever the week OR the staff/location filter changes.
|
|
235
|
+
useEffect(() => { void load(weekStart); }, [weekStart, load]);
|
|
162
236
|
|
|
163
|
-
const
|
|
164
|
-
|
|
237
|
+
const shiftWeek = (deltaWeeks: number) =>
|
|
238
|
+
setWeekStart((prev) => startOfDay(new Date(prev.getTime() + deltaWeeks * 7 * DAY_MS)));
|
|
165
239
|
|
|
166
|
-
|
|
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 =
|
|
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(
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
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(
|
|
343
|
+
<button type="button" className="availability-nav-btn" onClick={() => void load(weekStart)}>Retry</button>
|
|
190
344
|
</>
|
|
191
345
|
)}
|
|
192
|
-
{
|
|
193
|
-
<
|
|
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" &&
|
|
352
|
+
{status === "ready" && daySlots.length > 0 && (
|
|
196
353
|
<div className="availability-slots" role="group" aria-label={`Available times for ${serviceName}`}>
|
|
197
|
-
{
|
|
198
|
-
const
|
|
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"
|
|
362
|
+
className={["time-slot", isSelected ? "time-slot--selected" : "time-slot--available"].join(" ")}
|
|
206
363
|
aria-pressed={isSelected}
|
|
207
|
-
onClick={() =>
|
|
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
|
})}
|