@wix/agent-skills 1.9.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-headless/references/DESIGN_SYSTEM.md +6 -16
- package/skills/wix-headless/references/astro/bookings/COMPONENTS.md +1 -1
- package/skills/wix-headless/references/astro/bookings/SERVICES_PAGES.md +19 -0
- package/skills/wix-headless/references/astro/cms/FAQ_KNOWLEDGE_BASE.md +2 -2
- package/skills/wix-headless/references/astro/designer/INSTRUCTIONS.md +3 -3
- 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 +147 -6
- package/skills/wix-headless/references/astro/templates/bookings/ServiceBookingFlow.tsx +21 -4
- package/skills/wix-headless/references/astro/templates/bookings/ServiceCard.astro +11 -2
- package/skills/wix-headless/references/astro/templates/bookings/booking-confirmation.astro +8 -8
- package/skills/wix-headless/references/astro/templates/bookings/components-bookings.css +94 -26
- package/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro +53 -10
- package/skills/wix-headless/references/astro/templates/bookings/services/index.astro +128 -20
- 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 +93 -32
- package/skills/wix-headless/references/bookings/INSTRUCTIONS.md +8 -3
- package/skills/wix-headless/references/bookings/SERVICES_DATA.md +60 -0
- package/skills/wix-headless/references/custom/bookings/WIRING.md +60 -9
- 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 +5 -3
- package/skills/wix-headless/scripts/compose.mjs +4 -19
- package/skills/wix-headless/scripts/emit-design-tokens.mjs +0 -3
- package/skills/wix-manage/references/bookings/create-appointment-service.md +1 -13
- package/skills/wix-manage/references/bookings/create-class-service.md +1 -13
- package/skills/wix-manage/references/bookings/create-course-service.md +1 -13
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { availabilityTimeSlots, eventTimeSlots } from "@wix/bookings";
|
|
3
3
|
import type { SelectedSlot } from "./bookingDriver";
|
|
4
|
+
import { STAFF_MEMBER_RESOURCE_TYPE_ID } from "./bookingDriver";
|
|
4
5
|
|
|
5
6
|
// AvailabilityCalendar.tsx — client:only="react" island. A week calendar: a
|
|
6
7
|
// 7-day strip with week navigation, then the picked day's time slots. This is
|
|
@@ -16,18 +17,55 @@ import type { SelectedSlot } from "./bookingDriver";
|
|
|
16
17
|
// a single GUID string; slots carry scheduleId).
|
|
17
18
|
// CLASS → eventTimeSlots.listEventTimeSlots() (serviceIds is an array;
|
|
18
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.
|
|
19
32
|
// Re-export SelectedSlot from bookingDriver so the form/driver share one shape.
|
|
20
33
|
export type { SelectedSlot } from "./bookingDriver";
|
|
21
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;
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
interface Props {
|
|
23
52
|
serviceId: string;
|
|
24
53
|
serviceName: string;
|
|
25
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
|
|
26
58
|
onSlotSelected: (slot: SelectedSlot) => void;
|
|
27
59
|
}
|
|
28
60
|
|
|
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
|
+
|
|
29
66
|
const DAY_MS = 86_400_000;
|
|
30
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
|
|
31
69
|
|
|
32
70
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
33
71
|
// Local date string YYYY-MM-DDThh:mm:ss (no Z) — availability expects local time.
|
|
@@ -52,6 +90,7 @@ const fetchWeek = async (
|
|
|
52
90
|
serviceId: string,
|
|
53
91
|
serviceType: "APPOINTMENT" | "CLASS",
|
|
54
92
|
weekStart: Date,
|
|
93
|
+
opts: FetchOpts = {},
|
|
55
94
|
): Promise<Record<string, SelectedSlot[]>> => {
|
|
56
95
|
const from = startOfDay(weekStart);
|
|
57
96
|
const to = new Date(from.getTime() + 7 * DAY_MS);
|
|
@@ -59,6 +98,11 @@ const fetchWeek = async (
|
|
|
59
98
|
let raw: any[] = [];
|
|
60
99
|
|
|
61
100
|
if (serviceType === "CLASS") {
|
|
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];
|
|
62
106
|
const result = await eventTimeSlots.listEventTimeSlots({
|
|
63
107
|
serviceIds: [serviceId], // CLASS API takes an array
|
|
64
108
|
fromLocalDate: localDateString(from),
|
|
@@ -66,6 +110,7 @@ const fetchWeek = async (
|
|
|
66
110
|
timeZone,
|
|
67
111
|
includeNonBookable: false,
|
|
68
112
|
cursorPaging: { limit: 100 },
|
|
113
|
+
...(Object.keys(eventFilter).length ? { eventFilter } : {}),
|
|
69
114
|
});
|
|
70
115
|
raw = (result.timeSlots ?? []).map((s: any) => ({
|
|
71
116
|
serviceType: "CLASS" as const,
|
|
@@ -76,6 +121,8 @@ const fetchWeek = async (
|
|
|
76
121
|
eventId: s.eventInfo?.eventId, // session id — no scheduleId on event slots
|
|
77
122
|
}));
|
|
78
123
|
} else {
|
|
124
|
+
// APPOINTMENT filters staff via resourceTypes (resourceIds), location via locations[].
|
|
125
|
+
// Scoping to a single location collapses the per-location duplicate rows.
|
|
79
126
|
const result = await availabilityTimeSlots.listAvailabilityTimeSlots({
|
|
80
127
|
serviceId, // single GUID string — NOT an array
|
|
81
128
|
fromLocalDate: localDateString(from),
|
|
@@ -83,6 +130,17 @@ const fetchWeek = async (
|
|
|
83
130
|
timeZone,
|
|
84
131
|
bookable: true,
|
|
85
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
|
+
: {}),
|
|
86
144
|
});
|
|
87
145
|
raw = (result.timeSlots ?? []).map((s: any) => ({
|
|
88
146
|
serviceType: "APPOINTMENT" as const,
|
|
@@ -104,13 +162,46 @@ const fetchWeek = async (
|
|
|
104
162
|
return byDay;
|
|
105
163
|
};
|
|
106
164
|
|
|
107
|
-
export default function AvailabilityCalendar({
|
|
165
|
+
export default function AvailabilityCalendar({
|
|
166
|
+
serviceId,
|
|
167
|
+
serviceName,
|
|
168
|
+
serviceType,
|
|
169
|
+
staffMembers,
|
|
170
|
+
locations,
|
|
171
|
+
locationId,
|
|
172
|
+
onSlotSelected,
|
|
173
|
+
}: Props) {
|
|
108
174
|
const thisMonday = useMemo(() => mondayOf(new Date()), []);
|
|
109
175
|
const [weekStart, setWeekStart] = useState<Date>(thisMonday);
|
|
110
176
|
const [byDay, setByDay] = useState<Record<string, SelectedSlot[]>>({});
|
|
111
177
|
const [selectedDay, setSelectedDay] = useState<string | null>(null);
|
|
112
178
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
|
113
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;
|
|
114
205
|
|
|
115
206
|
const days = useMemo(
|
|
116
207
|
() => Array.from({ length: 7 }, (_, i) => new Date(weekStart.getTime() + i * DAY_MS)),
|
|
@@ -118,10 +209,18 @@ export default function AvailabilityCalendar({ serviceId, serviceName, serviceTy
|
|
|
118
209
|
);
|
|
119
210
|
const atFirstWeek = weekStart.getTime() <= thisMonday.getTime();
|
|
120
211
|
|
|
212
|
+
const fetchOpts = useCallback(
|
|
213
|
+
(): FetchOpts => ({
|
|
214
|
+
resourceId: selectedResource || undefined,
|
|
215
|
+
locationId: selectedLocation || undefined,
|
|
216
|
+
}),
|
|
217
|
+
[selectedResource, selectedLocation],
|
|
218
|
+
);
|
|
219
|
+
|
|
121
220
|
const load = useCallback(async (start: Date) => {
|
|
122
221
|
setStatus("loading"); setSelectedKey(null);
|
|
123
222
|
try {
|
|
124
|
-
const grouped = await fetchWeek(serviceId, serviceType, start);
|
|
223
|
+
const grouped = await fetchWeek(serviceId, serviceType, start, fetchOpts());
|
|
125
224
|
setByDay(grouped);
|
|
126
225
|
const firstDayWithSlots = Object.keys(grouped).sort()[0] ?? null;
|
|
127
226
|
setSelectedDay(firstDayWithSlots);
|
|
@@ -130,8 +229,9 @@ export default function AvailabilityCalendar({ serviceId, serviceName, serviceTy
|
|
|
130
229
|
console.error("[availability] list failed:", err);
|
|
131
230
|
setStatus("error");
|
|
132
231
|
}
|
|
133
|
-
}, [serviceId, serviceType]);
|
|
232
|
+
}, [serviceId, serviceType, fetchOpts]);
|
|
134
233
|
|
|
234
|
+
// Reload whenever the week OR the staff/location filter changes.
|
|
135
235
|
useEffect(() => { void load(weekStart); }, [weekStart, load]);
|
|
136
236
|
|
|
137
237
|
const shiftWeek = (deltaWeeks: number) =>
|
|
@@ -143,7 +243,7 @@ export default function AvailabilityCalendar({ serviceId, serviceName, serviceTy
|
|
|
143
243
|
let cursor = new Date(weekStart.getTime() + 7 * DAY_MS);
|
|
144
244
|
for (let i = 0; i < HORIZON_WEEKS; i++) {
|
|
145
245
|
try {
|
|
146
|
-
const grouped = await fetchWeek(serviceId, serviceType, cursor);
|
|
246
|
+
const grouped = await fetchWeek(serviceId, serviceType, cursor, fetchOpts());
|
|
147
247
|
if (Object.keys(grouped).length > 0) {
|
|
148
248
|
setWeekStart(cursor); // triggers load() via the effect
|
|
149
249
|
return;
|
|
@@ -152,13 +252,18 @@ export default function AvailabilityCalendar({ serviceId, serviceName, serviceTy
|
|
|
152
252
|
cursor = new Date(cursor.getTime() + 7 * DAY_MS);
|
|
153
253
|
}
|
|
154
254
|
setStatus("ready"); // nothing found within the horizon
|
|
155
|
-
}, [serviceId, serviceType, weekStart]);
|
|
255
|
+
}, [serviceId, serviceType, weekStart, fetchOpts]);
|
|
156
256
|
|
|
157
257
|
const handleSelect = (s: SelectedSlot) => {
|
|
158
258
|
const id = s.serviceType === "CLASS" ? s.eventId : s.scheduleId;
|
|
159
259
|
if (!s.localStartDate || !id) return;
|
|
160
260
|
setSelectedKey(`${s.localStartDate}|${id}`);
|
|
161
|
-
|
|
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);
|
|
162
267
|
};
|
|
163
268
|
|
|
164
269
|
const weekLabel = `${days[0].toLocaleDateString([], { month: "short", day: "numeric" })} – ${days[6].toLocaleDateString([], { month: "short", day: "numeric" })}`;
|
|
@@ -167,6 +272,42 @@ export default function AvailabilityCalendar({ serviceId, serviceName, serviceTy
|
|
|
167
272
|
|
|
168
273
|
return (
|
|
169
274
|
<div className="availability-calendar">
|
|
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
|
+
|
|
170
311
|
<div className="availability-week-nav">
|
|
171
312
|
<button type="button" className="availability-nav-btn" onClick={() => shiftWeek(-1)} disabled={atFirstWeek} aria-label="Previous week">← Prev week</button>
|
|
172
313
|
<span className="availability-week-label">{weekLabel}</span>
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import AvailabilityCalendar from "./AvailabilityCalendar";
|
|
2
|
+
import AvailabilityCalendar, { type StaffMemberOption, type LocationOption } from "./AvailabilityCalendar";
|
|
3
3
|
import BookingForm, { type BookingFormField } from "./BookingForm";
|
|
4
4
|
import type { SelectedSlot } from "./bookingDriver";
|
|
5
5
|
|
|
6
6
|
// ServiceBookingFlow.tsx — client:only="react" coordinator island. Holds the
|
|
7
7
|
// selected-slot state shared between the calendar and the form, transitions
|
|
8
8
|
// between them, and redirects to the confirmation page on success. The SSR
|
|
9
|
-
// detail page passes the full `service` (the driver reads its payment/policy)
|
|
10
|
-
//
|
|
9
|
+
// detail page passes the full `service` (the driver reads its payment/policy),
|
|
10
|
+
// the booking-form `fields` (the @wix/forms schema), the service's `staffMembers`
|
|
11
|
+
// (for the optional staff picker), the service's business `locations` + the
|
|
12
|
+
// catalog-chosen `locationId` (so the calendar scopes availability to one
|
|
13
|
+
// location — see AvailabilityCalendar) through to the calendar.
|
|
11
14
|
//
|
|
12
15
|
// This is the framework-agnostic flow shape (a step coordinator + shared
|
|
13
16
|
// selection state). On another framework, the same two steps + shared state can
|
|
@@ -18,9 +21,20 @@ interface Props {
|
|
|
18
21
|
serviceName: string;
|
|
19
22
|
serviceType: "APPOINTMENT" | "CLASS";
|
|
20
23
|
fields: BookingFormField[];
|
|
24
|
+
staffMembers?: StaffMemberOption[];
|
|
25
|
+
locations?: LocationOption[];
|
|
26
|
+
locationId?: string;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
export default function ServiceBookingFlow({
|
|
29
|
+
export default function ServiceBookingFlow({
|
|
30
|
+
service,
|
|
31
|
+
serviceName,
|
|
32
|
+
serviceType,
|
|
33
|
+
fields,
|
|
34
|
+
staffMembers,
|
|
35
|
+
locations,
|
|
36
|
+
locationId,
|
|
37
|
+
}: Props) {
|
|
24
38
|
const [selectedSlot, setSelectedSlot] = useState<SelectedSlot | null>(null);
|
|
25
39
|
|
|
26
40
|
const handleSuccess = (_orderId: string, startDate?: string) => {
|
|
@@ -36,6 +50,9 @@ export default function ServiceBookingFlow({ service, serviceName, serviceType,
|
|
|
36
50
|
serviceId={service._id}
|
|
37
51
|
serviceName={serviceName}
|
|
38
52
|
serviceType={serviceType}
|
|
53
|
+
staffMembers={staffMembers}
|
|
54
|
+
locations={locations}
|
|
55
|
+
locationId={locationId}
|
|
39
56
|
onSlotSelected={setSelectedSlot}
|
|
40
57
|
/>
|
|
41
58
|
);
|
|
@@ -14,12 +14,21 @@ interface Props {
|
|
|
14
14
|
currency?: string;
|
|
15
15
|
imageUrl?: string;
|
|
16
16
|
type: "APPOINTMENT" | "CLASS";
|
|
17
|
+
locationId?: string; // when a location is selected on the catalog, carry it to the detail page
|
|
18
|
+
locationType?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
const { slug, name, tagLine, durationMinutes, price, imageUrl } = Astro.props;
|
|
21
|
+
const { slug, name, tagLine, durationMinutes, price, imageUrl, locationId, locationType } = Astro.props;
|
|
22
|
+
|
|
23
|
+
// Carry the selected location into the detail page so availability is scoped to it.
|
|
24
|
+
const q = new URLSearchParams();
|
|
25
|
+
if (locationId) q.set("locationId", locationId);
|
|
26
|
+
if (locationType) q.set("locationType", locationType);
|
|
27
|
+
const qs = q.toString();
|
|
28
|
+
const href = qs ? `/services/${slug}?${qs}` : `/services/${slug}`;
|
|
20
29
|
---
|
|
21
30
|
|
|
22
|
-
<a href={
|
|
31
|
+
<a href={href} class="service-card">
|
|
23
32
|
{
|
|
24
33
|
imageUrl ? (
|
|
25
34
|
<div class="service-card-image">
|
|
@@ -19,29 +19,29 @@ const formattedDate = startDateParam
|
|
|
19
19
|
<!-- `max-w-prose` (built-in literal = 65ch), not a bare max-w-<size> that could
|
|
20
20
|
collapse to the spacing scale. -->
|
|
21
21
|
<Layout title="Booking confirmed">
|
|
22
|
-
<section class="max-w-prose mx-auto px-
|
|
23
|
-
<div class="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-
|
|
22
|
+
<section class="max-w-prose mx-auto px-6 py-24 text-center">
|
|
23
|
+
<div class="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-8"
|
|
24
24
|
style="background-color: var(--color-accent);" aria-hidden="true">
|
|
25
25
|
<span class="text-2xl" style="color: var(--color-paper);">✓</span>
|
|
26
26
|
</div>
|
|
27
|
-
<hr class="editorial-rule w-16 mx-auto mb-
|
|
28
|
-
<h1 class="font-display text-4xl md:text-5xl leading-tight mb-
|
|
27
|
+
<hr class="editorial-rule w-16 mx-auto mb-6" />
|
|
28
|
+
<h1 class="font-display text-4xl md:text-5xl leading-tight mb-4">Thank you for booking</h1>
|
|
29
29
|
|
|
30
30
|
{
|
|
31
31
|
formattedDate ? (
|
|
32
|
-
<p class="text-lg text-mute leading-relaxed mb-
|
|
32
|
+
<p class="text-lg text-mute leading-relaxed mb-4">
|
|
33
33
|
Your spot in <strong class="text-ink">{serviceName}</strong> is <strong class="text-ink">confirmed</strong> for{" "}
|
|
34
34
|
<strong class="text-ink">{formattedDate}</strong>. We've sent the details to your email.
|
|
35
35
|
</p>
|
|
36
36
|
) : (
|
|
37
|
-
<p class="text-lg text-mute leading-relaxed mb-
|
|
37
|
+
<p class="text-lg text-mute leading-relaxed mb-4">
|
|
38
38
|
Your booking for <strong class="text-ink">{serviceName}</strong> is <strong class="text-ink">confirmed</strong>. We've sent the details to your email.
|
|
39
39
|
</p>
|
|
40
40
|
)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
<div class="flex flex-col gap-
|
|
44
|
-
<a href="/services" class="inline-block font-display text-base px-
|
|
43
|
+
<div class="flex flex-col gap-4 items-center">
|
|
44
|
+
<a href="/services" class="inline-block font-display text-base px-8 py-4 rounded-sm"
|
|
45
45
|
style="background-color: var(--color-accent); color: var(--color-paper);">Browse more services</a>
|
|
46
46
|
<a href="/" class="text-sm text-mute hover:text-ink">Return home</a>
|
|
47
47
|
</div>
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Class groups:
|
|
9
9
|
* - .service-card / .service-grid / .service-card-* → ServiceCard.astro, services/index.astro
|
|
10
|
-
* - .
|
|
10
|
+
* - .booking-filter-bar / .booking-filter-chip* → services/index.astro (location + category filters)
|
|
11
|
+
* - .availability-* / .time-slot* → AvailabilityCalendar.tsx (week calendar, slots, staff picker)
|
|
11
12
|
* - .booking-* → BookingForm.tsx (schema-driven form)
|
|
12
13
|
*
|
|
13
14
|
* Primary actions (a selected slot, the confirm button) use var(--color-accent)
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
.service-grid {
|
|
22
23
|
display: grid;
|
|
23
24
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
24
|
-
gap:
|
|
25
|
+
gap: 2rem;
|
|
25
26
|
transition: opacity 200ms ease;
|
|
26
27
|
}
|
|
27
28
|
body[data-navigating="true"] .service-grid {
|
|
@@ -63,8 +64,8 @@ body[data-navigating="true"] .service-grid {
|
|
|
63
64
|
.service-card-meta {
|
|
64
65
|
display: flex;
|
|
65
66
|
flex-direction: column;
|
|
66
|
-
gap:
|
|
67
|
-
padding:
|
|
67
|
+
gap: 0.5rem;
|
|
68
|
+
padding: 1rem 1rem 1.5rem;
|
|
68
69
|
flex: 1;
|
|
69
70
|
}
|
|
70
71
|
.service-card-name {
|
|
@@ -84,32 +85,99 @@ body[data-navigating="true"] .service-grid {
|
|
|
84
85
|
.service-card-price {
|
|
85
86
|
display: flex;
|
|
86
87
|
align-items: center;
|
|
87
|
-
gap:
|
|
88
|
+
gap: 0.75rem;
|
|
88
89
|
margin-top: auto;
|
|
89
|
-
padding-top:
|
|
90
|
+
padding-top: 0.75rem;
|
|
90
91
|
font-family: var(--font-body);
|
|
91
92
|
font-size: 0.875rem;
|
|
92
93
|
color: var(--color-mute);
|
|
93
94
|
}
|
|
94
95
|
.service-card-price span + span::before {
|
|
95
96
|
content: "·";
|
|
96
|
-
padding-right:
|
|
97
|
+
padding-right: 0.75rem;
|
|
97
98
|
color: var(--color-rule);
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/* ── Catalog filters (location + category) ──────────────────────────────────── */
|
|
102
|
+
.booking-filter-bar {
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-wrap: wrap;
|
|
105
|
+
gap: 0.75rem;
|
|
106
|
+
margin-bottom: 2rem;
|
|
107
|
+
}
|
|
108
|
+
.booking-filter-chip {
|
|
109
|
+
padding: 0.5rem 1rem;
|
|
110
|
+
border: 1px solid var(--color-rule);
|
|
111
|
+
border-radius: var(--radius-pill, 999px);
|
|
112
|
+
background: var(--color-paper, var(--color-cream));
|
|
113
|
+
color: var(--color-ink);
|
|
114
|
+
font-family: var(--font-body);
|
|
115
|
+
font-size: 0.875rem;
|
|
116
|
+
text-decoration: none;
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease;
|
|
119
|
+
}
|
|
120
|
+
.booking-filter-chip:hover {
|
|
121
|
+
border-color: var(--color-ink);
|
|
122
|
+
}
|
|
123
|
+
.booking-filter-chip--active {
|
|
124
|
+
background: var(--color-accent);
|
|
125
|
+
border-color: var(--color-accent);
|
|
126
|
+
color: var(--color-paper);
|
|
127
|
+
}
|
|
128
|
+
|
|
100
129
|
/* ── Availability — week calendar ───────────────────────────────────────────── */
|
|
101
130
|
.availability-calendar {
|
|
102
131
|
display: flex;
|
|
103
132
|
flex-direction: column;
|
|
104
|
-
gap:
|
|
133
|
+
gap: 1.5rem;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* Filter row — holds the optional location + staff pickers side by side. */
|
|
137
|
+
.availability-filters {
|
|
138
|
+
display: flex;
|
|
139
|
+
flex-wrap: wrap;
|
|
140
|
+
gap: 1rem;
|
|
141
|
+
}
|
|
142
|
+
.availability-filters > * {
|
|
143
|
+
flex: 1 1 12rem;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* Location / staff picker (shown only when there's >1 option). */
|
|
147
|
+
.availability-staff {
|
|
148
|
+
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
150
|
+
gap: 0.5rem;
|
|
151
|
+
}
|
|
152
|
+
.availability-staff-label {
|
|
153
|
+
font-family: var(--font-display);
|
|
154
|
+
font-size: 0.75rem;
|
|
155
|
+
text-transform: uppercase;
|
|
156
|
+
letter-spacing: 0.05em;
|
|
157
|
+
color: var(--color-ink-soft, var(--color-mute));
|
|
158
|
+
}
|
|
159
|
+
.availability-staff-select {
|
|
160
|
+
width: 100%;
|
|
161
|
+
padding: 0.75rem 1rem;
|
|
162
|
+
font-family: var(--font-body);
|
|
163
|
+
font-size: 0.9375rem;
|
|
164
|
+
border: 1px solid var(--color-rule);
|
|
165
|
+
border-radius: var(--radius-sm);
|
|
166
|
+
background: var(--color-paper, var(--color-cream));
|
|
167
|
+
color: var(--color-ink);
|
|
168
|
+
box-sizing: border-box;
|
|
169
|
+
}
|
|
170
|
+
.availability-staff-select:focus {
|
|
171
|
+
outline: none;
|
|
172
|
+
border-color: var(--color-accent);
|
|
105
173
|
}
|
|
106
174
|
|
|
107
175
|
.availability-week-nav {
|
|
108
176
|
display: flex;
|
|
109
177
|
align-items: center;
|
|
110
178
|
justify-content: space-between;
|
|
111
|
-
gap:
|
|
112
|
-
padding:
|
|
179
|
+
gap: 1rem;
|
|
180
|
+
padding: 0.75rem 0;
|
|
113
181
|
border-bottom: 1px solid var(--color-rule);
|
|
114
182
|
}
|
|
115
183
|
.availability-week-label {
|
|
@@ -121,7 +189,7 @@ body[data-navigating="true"] .service-grid {
|
|
|
121
189
|
}
|
|
122
190
|
|
|
123
191
|
.availability-nav-btn {
|
|
124
|
-
padding:
|
|
192
|
+
padding: 0.75rem 1rem;
|
|
125
193
|
background: transparent;
|
|
126
194
|
border: 1px solid var(--color-rule);
|
|
127
195
|
border-radius: var(--radius-sm);
|
|
@@ -144,14 +212,14 @@ body[data-navigating="true"] .service-grid {
|
|
|
144
212
|
.availability-day-strip {
|
|
145
213
|
display: grid;
|
|
146
214
|
grid-template-columns: repeat(7, 1fr);
|
|
147
|
-
gap:
|
|
215
|
+
gap: 0.75rem;
|
|
148
216
|
}
|
|
149
217
|
.availability-day {
|
|
150
218
|
display: flex;
|
|
151
219
|
flex-direction: column;
|
|
152
220
|
align-items: center;
|
|
153
221
|
gap: 2px;
|
|
154
|
-
padding:
|
|
222
|
+
padding: 0.75rem 0;
|
|
155
223
|
border: 1px solid var(--color-rule);
|
|
156
224
|
border-radius: var(--radius-sm);
|
|
157
225
|
background: var(--color-paper, var(--color-cream));
|
|
@@ -189,7 +257,7 @@ body[data-navigating="true"] .service-grid {
|
|
|
189
257
|
.availability-slots {
|
|
190
258
|
display: grid;
|
|
191
259
|
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
|
192
|
-
gap:
|
|
260
|
+
gap: 0.75rem;
|
|
193
261
|
}
|
|
194
262
|
.availability-loading,
|
|
195
263
|
.availability-empty,
|
|
@@ -197,14 +265,14 @@ body[data-navigating="true"] .service-grid {
|
|
|
197
265
|
font-family: var(--font-body);
|
|
198
266
|
font-size: 0.9375rem;
|
|
199
267
|
color: var(--color-ink-soft, var(--color-mute));
|
|
200
|
-
padding:
|
|
268
|
+
padding: 1.5rem 0;
|
|
201
269
|
text-align: center;
|
|
202
270
|
}
|
|
203
271
|
.availability-empty {
|
|
204
272
|
display: flex;
|
|
205
273
|
flex-direction: column;
|
|
206
274
|
align-items: center;
|
|
207
|
-
gap:
|
|
275
|
+
gap: 1rem;
|
|
208
276
|
}
|
|
209
277
|
.availability-error {
|
|
210
278
|
color: var(--color-error, var(--color-accent));
|
|
@@ -212,7 +280,7 @@ body[data-navigating="true"] .service-grid {
|
|
|
212
280
|
|
|
213
281
|
/* ── Time-slot buttons ──────────────────────────────────────────────────────── */
|
|
214
282
|
.time-slot {
|
|
215
|
-
padding:
|
|
283
|
+
padding: 0.75rem 1rem;
|
|
216
284
|
border: 1px solid var(--color-rule);
|
|
217
285
|
border-radius: var(--radius-sm);
|
|
218
286
|
background: var(--color-paper, var(--color-cream));
|
|
@@ -245,8 +313,8 @@ body[data-navigating="true"] .service-grid {
|
|
|
245
313
|
.booking-form {
|
|
246
314
|
display: flex;
|
|
247
315
|
flex-direction: column;
|
|
248
|
-
gap:
|
|
249
|
-
padding:
|
|
316
|
+
gap: 1.5rem;
|
|
317
|
+
padding: 2rem;
|
|
250
318
|
border: 1px solid var(--color-rule);
|
|
251
319
|
border-radius: var(--radius-md);
|
|
252
320
|
background: var(--color-paper-warm, var(--color-paper, var(--color-cream)));
|
|
@@ -255,8 +323,8 @@ body[data-navigating="true"] .service-grid {
|
|
|
255
323
|
.booking-form-summary {
|
|
256
324
|
display: flex;
|
|
257
325
|
flex-direction: column;
|
|
258
|
-
gap:
|
|
259
|
-
padding-bottom:
|
|
326
|
+
gap: 0.5rem;
|
|
327
|
+
padding-bottom: 1rem;
|
|
260
328
|
border-bottom: 1px solid var(--color-rule);
|
|
261
329
|
}
|
|
262
330
|
.booking-form-summary strong {
|
|
@@ -271,7 +339,7 @@ body[data-navigating="true"] .service-grid {
|
|
|
271
339
|
}
|
|
272
340
|
.booking-form-change {
|
|
273
341
|
align-self: flex-start;
|
|
274
|
-
margin-top:
|
|
342
|
+
margin-top: 0.5rem;
|
|
275
343
|
background: none;
|
|
276
344
|
border: none;
|
|
277
345
|
padding: 0;
|
|
@@ -288,7 +356,7 @@ body[data-navigating="true"] .service-grid {
|
|
|
288
356
|
.booking-field {
|
|
289
357
|
display: flex;
|
|
290
358
|
flex-direction: column;
|
|
291
|
-
gap:
|
|
359
|
+
gap: 0.5rem;
|
|
292
360
|
}
|
|
293
361
|
.booking-label {
|
|
294
362
|
font-family: var(--font-display);
|
|
@@ -303,7 +371,7 @@ body[data-navigating="true"] .service-grid {
|
|
|
303
371
|
|
|
304
372
|
.booking-input {
|
|
305
373
|
width: 100%;
|
|
306
|
-
padding:
|
|
374
|
+
padding: 0.75rem 1rem;
|
|
307
375
|
font-family: var(--font-body);
|
|
308
376
|
font-size: 0.9375rem;
|
|
309
377
|
border: 1px solid var(--color-rule);
|
|
@@ -326,7 +394,7 @@ textarea.booking-input {
|
|
|
326
394
|
font-family: var(--font-body);
|
|
327
395
|
font-size: 0.875rem;
|
|
328
396
|
color: var(--color-error, var(--color-accent));
|
|
329
|
-
padding:
|
|
397
|
+
padding: 0.75rem 1rem;
|
|
330
398
|
border: 1px solid var(--color-error, var(--color-accent));
|
|
331
399
|
border-radius: var(--radius-sm);
|
|
332
400
|
}
|
|
@@ -336,7 +404,7 @@ textarea.booking-input {
|
|
|
336
404
|
align-items: center;
|
|
337
405
|
justify-content: center;
|
|
338
406
|
width: 100%;
|
|
339
|
-
padding:
|
|
407
|
+
padding: 1rem 2rem;
|
|
340
408
|
font-family: var(--font-display);
|
|
341
409
|
font-size: 0.9375rem;
|
|
342
410
|
font-weight: 500;
|