diff --git a/src/components/BookEngineer.tsx b/src/components/BookEngineer.tsx index 55aca4f..e2290ae 100644 --- a/src/components/BookEngineer.tsx +++ b/src/components/BookEngineer.tsx @@ -230,49 +230,86 @@ export default function BookEngineer({ const resource = pickResource(selectedSlot); const resourceId = resource?._id ?? resource?.id; const trimmedDescription = description.trim(); + + // Pre-create the contact so we can pass `contactId` into the + // booking. The `sessions_booked` automation that sends the + // confirmation email reads `contact_id` from its trigger payload to + // address the email — when the booking is created with only an + // anonymous-visitor identity, the CRM contact link may not be + // populated synchronously, so we resolve one ourselves and attach + // it explicitly. + let contactId: string | undefined; + try { + const contactRes: any = await submittedContact.appendOrCreateContact({ + info: { + name: { first: firstName || "Guest", last: lastName }, + emails: { items: [{ email, tag: "MAIN" as any }] }, + }, + } as any); + contactId = contactRes?.contactId; + } catch { + // Booking can still proceed without an explicit contactId — the + // API will create one from contactDetails.email. Don't block. + } + // Appointment bookings always come back as `CREATED` for anonymous // visitors — `skipBusinessConfirmation` requires a scope anon visitors // don't have, so the SDK silently drops it. Without confirmation no // session is minted, no email goes out. We then POST to a server route // that confirms with elevated permissions. - const createRes: any = await bookings.createBooking({ - bookedEntity: { - slot: { - serviceId: service._id, - scheduleId: selectedSlot.scheduleId, - startDate: selectedSlot.localStartDate ?? selectedSlot.startDate, - endDate: selectedSlot.localEndDate ?? selectedSlot.endDate, - timezone: selectedSlot.timezone ?? service.schedule?.timezone, - location: { - locationType: slotLocationType, - ...(selectedSlot.location?.id && { _id: selectedSlot.location.id }), + // + // `selectedPaymentOption: OFFLINE` declares the customer's intended + // payment method up front. Pairs with the server-side confirmOrDecline + // call (paymentStatus PAID) so Bookings treats this as a fully paid + // offline booking — the path that actually triggers the customer email. + const createRes: any = await bookings.createBooking( + { + bookedEntity: { + slot: { + serviceId: service._id, + scheduleId: selectedSlot.scheduleId, + startDate: selectedSlot.localStartDate ?? selectedSlot.startDate, + endDate: selectedSlot.localEndDate ?? selectedSlot.endDate, + timezone: selectedSlot.timezone ?? service.schedule?.timezone, + location: { + locationType: slotLocationType, + ...(selectedSlot.location?.id && { _id: selectedSlot.location.id }), + }, + ...(resourceId && { resource: { _id: resourceId } }), }, - ...(resourceId && { resource: { _id: resourceId } }), }, - }, - contactDetails: { - firstName: firstName || "Guest", - lastName, - email, - }, - totalParticipants: 1, - } as any); + contactDetails: { + firstName: firstName || "Guest", + lastName, + email, + ...(contactId ? { contactId } : {}), + }, + totalParticipants: 1, + selectedPaymentOption: "OFFLINE", + } as any, + { + // The "Send clients an email confirmation when they book" site + // automation gates on `notify_participants=true` in the + // `sessions_booked` trigger payload — and that flag is sourced + // from the booking's stored `participantNotification`, which is + // only written at create-time. Confirm-time notification args + // don't backfill it, so we must set it here. + participantNotification: { + notifyParticipants: true, + ...(trimmedDescription + ? { message: `What I want to build:\n${trimmedDescription}` } + : {}), + }, + } as any, + ); const createdBooking = createRes?.booking ?? createRes; const bookingId = createdBooking?._id ?? createdBooking?.id; - const revision = createdBooking?.revision; - if (bookingId && revision) { + if (bookingId) { const confirmRes = await fetch("/api/bookings/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - bookingId, - revision, - email, - message: trimmedDescription - ? `What I want to build:\n${trimmedDescription}` - : undefined, - }), + body: JSON.stringify({ bookingId }), }); if (!confirmRes.ok) { const body = await confirmRes.json().catch(() => ({})); diff --git a/src/pages/api/bookings/confirm.ts b/src/pages/api/bookings/confirm.ts index af77b89..fd8d5e7 100644 --- a/src/pages/api/bookings/confirm.ts +++ b/src/pages/api/bookings/confirm.ts @@ -1,45 +1,60 @@ import type { APIRoute } from "astro"; -import { bookings } from "@wix/bookings"; -import { auth } from "@wix/essentials"; +import { auth, httpClient } from "@wix/essentials"; export const prerender = false; +// Trial: route anonymous bookings through Confirm Or Decline with +// `paymentStatus: PAID` instead of Confirm Booking. The hypothesis is that +// Bookings only fires the customer confirmation email when the booking +// transitions via the paid path — confirmBooking sets status without +// touching payment status, so the email template never gets the trigger +// it's waiting for. +// +// The endpoint isn't in @wix/bookings yet, so we hit REST directly via +// the auth-aware fetch and elevate it (anonymous visitors lack +// BOOKINGS.BOOKING_CONFIRM_OR_DECLINE). +const elevatedFetch = auth.elevate(httpClient.fetchWithAuth); + export const POST: APIRoute = async ({ request }) => { try { const body = await request.json(); - const { bookingId, revision, email, message } = body ?? {}; - if (!bookingId || !revision) { + const { bookingId } = body ?? {}; + if (!bookingId) { return new Response( - JSON.stringify({ error: "bookingId and revision are required" }), + JSON.stringify({ error: "bookingId is required" }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } - // Anonymous-visitor bookings come back as `CREATED` because they lack - // the BOOKINGS.OVERRIDE_AVAILABILITY scope — the SDK silently drops - // any flag that would auto-confirm. Without confirmation no session is - // minted, no email goes out. `auth.elevate` runs confirmBooking with - // the site app's permissions, which can transition the booking. - const elevatedConfirm = auth.elevate(bookings.confirmBooking); - const res = await elevatedConfirm(bookingId, revision, { - participantNotification: { - notifyParticipants: true, - ...(typeof message === "string" && message.trim() - ? { message: message.trim() } - : {}), - }, - } as any); + const url = `https://www.wixapis.com/bookings/v2/confirmation/${encodeURIComponent( + bookingId, + )}:confirmOrDecline`; + const res = await elevatedFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paymentStatus: "PAID" }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + return new Response( + JSON.stringify({ error: text || `confirmOrDecline failed (${res.status})` }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + const data: any = await res.json().catch(() => ({})); return new Response( JSON.stringify({ - status: (res as any)?.booking?.status ?? null, - eventId: (res as any)?.booking?.bookedEntity?.slot?.eventId ?? null, + status: data?.booking?.status ?? null, + paymentStatus: data?.booking?.paymentStatus ?? null, + eventId: data?.booking?.bookedEntity?.slot?.eventId ?? null, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } catch (err: any) { return new Response( - JSON.stringify({ error: err?.message ?? "confirmBooking failed" }), + JSON.stringify({ error: err?.message ?? "confirmOrDecline failed" }), { status: 500, headers: { "Content-Type": "application/json" } }, ); }