@wix/agent-skills 1.7.0 → 1.8.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/README.md +3 -7
- package/package.json +1 -1
- package/skills/wix-app/SKILL.md +2 -0
- package/skills/wix-app/references/STORES_VERSIONING.md +1 -0
- package/skills/wix-design-system/SKILL.md +4 -0
- package/skills/wix-headless/SKILL.md +55 -32
- package/skills/wix-headless/references/BUILD-astro.md +246 -0
- package/skills/wix-headless/references/BUILD-own-build.md +167 -0
- package/skills/wix-headless/references/BUILD.md +50 -252
- package/skills/wix-headless/references/DESIGN_SYSTEM.md +94 -34
- package/skills/wix-headless/references/DISCOVERY-connect.md +55 -0
- package/skills/wix-headless/references/DISCOVERY-create.md +238 -0
- package/skills/wix-headless/references/DISCOVERY.md +41 -422
- package/skills/wix-headless/references/PLAN-connect.md +19 -0
- package/skills/wix-headless/references/PLAN-create.md +17 -0
- package/skills/wix-headless/references/PLAN.md +37 -27
- package/skills/wix-headless/references/SEED.md +27 -18
- package/skills/wix-headless/references/SETUP.md +32 -213
- package/skills/wix-headless/references/astro/blog/BLOG_PAGES.md +5 -4
- package/skills/wix-headless/references/astro/bookings/COMPONENTS.md +337 -0
- package/skills/wix-headless/references/astro/bookings/SERVICES_PAGES.md +428 -0
- package/skills/wix-headless/references/astro/cms/CMS_FOUNDATIONS.md +5 -5
- package/skills/wix-headless/references/astro/cms/FAQ_KNOWLEDGE_BASE.md +10 -10
- package/skills/wix-headless/references/astro/cms/PORTFOLIO.md +12 -13
- package/skills/wix-headless/references/astro/cms/RESOURCE_LIBRARY.md +10 -12
- package/skills/wix-headless/references/astro/cms/TEAM_DIRECTORY.md +8 -8
- package/skills/wix-headless/references/astro/designer/INSTRUCTIONS.md +50 -27
- package/skills/wix-headless/references/astro/ecom/CART_PAGES.md +7 -8
- package/skills/wix-headless/references/astro/ecom/CART_WIRING.md +5 -5
- package/skills/wix-headless/references/astro/ecom/COMPONENTS_CSS.md +3 -3
- package/skills/wix-headless/references/astro/forms/CONTACT_FORM.md +5 -19
- package/skills/wix-headless/references/astro/gift-cards/COMPONENTS.md +2 -2
- package/skills/wix-headless/references/astro/gift-cards/PAGES.md +1 -1
- package/skills/wix-headless/references/astro/stores/BACK_IN_STOCK.md +6 -6
- package/skills/wix-headless/references/astro/stores/CATEGORY_PAGES.md +4 -4
- package/skills/wix-headless/references/astro/stores/COMPONENTS_CSS.md +3 -3
- package/skills/wix-headless/references/astro/stores/HOME_AND_NAV.md +3 -3
- package/skills/wix-headless/references/astro/stores/PRODUCT_PAGES.md +9 -9
- package/skills/wix-headless/references/astro/stores/SHARED_WIRING.md +5 -4
- package/skills/wix-headless/references/astro/templates/Footer.astro +1 -1
- package/skills/wix-headless/references/astro/templates/Layout.astro +1 -1
- package/skills/wix-headless/references/astro/templates/Navigation.astro +1 -1
- package/skills/wix-headless/references/astro/templates/astro.config.mjs +1 -1
- package/skills/wix-headless/references/astro/templates/bookings/AvailabilityCalendar.tsx +218 -0
- package/skills/wix-headless/references/astro/templates/bookings/BookingForm.tsx +194 -0
- package/skills/wix-headless/references/astro/templates/bookings/ManageBooking.tsx +84 -0
- package/skills/wix-headless/references/astro/templates/bookings/SeoTags.astro +25 -0
- package/skills/wix-headless/references/astro/templates/bookings/ServiceBookingFlow.tsx +50 -0
- package/skills/wix-headless/references/astro/templates/bookings/ServiceCard.astro +45 -0
- package/skills/wix-headless/references/astro/templates/bookings/api/confirm-booking.ts +34 -0
- package/skills/wix-headless/references/astro/templates/bookings/api/waitlist.ts +74 -0
- package/skills/wix-headless/references/astro/templates/bookings/booking-confirmation.astro +111 -0
- package/skills/wix-headless/references/astro/templates/bookings/components-bookings.css +413 -0
- package/skills/wix-headless/references/astro/templates/bookings/manage-booking.astro +76 -0
- package/skills/wix-headless/references/astro/templates/bookings/services/[slug].astro +81 -0
- package/skills/wix-headless/references/astro/templates/bookings/services/index.astro +65 -0
- package/skills/wix-headless/references/astro/templates/global.css +2 -2
- package/skills/wix-headless/references/astro/templates/index.astro +1 -1
- package/skills/wix-headless/references/blog/BLOG_CONTENT.md +19 -9
- package/skills/wix-headless/references/blog/INSTRUCTIONS.md +3 -3
- package/skills/wix-headless/references/bookings/INSTRUCTIONS.md +106 -0
- package/skills/wix-headless/references/bookings/SERVICES_DATA.md +295 -0
- package/skills/wix-headless/references/cms/CMS_FOUNDATIONS.md +4 -2
- package/skills/wix-headless/references/cms/INSTRUCTIONS.md +27 -1
- package/skills/wix-headless/references/commands/known-apps.json +2 -1
- package/skills/wix-headless/references/custom/CONNECTION_PLAN.md +142 -0
- package/skills/wix-headless/references/custom/INSTRUCTIONS.md +71 -21
- package/skills/wix-headless/references/custom/blog/WIRING.md +52 -0
- package/skills/wix-headless/references/custom/bookings/WIRING.md +88 -0
- package/skills/wix-headless/references/custom/cms/WIRING.md +97 -0
- package/skills/wix-headless/references/custom/ecom/WIRING.md +67 -0
- package/skills/wix-headless/references/custom/forms/WIRING.md +101 -0
- package/skills/wix-headless/references/custom/stores/WIRING.md +80 -0
- package/skills/wix-headless/references/ecom/INSTRUCTIONS.md +5 -5
- package/skills/wix-headless/references/forms/INSTRUCTIONS.md +2 -2
- package/skills/wix-headless/references/gift-cards/INSTRUCTIONS.md +3 -3
- package/skills/wix-headless/references/images/INSTRUCTIONS.md +16 -15
- package/skills/wix-headless/references/seed-recipes.md +2 -2
- package/skills/wix-headless/references/shared/AGENTS_MD.md +47 -0
- package/skills/wix-headless/references/shared/AUTHENTICATION.md +3 -5
- package/skills/wix-headless/references/shared/DESIGN_MD.md +85 -0
- package/skills/wix-headless/references/shared/IMAGE_GENERATION.md +29 -28
- package/skills/wix-headless/references/shared/IMPLEMENTER.md +87 -27
- package/skills/wix-headless/references/shared/PRODUCTION_SHARP_EDGES.md +2 -2
- package/skills/wix-headless/references/shared/RETURN_CONTRACT.md +24 -313
- package/skills/wix-headless/references/shared/STYLING.md +8 -18
- package/skills/wix-headless/references/stores/INSTRUCTIONS.md +39 -9
- package/skills/wix-headless/references/stores/PRODUCT_CATALOG_DATA.md +17 -14
- package/skills/wix-headless/references/verticals/_schema.json +1 -1
- package/skills/wix-headless/references/verticals/_schema.md +3 -3
- package/skills/wix-headless/references/verticals/blog.md +1 -1
- package/skills/wix-headless/references/verticals/bookings.md +41 -0
- package/skills/wix-headless/references/verticals/cms.md +1 -1
- package/skills/wix-headless/references/verticals/ecom.md +2 -2
- package/skills/wix-headless/references/verticals/gift-cards.md +1 -1
- package/skills/wix-headless/references/verticals/stores.md +1 -1
- package/skills/wix-headless/scripts/check-manifest.mjs +143 -6
- package/skills/wix-headless/scripts/compose.mjs +540 -0
- package/skills/wix-headless/scripts/emit-design-tokens.mjs +128 -51
- package/skills/wix-headless/scripts/flatten.sh +61 -0
- package/skills/wix-headless/scripts/generate-images.mjs +242 -0
- package/skills/wix-headless/scripts/scaffold.sh +47 -16
- package/skills/wix-headless/scripts/write-decorative-json.mjs +62 -0
- package/skills/wix-manage/SKILL.md +1 -1
- package/skills/wix-manage/references/blog/how-to-create-blog-posts.md +1 -1
- package/skills/wix-manage/references/cms/cms-data-items-crud.md +1 -1
- package/skills/wix-manage/references/cms/cms-references-and-relationships.md +1 -1
- package/skills/wix-manage/references/cms/cms-schema-management.md +1 -1
- package/skills/wix-manage/references/domains/domain-search-and-purchase.md +10 -7
- package/skills/wix-manage/references/forms/create-form.md +49 -1
- package/skills/wix-manage/references/stores/bulk-create-products-with-options.md +1 -1
- package/skills/wix-manage/references/stores/setup-online-store-catalog-v3.md +1 -1
- package/skills/wix-headless/references/astro/COMPOSE.md +0 -142
- package/skills/wix-headless/scripts/finalize-run-json.mjs +0 -141
- package/skills/wix-headless/scripts/init-site-json.mjs +0 -101
- package/skills/wix-headless/scripts/preview.sh +0 -45
- package/skills/wix-headless/scripts/release.sh +0 -73
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Phase 4 Product Pages — Stores
|
|
2
2
|
|
|
3
|
-
Scope: `product-pages
|
|
3
|
+
Scope: `product-pages` (the `pages-products` scope) — written by the stores **merged build agent** (the build wave) *after* its `components` and `pages-categories` scopes. This scope writes the product listing, product detail, and ProductCard component **once** — applying the designer's visual spec (`references/astro/designer/INSTRUCTIONS.md` → `store-pages`) together with live `productsV3` queries in a single pass. Read your seeded slice from `.wix/seeded.json` (`seeded.stores`).
|
|
4
4
|
|
|
5
5
|
## Scope
|
|
6
6
|
|
|
7
|
-
Files this agent OWNS (
|
|
7
|
+
Files this agent OWNS (each written once, visual structure + live queries together):
|
|
8
8
|
|
|
9
9
|
- `src/pages/products/index.astro` — product listing
|
|
10
10
|
- `src/pages/products/[slug].astro` — product detail with `wixMetadata` + SEO tags
|
|
@@ -12,17 +12,17 @@ Files this agent OWNS (rewrites from designer output):
|
|
|
12
12
|
|
|
13
13
|
Files this agent MUST NOT touch:
|
|
14
14
|
- `src/components/ProductPurchase.tsx`, `src/components/AddToCartButton.tsx` — owned by `components`
|
|
15
|
-
- `src/pages/cart.astro`, `src/pages/thank-you.astro` — owned by `
|
|
15
|
+
- `src/pages/cart.astro`, `src/pages/thank-you.astro` — owned by `cart-checkout`
|
|
16
16
|
- `src/pages/index.astro`, `src/components/Navigation.astro` — owned by `pages-home-and-nav`
|
|
17
|
-
- `src/components/CategoryRail.astro` — owned by `pages-categories
|
|
18
|
-
- `src/utils/categories.ts` — pre-copied by the orchestrator
|
|
17
|
+
- `src/components/CategoryRail.astro` — owned by the `pages-categories` scope, which your merged agent writes **before** this one. **Import** from `../../components/CategoryRail.astro`; never `Write` it here. By the time you mount it, the `pages-categories` step has already put it on disk. If it's somehow absent, you wrote your scopes out of order — return `status: "partial"` with `errors: [{ code: "MISSING_PAGES_CATEGORIES_OUTPUT", path: "src/components/CategoryRail.astro" }]` (write `pages-categories` before `pages-products`; never defensively `Write` it from this scope).
|
|
18
|
+
- `src/utils/categories.ts` — pre-copied by the orchestrator in the build-wave pre-batch (BUILD-astro.md § "Step 4.5"). **Import** `listStoreCategories`/etc. from `../../utils/categories`; never `Write` it.
|
|
19
19
|
- `global.css`, any other `.astro` page, any other component — owned by designers
|
|
20
20
|
|
|
21
21
|
## Inputs (from parent prompt)
|
|
22
22
|
|
|
23
23
|
- **Phase 1 return data** — `products: [{id, name, slug, variantId, price, inventory, sku}]` — use slugs when needed (rarely; `queryProducts` returns them already).
|
|
24
|
-
- **Design tokens** — the
|
|
25
|
-
- **Designer
|
|
24
|
+
- **Design tokens** — read the design tokens (the DESIGN.md vocabulary) from `.wix/design-tokens.css` (on disk) for the published color/spacing/typography vocabulary; compose Tailwind utilities derived from those tokens (`class="py-4xl flex flex-col gap-md font-display"`) at the call site rather than inventing semantic classes for layout/spacing/typography. Retained global semantic classes are limited to compound patterns (`offer-callout`, `cart-summary`), interactive primitives (`btn-primary`, `product-card`), and JS targets — see `references/shared/STYLING.md`.
|
|
25
|
+
- **Designer visual spec** — the layout, contract classes, and decorative-slot conventions for `products/index.astro`, `products/[slug].astro`, `ProductCard.astro` (from `references/astro/designer/INSTRUCTIONS.md` → `store-pages`), applied in the same pass as the live queries.
|
|
26
26
|
|
|
27
27
|
## Critical rules (all must be honored)
|
|
28
28
|
|
|
@@ -68,9 +68,9 @@ Do NOT modify logic, imports, SDK calls, `wixMetadata` export, or prop-passing.
|
|
|
68
68
|
|
|
69
69
|
Use template `templates/products/index.astro`.
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
Applies the designer's page structure (class names, layout) and binds a guarded, **cursor-paginated** `productsV3.queryProducts({ fields: ["CURRENCY"] }).limit(24).skipTo(cursor).find()` (cursor read from `?cursor=` on the URL; Prev/Next anchors built from `result.cursors.next` / `.prev`). Mounts the shared `<CategoryRail/>` (written by `pages-categories`) above the grid, passing `activeSlug={null}` and the prev/next hrefs. Emits the `AddProductImpression` analytics event from a client-side script that reads a JSON payload serialized at SSR.
|
|
72
72
|
|
|
73
|
-
> **Imports written by `pages-categories
|
|
73
|
+
> **Imports you must NOT write.** `<CategoryRail/>` (`../../components/CategoryRail.astro`) is written by the `pages-categories` scope your merged agent runs *before* this one; `listStoreCategories` (`../../utils/categories`) is pre-copied by the orchestrator. Both are on disk by the time you mount them. If either fails to resolve, return `status: "partial"` with `errors: [{ code: "MISSING_PAGES_CATEGORIES_OUTPUT", path: "<missing path>" }]` — that means your agent wrote `pages-products` before `pages-categories`, or the pre-copy was skipped.
|
|
74
74
|
|
|
75
75
|
> **Page size 24** — multiple of the 2/3/4-column grid, well under Wix's per-request cap of 100. Do not change without updating `CATEGORY_PAGES.md` to match.
|
|
76
76
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Phase 3 Components — Stores (TSX/Astro)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This is the **components** portion of the stores **merged build agent** (the build wave — `BUILD-astro.md` § "Step 4.5"). Your agent writes these islands **first**, then the stores pages (`pages-categories`, `pages-products`) that mount them — so the islands are on disk before the page code references them. The code here depends on the **design tokens** (read from `.wix/design-tokens.css` on disk) but NOT on page markup.
|
|
4
4
|
|
|
5
|
-
> **CSS
|
|
5
|
+
> **CSS is pre-copied, not authored.** `src/styles/components-stores.css` ships from the skill template, copied by the orchestrator in the build-wave pre-batch (see `./COMPONENTS_CSS.md`). This scope does NOT write the CSS file. Reference its contract class names from the design tokens here; the pre-copied stylesheet defines the rules.
|
|
6
6
|
|
|
7
7
|
## Scope
|
|
8
8
|
|
|
@@ -17,14 +17,15 @@ Files this agent MUST NOT touch:
|
|
|
17
17
|
- `src/styles/components-stores.css` — owned by the **`components-css`** sibling scope (see `./COMPONENTS_CSS.md`). Reference its class names; do not write the file.
|
|
18
18
|
- `src/utils/wix-image.ts` — **shared utility shipped by the build skill.** Import `resolveWixImageUrl` from `../utils/wix-image`; do NOT write your own copy (would shadow the shared util and drop other verticals' callers). The canonical source lives at `<SKILL_ROOT>/shared-utilities/wix-image.ts`; it's copied into projects by `seed-utilities.sh` during Setup.
|
|
19
19
|
- `src/components/CartView.tsx`, `src/components/CartBadge.tsx`, `src/utils/analytics.ts`, `src/styles/components-ecom.css` — owned by ecom
|
|
20
|
-
-
|
|
20
|
+
- `src/utils/back-in-stock.ts` — **pre-copied by the orchestrator** in the build-wave pre-batch (BUILD-astro.md § "Step 4.5"), same as `categories.ts`. Import `getBackInStockEnabled`/the app-id constants from `../utils/back-in-stock`; never `Write` it (see `./BACK_IN_STOCK.md`).
|
|
21
|
+
- Any `.astro` page — written by your agent's `pages-*` scopes (after the islands) or by another vertical
|
|
21
22
|
- `src/styles/global.css` — owned by designer foundation
|
|
22
23
|
- `src/layouts/Layout.astro` — owned by designer foundation (including the `components-stores.css` import line)
|
|
23
24
|
- Any designed component (`ProductCard.astro`, `Navigation.astro`, etc.) — owned by designers
|
|
24
25
|
|
|
25
26
|
## Coordination: design tokens
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
Read the design tokens from `.wix/design-tokens.css` (on disk, gate-verified present before the wave). The parent skill serializes your launch behind the designer foundation specifically so this file is already written when you start.
|
|
28
29
|
|
|
29
30
|
Reference the ACTUAL class names from the contract in React components (e.g. `className="add-to-cart-btn"`, not `className="addToCartButton"` or an invented name). See `references/shared/IMPLEMENTER.md` § "Contract class-name adaptation" for the full rule.
|
|
30
31
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
// ── Composer skeleton: src/components/Footer.astro ────────────────────────────
|
|
3
|
-
// Substitutions (see
|
|
3
|
+
// Substitutions (see compose.mjs):
|
|
4
4
|
// {{brand.name}} — brand name for the wordmark + copyright.
|
|
5
5
|
// {{shell.footerTagline}} — short brand-voice tagline from data.shell.
|
|
6
6
|
// {{nav.links}} — same inlined Navigation links array (mirror/subset);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// ── Composer skeleton: src/layouts/Layout.astro ──────────────────────────────
|
|
3
3
|
// Fully replaces the scaffold's bare stub. The <script> below and the View
|
|
4
4
|
// Transitions wiring are LITERAL fixed bulk — do not re-author. Composer's only
|
|
5
|
-
// substitutions (see
|
|
5
|
+
// substitutions (see compose.mjs):
|
|
6
6
|
// {{components-css-imports}} — one `import '../styles/components-<pack>.css';`
|
|
7
7
|
// per pack in "Packs with components" (omit entirely when none).
|
|
8
8
|
// {{fonts.googleHref}} — the Google Fonts <link> href built from designTokens
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
// ── Composer skeleton: src/components/Navigation.astro ────────────────────────
|
|
3
|
-
// Substitutions (see
|
|
3
|
+
// Substitutions (see compose.mjs):
|
|
4
4
|
// {{shell.navBrandMark}} — brand mark / wordmark text from data.shell.
|
|
5
5
|
// {{nav.links}} — one <a href={href}>{label}</a> per entry in the inlined
|
|
6
6
|
// Navigation links array, labels VERBATIM (no editorial rebrands). Render
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// scaffold's generated astro.config.mjs varies (integrations args, image.domains,
|
|
4
4
|
// vite.server settings differ run to run). Composer must NOT overwrite it with
|
|
5
5
|
// this file — it applies exactly two mutations to the scaffold's own config and
|
|
6
|
-
// preserves everything else (see
|
|
6
|
+
// preserves everything else (see compose.mjs):
|
|
7
7
|
//
|
|
8
8
|
// (1) register the Tailwind v4 Vite plugin → vite.plugins: [tailwindcss()]
|
|
9
9
|
// (2) fix the bare `process.env` reference → globalThis guard (TS-safe)
|
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Props {
|
|
38
|
+
serviceId: string;
|
|
39
|
+
serviceName: string;
|
|
40
|
+
serviceType: "APPOINTMENT" | "CLASS";
|
|
41
|
+
onSlotSelected: (slot: SelectedSlot) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const wixClient = createClient({
|
|
45
|
+
modules: { availabilityTimeSlots, eventTimeSlots, bookings },
|
|
46
|
+
auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
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.
|
|
51
|
+
const localDateString = (d: Date) =>
|
|
52
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
53
|
+
const tz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
54
|
+
|
|
55
|
+
const fetchSlots = async (
|
|
56
|
+
serviceId: string,
|
|
57
|
+
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);
|
|
62
|
+
const timeZone = tz();
|
|
63
|
+
|
|
64
|
+
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),
|
|
69
|
+
timeZone,
|
|
70
|
+
includeNonBookable: true, // include full sessions → waitlist CTA
|
|
71
|
+
cursorPaging: { limit: 50 },
|
|
72
|
+
});
|
|
73
|
+
const z = result.timeZone ?? timeZone;
|
|
74
|
+
const slots: SelectedSlot[] = (result.timeSlots ?? []).map((s: any) => ({
|
|
75
|
+
serviceType: "CLASS",
|
|
76
|
+
serviceId,
|
|
77
|
+
localStartDate: s.localStartDate,
|
|
78
|
+
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,
|
|
85
|
+
}));
|
|
86
|
+
return { slots, timezone: z };
|
|
87
|
+
}
|
|
88
|
+
|
|
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;
|
|
125
|
+
}
|
|
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`;
|
|
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");
|
|
146
|
+
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
|
147
|
+
const today = useMemo(() => startOfToday(), []);
|
|
148
|
+
const atToday = date.getTime() <= today.getTime();
|
|
149
|
+
|
|
150
|
+
const load = useCallback(async (target: Date) => {
|
|
151
|
+
setStatus("loading"); setSelectedKey(null);
|
|
152
|
+
try {
|
|
153
|
+
const { slots: fetched } = await fetchSlots(serviceId, serviceType, target);
|
|
154
|
+
setSlots(fetched); setStatus("ready");
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error("[availability] list failed:", err);
|
|
157
|
+
setStatus("error");
|
|
158
|
+
}
|
|
159
|
+
}, [serviceId, serviceType]);
|
|
160
|
+
|
|
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 ?? ""}`;
|
|
167
|
+
|
|
168
|
+
const handleSelect = async (s: SelectedSlot) => {
|
|
169
|
+
// Guard on the type-appropriate id — CLASS slots have no scheduleId.
|
|
170
|
+
const id = s.serviceType === "CLASS" ? s.eventId : s.scheduleId;
|
|
171
|
+
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 });
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<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>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{status === "loading" && <p className="availability-loading">Checking availability…</p>}
|
|
186
|
+
{status === "error" && (
|
|
187
|
+
<>
|
|
188
|
+
<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>
|
|
190
|
+
</>
|
|
191
|
+
)}
|
|
192
|
+
{status === "ready" && slots.length === 0 && (
|
|
193
|
+
<p className="availability-empty">No availability on this date — try another day.</p>
|
|
194
|
+
)}
|
|
195
|
+
{status === "ready" && slots.length > 0 && (
|
|
196
|
+
<div className="availability-slots" role="group" aria-label={`Available times for ${serviceName}`}>
|
|
197
|
+
{slots.map((s) => {
|
|
198
|
+
const key = keyOf(s);
|
|
199
|
+
const isSelected = key === selectedKey;
|
|
200
|
+
const cap = capacityLabel(s);
|
|
201
|
+
return (
|
|
202
|
+
<button
|
|
203
|
+
key={key}
|
|
204
|
+
type="button"
|
|
205
|
+
className={["time-slot", isSelected ? "time-slot--selected" : "time-slot--available", s.isFull ? "time-slot--full" : ""].filter(Boolean).join(" ")}
|
|
206
|
+
aria-pressed={isSelected}
|
|
207
|
+
onClick={() => void handleSelect(s)}
|
|
208
|
+
>
|
|
209
|
+
<span className="time-slot-time">{timeLabel(s.localStartDate)}</span>
|
|
210
|
+
{cap && <span className="time-slot-capacity">{cap}</span>}
|
|
211
|
+
</button>
|
|
212
|
+
);
|
|
213
|
+
})}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { createClient, OAuthStrategy } from "@wix/sdk";
|
|
3
|
+
import { bookings } from "@wix/bookings";
|
|
4
|
+
import type { SelectedSlot } from "./AvailabilityCalendar";
|
|
5
|
+
import { WIX_CLIENT_ID } from "astro:env/client"; // browser client ID, NOT import.meta.env
|
|
6
|
+
|
|
7
|
+
// BookingForm.tsx — client:only="react" island. Renders after a slot is picked.
|
|
8
|
+
// Flow: createBooking → CREATED holds NO seat, so POST the booking to
|
|
9
|
+
// the elevated /api/confirm-booking to occupy it (and validate availability).
|
|
10
|
+
// A full CLASS session throws SESSION_CAPACITY_EXCEEDED whose message carries the
|
|
11
|
+
// v1 session id → register on the native waitlist via /api/waitlist.
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
serviceId: string;
|
|
15
|
+
serviceName: string;
|
|
16
|
+
serviceType: "APPOINTMENT" | "CLASS";
|
|
17
|
+
slot: SelectedSlot;
|
|
18
|
+
onSuccess: (bookingId: string, startDate: string) => void;
|
|
19
|
+
onCancel: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const wixClient = createClient({
|
|
23
|
+
modules: { bookings },
|
|
24
|
+
auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const slotDisplay = (slot: SelectedSlot) =>
|
|
28
|
+
slot.localStartDate
|
|
29
|
+
? new Date(slot.localStartDate).toLocaleString([], {
|
|
30
|
+
weekday: "long", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit",
|
|
31
|
+
})
|
|
32
|
+
: "";
|
|
33
|
+
|
|
34
|
+
export default function BookingForm({ serviceName, serviceType, slot, onSuccess, onCancel }: Props) {
|
|
35
|
+
const [firstName, setFirstName] = useState("");
|
|
36
|
+
const [lastName, setLastName] = useState("");
|
|
37
|
+
const [email, setEmail] = useState("");
|
|
38
|
+
const [phone, setPhone] = useState("");
|
|
39
|
+
const [participants, setParticipants] = useState(1);
|
|
40
|
+
const [submitting, setSubmitting] = useState(false);
|
|
41
|
+
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
const [waitlisted, setWaitlisted] = useState(false);
|
|
43
|
+
|
|
44
|
+
const isWaitlist = serviceType === "CLASS" && !!slot.isFull;
|
|
45
|
+
// Party size only applies to CLASS (APPOINTMENT is 1-on-1).
|
|
46
|
+
const maxParty = serviceType === "CLASS" ? Math.max(1, slot.bookableCapacity ?? slot.remainingCapacity ?? 1) : 1;
|
|
47
|
+
const partyOptions = Array.from({ length: Math.min(maxParty, 8) }, (_, i) => i + 1);
|
|
48
|
+
|
|
49
|
+
const contactDetails = () => ({
|
|
50
|
+
firstName: firstName.trim(),
|
|
51
|
+
lastName: lastName.trim() || undefined,
|
|
52
|
+
email: email.trim(),
|
|
53
|
+
phone: phone.trim() || undefined,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const buildSlot = () =>
|
|
57
|
+
serviceType === "CLASS"
|
|
58
|
+
// CLASS: serviceId + eventId only — Wix derives the rest from the event.
|
|
59
|
+
? { serviceId: slot.serviceId, eventId: slot.eventId }
|
|
60
|
+
// APPOINTMENT: scheduleId + explicit start/end/timezone/location.
|
|
61
|
+
: {
|
|
62
|
+
serviceId: slot.serviceId,
|
|
63
|
+
scheduleId: slot.scheduleId,
|
|
64
|
+
startDate: slot.localStartDate,
|
|
65
|
+
endDate: slot.localEndDate,
|
|
66
|
+
timezone: slot.timezone,
|
|
67
|
+
...(slot.locationId
|
|
68
|
+
? { location: { _id: slot.locationId, locationType: slot.locationType as any } }
|
|
69
|
+
: { location: { locationType: "OWNER_BUSINESS" as const } }),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
setError(null);
|
|
75
|
+
if (!firstName.trim() || !email.trim()) {
|
|
76
|
+
setError("Please enter your first name and email.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
setSubmitting(true);
|
|
80
|
+
try {
|
|
81
|
+
const result = await wixClient.bookings.createBooking({
|
|
82
|
+
totalParticipants: participants, // party size
|
|
83
|
+
contactDetails: contactDetails(),
|
|
84
|
+
selectedPaymentOption: "OFFLINE" as const,
|
|
85
|
+
bookedEntity: { slot: buildSlot() },
|
|
86
|
+
} as any);
|
|
87
|
+
|
|
88
|
+
const status = result.booking?.status;
|
|
89
|
+
const bookingId = result.booking?._id ?? "";
|
|
90
|
+
const revision = result.booking?.revision ?? "";
|
|
91
|
+
|
|
92
|
+
// Defensive: if createBooking ever returns a WAITING_LIST booking directly,
|
|
93
|
+
// treat it as joined. In practice a full session THROWS
|
|
94
|
+
// SESSION_CAPACITY_EXCEEDED (handled in catch → /api/waitlist) rather than
|
|
95
|
+
// returning this status — both paths land the user on the waitlist.
|
|
96
|
+
if (status === "WAITING_LIST") { setWaitlisted(true); setSubmitting(false); return; }
|
|
97
|
+
|
|
98
|
+
// CREATED → confirm server-side to hold the seat.
|
|
99
|
+
const confirmRes = await fetch("/api/confirm-booking", {
|
|
100
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
101
|
+
body: JSON.stringify({ bookingId, revision }),
|
|
102
|
+
});
|
|
103
|
+
const confirmJson = await confirmRes.json().catch(() => ({}));
|
|
104
|
+
if (!confirmRes.ok || confirmJson.ok === false) {
|
|
105
|
+
setError("This session just filled up — please pick another time.");
|
|
106
|
+
setSubmitting(false); onCancel(); return;
|
|
107
|
+
}
|
|
108
|
+
onSuccess(bookingId, slot.localStartDate);
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
console.error("[booking] createBooking/confirm failed:", err);
|
|
111
|
+
// Full CLASS session → native waitlist. The error carries the v1 session id.
|
|
112
|
+
const full = `${err?.message ?? ""} ${JSON.stringify(err?.details ?? {})} ${String(err)}`;
|
|
113
|
+
if (/SESSION_CAPACITY_EXCEEDED/i.test(full) || /no available spots/i.test(full)) {
|
|
114
|
+
const sessionId = full.match(/on session ([^\s"}]+)/)?.[1];
|
|
115
|
+
if (sessionId) {
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch("/api/waitlist", {
|
|
118
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
119
|
+
body: JSON.stringify({ sessionId, totalParticipants: participants, contactDetails: contactDetails() }),
|
|
120
|
+
});
|
|
121
|
+
const j = await res.json().catch(() => ({}));
|
|
122
|
+
if (res.ok && j.ok !== false) { setWaitlisted(true); setSubmitting(false); return; }
|
|
123
|
+
} catch (e) { console.error("[waitlist] failed:", e); }
|
|
124
|
+
}
|
|
125
|
+
setError("This class is full and we couldn't add you to the waitlist — please try again later.");
|
|
126
|
+
setSubmitting(false); return;
|
|
127
|
+
}
|
|
128
|
+
setError("This time slot was just taken. Please select another.");
|
|
129
|
+
setSubmitting(false); onCancel();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (waitlisted) {
|
|
134
|
+
return (
|
|
135
|
+
<div className="booking-form">
|
|
136
|
+
<div className="booking-form-header">
|
|
137
|
+
<h3 className="booking-form-title">You're on the waitlist</h3>
|
|
138
|
+
<p className="booking-form-subtitle">{serviceName} · {slotDisplay(slot)}</p>
|
|
139
|
+
</div>
|
|
140
|
+
<p>We'll email <strong>{email.trim()}</strong> if a spot opens up.</p>
|
|
141
|
+
<button type="button" className="booking-cancel" onClick={onCancel}>← Back to available times</button>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<form className="booking-form" onSubmit={handleSubmit}>
|
|
148
|
+
<div className="booking-form-header">
|
|
149
|
+
<h3 className="booking-form-title">{isWaitlist ? "Join the waitlist" : "Reserve your spot"}</h3>
|
|
150
|
+
<p className="booking-form-subtitle">
|
|
151
|
+
{serviceName} · {slotDisplay(slot)}{slot.instructorName ? ` · with ${slot.instructorName}` : ""}
|
|
152
|
+
</p>
|
|
153
|
+
{isWaitlist && <p className="booking-form-note">This session is full — join the waitlist and we'll notify you if a spot opens.</p>}
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{error && <p className="booking-error">{error}</p>}
|
|
157
|
+
|
|
158
|
+
<div className="booking-field-row">
|
|
159
|
+
<div className="booking-field">
|
|
160
|
+
<label className="booking-label booking-label--required" htmlFor="bf-first">First name</label>
|
|
161
|
+
<input id="bf-first" className="booking-input" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} required autoComplete="given-name" />
|
|
162
|
+
</div>
|
|
163
|
+
<div className="booking-field">
|
|
164
|
+
<label className="booking-label" htmlFor="bf-last">Last name</label>
|
|
165
|
+
<input id="bf-last" className="booking-input" type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} autoComplete="family-name" />
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div className="booking-field">
|
|
170
|
+
<label className="booking-label booking-label--required" htmlFor="bf-email">Email</label>
|
|
171
|
+
<input id="bf-email" className="booking-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoComplete="email" />
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div className="booking-field">
|
|
175
|
+
<label className="booking-label" htmlFor="bf-phone">Phone</label>
|
|
176
|
+
<input id="bf-phone" className="booking-input" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} autoComplete="tel" />
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{!isWaitlist && maxParty > 1 && (
|
|
180
|
+
<div className="booking-field">
|
|
181
|
+
<label className="booking-label" htmlFor="bf-party">Number of spots</label>
|
|
182
|
+
<select id="bf-party" className="booking-input" value={participants} onChange={(e) => setParticipants(Number(e.target.value))}>
|
|
183
|
+
{partyOptions.map((n) => <option key={n} value={n}>{n}</option>)}
|
|
184
|
+
</select>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
<button type="submit" className="booking-submit" disabled={submitting}>
|
|
189
|
+
{submitting ? (isWaitlist ? "Joining…" : "Confirming…") : isWaitlist ? "Join Waitlist" : "Confirm Booking"}
|
|
190
|
+
</button>
|
|
191
|
+
<button type="button" className="booking-cancel" onClick={onCancel} disabled={submitting}>← Back to available times</button>
|
|
192
|
+
</form>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { createClient, OAuthStrategy } from "@wix/sdk";
|
|
3
|
+
import { bookings } from "@wix/bookings";
|
|
4
|
+
import { WIX_CLIENT_ID } from "astro:env/client";
|
|
5
|
+
|
|
6
|
+
// ManageBooking.tsx — client:only="react" island for /manage-booking.
|
|
7
|
+
// The server (manage-booking.astro) already minted an anonymous action token
|
|
8
|
+
// (app-elevated) and passed it in. The cancel call is a NO-AUTH anonymous method,
|
|
9
|
+
// so the visitor client runs it directly with the token. Reschedule-in-place
|
|
10
|
+
// needs a verified class V2Slot shape, so we offer cancel-and-rebook instead.
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
token: string | null;
|
|
14
|
+
revision: string;
|
|
15
|
+
serviceName: string;
|
|
16
|
+
startDate: string | null;
|
|
17
|
+
status: string | null;
|
|
18
|
+
canCancel: boolean;
|
|
19
|
+
showSummary?: boolean; // false when embedded under a page that already shows the service/date
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const wixClient = createClient({
|
|
23
|
+
modules: { bookings },
|
|
24
|
+
auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const fmt = (iso: string | null) => {
|
|
28
|
+
if (!iso) return null;
|
|
29
|
+
const d = new Date(iso);
|
|
30
|
+
return Number.isNaN(d.getTime())
|
|
31
|
+
? null
|
|
32
|
+
: d.toLocaleString([], { weekday: "long", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default function ManageBooking({ token, revision, serviceName, startDate, status, canCancel, showSummary }: Props) {
|
|
36
|
+
const [state, setState] = useState<"idle" | "canceling" | "canceled" | "error">(
|
|
37
|
+
status === "CANCELED" ? "canceled" : "idle",
|
|
38
|
+
);
|
|
39
|
+
const when = fmt(startDate);
|
|
40
|
+
|
|
41
|
+
const handleCancel = async () => {
|
|
42
|
+
if (!token) return;
|
|
43
|
+
setState("canceling");
|
|
44
|
+
try {
|
|
45
|
+
await wixClient.bookings.bookingsCancelBookingAnonymously(token, revision);
|
|
46
|
+
setState("canceled");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error("[manage-booking] cancel failed:", err);
|
|
49
|
+
setState("error");
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (state === "canceled") {
|
|
54
|
+
return (
|
|
55
|
+
<div className="manage-booking">
|
|
56
|
+
<p className="manage-booking-status">
|
|
57
|
+
Your booking for <strong>{serviceName}</strong>{when ? <> on <strong>{when}</strong></> : null} is canceled.
|
|
58
|
+
</p>
|
|
59
|
+
<a href="/services" className="booking-submit" style={{ display: "inline-block" }}>Book another service</a>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="manage-booking">
|
|
66
|
+
{showSummary !== false && (
|
|
67
|
+
<p className="manage-booking-summary">
|
|
68
|
+
<strong>{serviceName}</strong>{when ? <> · {when}</> : null}
|
|
69
|
+
</p>
|
|
70
|
+
)}
|
|
71
|
+
{state === "error" && <p className="booking-error">Something went wrong canceling your booking. Please try again.</p>}
|
|
72
|
+
{canCancel ? (
|
|
73
|
+
<button type="button" className="booking-submit" onClick={handleCancel} disabled={state === "canceling"}>
|
|
74
|
+
{state === "canceling" ? "Canceling…" : "Cancel this booking"}
|
|
75
|
+
</button>
|
|
76
|
+
) : (
|
|
77
|
+
<p className="manage-booking-note">This booking can no longer be canceled online.</p>
|
|
78
|
+
)}
|
|
79
|
+
<p className="manage-booking-note">
|
|
80
|
+
Need a different time? Cancel above, then <a href="/services" className="manage-booking-link">book another time</a>.
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|