@wix/agent-skills 1.9.0 → 1.11.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 (47) hide show
  1. package/package.json +4 -1
  2. package/skills/wix-headless/references/DESIGN_SYSTEM.md +6 -16
  3. package/skills/wix-headless/references/astro/bookings/COMPONENTS.md +1 -1
  4. package/skills/wix-headless/references/astro/bookings/SERVICES_PAGES.md +19 -0
  5. package/skills/wix-headless/references/astro/cms/FAQ_KNOWLEDGE_BASE.md +2 -2
  6. package/skills/wix-headless/references/astro/designer/INSTRUCTIONS.md +3 -3
  7. package/skills/wix-headless/references/astro/ecom/CART_WIRING.md +2 -2
  8. package/skills/wix-headless/references/astro/ecom/COMPONENTS_CSS.md +3 -3
  9. package/skills/wix-headless/references/astro/gift-cards/COMPONENTS.md +2 -2
  10. package/skills/wix-headless/references/astro/stores/BACK_IN_STOCK.md +7 -7
  11. package/skills/wix-headless/references/astro/stores/COMPONENTS_CSS.md +4 -4
  12. package/skills/wix-headless/references/astro/stores/PRODUCT_PAGES.md +2 -2
  13. package/skills/wix-headless/references/astro/templates/Footer.astro +4 -4
  14. package/skills/wix-headless/references/astro/templates/Navigation.astro +4 -4
  15. package/skills/wix-headless/references/astro/templates/bookings/AvailabilityCalendar.tsx +147 -6
  16. package/skills/wix-headless/references/astro/templates/bookings/ServiceBookingFlow.tsx +21 -4
  17. package/skills/wix-headless/references/astro/templates/bookings/ServiceCard.astro +11 -2
  18. package/skills/wix-headless/references/astro/templates/bookings/booking-confirmation.astro +8 -8
  19. package/skills/wix-headless/references/astro/templates/bookings/components-bookings.css +94 -26
  20. package/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro +53 -10
  21. package/skills/wix-headless/references/astro/templates/bookings/services/index.astro +128 -20
  22. package/skills/wix-headless/references/astro/templates/ecom/CartView.tsx +3 -3
  23. package/skills/wix-headless/references/astro/templates/ecom/cart.astro +3 -3
  24. package/skills/wix-headless/references/astro/templates/ecom/components-ecom.css +17 -17
  25. package/skills/wix-headless/references/astro/templates/ecom/thank-you.astro +3 -3
  26. package/skills/wix-headless/references/astro/templates/gift-cards/_home-teaser-snippet.astro +4 -4
  27. package/skills/wix-headless/references/astro/templates/gift-cards/components-gift-cards.css +19 -18
  28. package/skills/wix-headless/references/astro/templates/gift-cards/gift-cards.astro +3 -3
  29. package/skills/wix-headless/references/astro/templates/global.css +17 -15
  30. package/skills/wix-headless/references/astro/templates/index.astro +4 -4
  31. package/skills/wix-headless/references/astro/templates/stores/components-stores.css +31 -31
  32. package/skills/wix-headless/references/astro/templates/stores/products/[slug].astro +4 -4
  33. package/skills/wix-headless/references/bookings/FLOW.md +93 -32
  34. package/skills/wix-headless/references/bookings/INSTRUCTIONS.md +8 -3
  35. package/skills/wix-headless/references/bookings/SERVICES_DATA.md +60 -0
  36. package/skills/wix-headless/references/custom/bookings/WIRING.md +60 -9
  37. package/skills/wix-headless/references/shared/DESIGN_MD.md +2 -3
  38. package/skills/wix-headless/references/shared/IMPLEMENTER.md +4 -4
  39. package/skills/wix-headless/references/shared/STYLING.md +15 -18
  40. package/skills/wix-headless/references/stores/INSTRUCTIONS.md +2 -2
  41. package/skills/wix-headless/references/verticals/bookings.md +5 -3
  42. package/skills/wix-headless/scripts/compose.mjs +4 -19
  43. package/skills/wix-headless/scripts/emit-design-tokens.mjs +0 -3
  44. package/skills/wix-manage/references/bookings/create-appointment-service.md +3 -28
  45. package/skills/wix-manage/references/bookings/create-booking-service-from-prompt.md +1 -8
  46. package/skills/wix-manage/references/bookings/create-class-service.md +3 -28
  47. package/skills/wix-manage/references/bookings/create-course-service.md +3 -28
@@ -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({ serviceId, serviceName, serviceType, onSlotSelected }: Props) {
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
- onSlotSelected(s);
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
- // and the booking-form `fields` (the @wix/forms schema) through to the form.
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({ service, serviceName, serviceType, fields }: Props) {
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={`/services/${slug}`} class="service-card">
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-lg py-4xl text-center">
23
- <div class="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-xl"
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-lg" />
28
- <h1 class="font-display text-4xl md:text-5xl leading-tight mb-md">Thank you for booking</h1>
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-md">
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-md">
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-md items-center">
44
- <a href="/services" class="inline-block font-display text-base px-xl py-md rounded-sm"
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
- * - .availability-* / .time-slot* → AvailabilityCalendar.tsx (week calendar + slots)
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: var(--spacing-xl);
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: var(--spacing-xs);
67
- padding: var(--spacing-md) var(--spacing-md) var(--spacing-lg);
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: var(--spacing-sm);
88
+ gap: 0.75rem;
88
89
  margin-top: auto;
89
- padding-top: var(--spacing-sm);
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: var(--spacing-sm);
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: var(--spacing-lg);
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: var(--spacing-md);
112
- padding: var(--spacing-sm) 0;
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: var(--spacing-sm) var(--spacing-md);
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: var(--spacing-sm);
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: var(--spacing-sm) 0;
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: var(--spacing-sm);
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: var(--spacing-lg) 0;
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: var(--spacing-md);
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: var(--spacing-sm) var(--spacing-md);
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: var(--spacing-lg);
249
- padding: var(--spacing-xl);
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: var(--spacing-xs);
259
- padding-bottom: var(--spacing-md);
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: var(--spacing-xs);
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: var(--spacing-xs);
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: var(--spacing-sm) var(--spacing-md);
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: var(--spacing-sm) var(--spacing-md);
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: var(--spacing-md) var(--spacing-xl);
407
+ padding: 1rem 2rem;
340
408
  font-family: var(--font-display);
341
409
  font-size: 0.9375rem;
342
410
  font-weight: 500;