Skip to content

Commit 0c59141

Browse files
committed
feat(booking): add 1-click post-precall recording slot email & pre-filled booking flow
1 parent 682fcb9 commit 0c59141

5 files changed

Lines changed: 277 additions & 56 deletions

File tree

‎apps/ops/src/App.tsx‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,7 @@ export function App() {
674674
| "approve_recording"
675675
| "cancel_booking"
676676
| "request_pre_meeting"
677+
| "request_recording_slot"
677678
| "bypass_pre_meeting",
678679
) => {
679680
setBusyActionId(`${bookingId}:${action}`);
@@ -698,7 +699,11 @@ export function App() {
698699
);
699700
} else if (action === "request_pre_meeting") {
700701
showFlash(
701-
"Flagged booking for a 15-minute Google Meet pre-meeting.",
702+
"Sent 15-minute Google Meet pre-call booking email to guest.",
703+
);
704+
} else if (action === "request_recording_slot") {
705+
showFlash(
706+
"Sent pre-filled 60m Podcast Recording booking link + Shared Run-of-Show email to guest!",
702707
);
703708
} else {
704709
showFlash("Bypassed 15-minute pre-meeting.");
@@ -3377,6 +3382,28 @@ export function App() {
33773382
📅 Request 15m Pre-Call
33783383
</button>
33793384
)}
3385+
<button
3386+
type="button"
3387+
disabled={
3388+
busyActionId ===
3389+
`${b._id}:request_recording_slot`
3390+
}
3391+
onClick={() =>
3392+
handleBookingManage(
3393+
b._id,
3394+
"request_recording_slot",
3395+
)
3396+
}
3397+
style={{
3398+
...btnStyle,
3399+
borderColor: "#8b5cf6",
3400+
color: "#5b21b6",
3401+
background: "#f5f3ff",
3402+
}}
3403+
title="Email guest a pre-filled 1-click link to pick their 60m StreamYard recording slot + review the Shared Run-of-Show"
3404+
>
3405+
🎙️ Email 60m Recording Link
3406+
</button>
33803407
{b.preMeetingStatus === "requested" && (
33813408
<button
33823409
type="button"

‎apps/site/src/pages/api/book/manage.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface ManageBookingPayload {
2626
| "cancel_booking"
2727
| "request_pre_meeting"
2828
| "schedule_pre_meeting"
29+
| "request_recording_slot"
2930
| "bypass_pre_meeting"
3031
| "reconcile_calendar_holds"
3132
| "migrate_private_ids";
@@ -420,6 +421,89 @@ export const POST: APIRoute = async ({ request, url }) => {
420421
);
421422
}
422423

424+
if (body.action === "request_recording_slot") {
425+
const recordingBookUrl = `${siteUrl}/book?portalToken=${encodeURIComponent(portalToken)}&bookingId=${encodeURIComponent(bookingId)}`;
426+
427+
if (canWrite) {
428+
try {
429+
await client
430+
.patch(bookingId)
431+
.set({
432+
preMeetingStatus: "completed",
433+
portalToken,
434+
})
435+
.commit();
436+
} catch {
437+
// Non-fatal
438+
}
439+
}
440+
441+
if (guestEmail) {
442+
const subject = `Schedule Your CodingCat.dev Podcast Recording + Shared Run-of-Show (${company})`;
443+
const text = [
444+
`Hi ${guestName},`,
445+
"",
446+
"Great chatting during our pre-call! Everything is pre-filled and your E-SIGN release is already on file, so you can pick your 60-minute StreamYard recording slot in one click:",
447+
"",
448+
`1. Pick Your Recording Time Slot: ${recordingBookUrl}`,
449+
`2. Shared Run-of-Show & Asset Vault: ${portalUrl}#run-of-show`,
450+
"",
451+
"On your Studio Portal link above, you can also review or edit our 5-part Run-of-Show outline and drop any repo links, demo URLs, or logos into the Vault.",
452+
"",
453+
"Looking forward to recording with you!",
454+
"Alex Patterson",
455+
"Founder, CodingCat.dev",
456+
].join("\n");
457+
458+
const html = `
459+
<div style="font-family: Inter, system-ui, sans-serif; max-width: 560px; margin: 0 auto; padding: 28px; background: #0B1326; color: #F8FAFC; border-radius: 16px; border: 2px solid #7C3AED;">
460+
<div style="font-family: monospace; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #C084FC; font-weight: 700; margin-bottom: 8px;">
461+
CodingCat.dev Studio &middot; Next Step After Pre-Call
462+
</div>
463+
<h1 style="font-size: 22px; font-weight: 800; margin: 0 0 12px; color: #FFFFFF;">
464+
Pick Your Podcast Recording Slot
465+
</h1>
466+
<p style="font-size: 14px; line-height: 1.6; color: #CBD5E1; margin: 0 0 20px;">
467+
Hi ${guestName}, great chatting during our pre-call! Your guest details and E-SIGN release are already on file. Click below to pick your 60-minute StreamYard recording window:
468+
</p>
469+
<div style="margin-bottom: 16px;">
470+
<a href="${recordingBookUrl}" style="display: inline-block; background: #7C3AED; color: #FFFFFF; font-weight: 700; font-size: 14px; text-decoration: none; padding: 12px 22px; border-radius: 10px;">
471+
1. Pick Your 60-Min Recording Slot &rarr;
472+
</a>
473+
</div>
474+
<div style="margin-bottom: 20px;">
475+
<a href="${portalUrl}#run-of-show" style="display: inline-block; background: #131B2E; color: #C084FC; border: 1px solid #7C3AED; font-weight: 700; font-size: 13px; text-decoration: none; padding: 10px 18px; border-radius: 10px;">
476+
2. Review / Edit Shared Run-of-Show &amp; Asset Vault &rarr;
477+
</a>
478+
</div>
479+
<p style="font-size: 12px; color: #94A3B8; line-height: 1.5; margin: 0;">
480+
Once you pick a slot, I will lock it in on Google Calendar with the StreamYard studio link.
481+
</p>
482+
</div>
483+
`;
484+
485+
await sendCodingCatEmail({
486+
to: guestEmail,
487+
subject,
488+
text,
489+
html,
490+
});
491+
}
492+
493+
return new Response(
494+
JSON.stringify({
495+
ok: true,
496+
action: "request_recording_slot",
497+
bookingId,
498+
preMeetingStatus: "completed",
499+
recordingBookUrl,
500+
portalToken,
501+
portalUrl,
502+
}),
503+
{ status: 200, headers: CORS_HEADERS },
504+
);
505+
}
506+
423507
if (body.action === "bypass_pre_meeting") {
424508
if (canWrite) {
425509
try {

‎apps/site/src/pages/api/booking.ts‎

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "@/lib/stripe";
1616

1717
interface BookingPayload {
18+
bookingId?: string;
1819
guestName?: string;
1920
guestEmail?: string;
2021
company?: string;
@@ -90,26 +91,51 @@ export const POST: APIRoute = async ({ request, url }) => {
9091
);
9192
}
9293

94+
const { client, canWrite } = getSanityServerClient();
95+
const existingToken = data.portalToken?.trim();
96+
const requestedBookingId =
97+
data.bookingId?.trim() || data.preMeetingForBookingId?.trim();
98+
99+
let existingBookingDoc: Record<string, any> | null = null;
100+
if (requestedBookingId || existingToken) {
101+
try {
102+
const stripped = (requestedBookingId || "").replace(/^private\./, "");
103+
const privId = stripped ? `private.${stripped}` : "";
104+
existingBookingDoc = await client.fetch(
105+
`*[_type == "booking" && (
106+
($privId != "" && _id in [$privId, $stripped]) ||
107+
($token != "" && (portalToken == $token || legacyPortalToken == $token))
108+
)][0]`,
109+
{
110+
privId,
111+
stripped,
112+
token: existingToken || "",
113+
},
114+
);
115+
} catch {
116+
// Non-fatal
117+
}
118+
}
119+
93120
const rawBookingId =
94-
data.preMeetingForBookingId?.trim() ||
121+
existingBookingDoc?._id ||
122+
requestedBookingId ||
95123
`booking-${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
96124
const bookingId = rawBookingId.startsWith("private.")
97125
? rawBookingId
98126
: `private.${rawBookingId}`;
99-
const existingToken = data.portalToken?.trim();
100127
const portalToken =
101-
existingToken && existingToken.length > 0
128+
existingBookingDoc?.portalToken ||
129+
(existingToken && existingToken.length > 0
102130
? existingToken
103-
: `spt_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
131+
: `spt_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`);
104132

105133
const workerEnv = (env ?? {}) as unknown as Record<
106134
string,
107135
string | undefined
108136
>;
109137
const fallbackOrigin = url?.origin ?? new URL(request.url).origin;
110138
const siteUrl = (workerEnv.SITE_URL || fallbackOrigin).replace(/\/$/, "");
111-
112-
const { client, canWrite } = getSanityServerClient();
113139
const queryFallback = new URLSearchParams({
114140
bookedSlot: slotStartAt,
115141
});
@@ -279,6 +305,34 @@ export const POST: APIRoute = async ({ request, url }) => {
279305
: {}),
280306
})
281307
.commit();
308+
} else if (existingBookingDoc?._id) {
309+
await client
310+
.patch(existingBookingDoc._id)
311+
.set({
312+
guestName,
313+
guestEmail,
314+
company,
315+
...(socialUrl ? { socialUrl } : {}),
316+
track,
317+
sessionType,
318+
status: isKickoff ? "scheduled" : "pending",
319+
slotStartAt,
320+
recordingUrl: STREAMYARD_STUDIO_URL,
321+
topics,
322+
appointmentScheduleUrl,
323+
...(calendarEvent.eventId
324+
? { googleCalendarEventId: calendarEvent.eventId }
325+
: {}),
326+
...(data.signatureData
327+
? {
328+
signatureMode,
329+
signatureData,
330+
signedAt: new Date().toISOString(),
331+
}
332+
: {}),
333+
portalToken,
334+
})
335+
.commit();
282336
} else {
283337
await client.createOrReplace({
284338
_id: bookingId,

0 commit comments

Comments
 (0)