Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 67 additions & 30 deletions src/components/BookEngineer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({}));
Expand Down
59 changes: 37 additions & 22 deletions src/pages/api/bookings/confirm.ts
Original file line number Diff line number Diff line change
@@ -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" } },
);
}
Expand Down