@wix/agent-skills 1.8.0 → 1.9.0

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