From fb4164973ba5ec7c3a0f8916b7c8c13d525babbf Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Tue, 3 Nov 2026 19:26:08 -0500 Subject: [PATCH 01/14] fix(site): report initial URL in visual editing history.subscribe so Presentation enables location navigation --- apps/site/src/layouts/BaseLayout.astro | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/site/src/layouts/BaseLayout.astro b/apps/site/src/layouts/BaseLayout.astro index d859e40b..acf58af1 100644 --- a/apps/site/src/layouts/BaseLayout.astro +++ b/apps/site/src/layouts/BaseLayout.astro @@ -330,8 +330,18 @@ const graph = buildGraph([ enableVisualEditing({ history: { subscribe: (navigate) => { + // Immediately report the current URL on initial mount so Sanity + // Presentation sets frameStateRef.current.url โ€” otherwise + // useNavigatePreviewFrame ignores subsequent location clicks. + navigate({ + type: "replace", + url: `${window.location.pathname}${window.location.search}${window.location.hash}`, + }); const onPopState = () => { - navigate({ type: "pop", url: window.location.href }); + navigate({ + type: "pop", + url: `${window.location.pathname}${window.location.search}${window.location.hash}`, + }); }; window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); @@ -351,10 +361,12 @@ const graph = buildGraph([ currentPerspective, ); } - if (update.type === "replace") { - window.location.replace(nextUrl.toString()); - } else { - window.location.assign(nextUrl.toString()); + if (nextUrl.href !== window.location.href) { + if (update.type === "replace") { + window.location.replace(nextUrl.toString()); + } else { + window.location.assign(nextUrl.toString()); + } } } else if (update.type === "pop") { window.history.back(); From 3995188d34bfc91db08a25e930087c30f0626385 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Tue, 3 Nov 2026 19:48:54 -0500 Subject: [PATCH 02/14] feat(portal,ops,sanity): add hybrid podcast workflow pipeline, needs_reschedule flow, and post-production rough cut + draft episode preview --- apps/ops/src/App.tsx | 698 +++++++++++++++++++++-- apps/sanity/schemas/documents/booking.ts | 43 +- apps/sanity/schemas/documents/podcast.ts | 30 + apps/sanity/structure.ts | 69 ++- apps/site/src/lib/google-calendar.ts | 4 +- apps/site/src/lib/sanity/preview.ts | 6 +- apps/site/src/pages/api/book/manage.ts | 421 ++++++++++++++ apps/site/src/pages/api/booking.ts | 7 +- apps/site/src/pages/api/portal/notes.ts | 4 +- apps/site/src/pages/podcast/[slug].astro | 38 +- apps/site/src/pages/portal/[token].astro | 517 +++++++++++++++-- 11 files changed, 1729 insertions(+), 108 deletions(-) diff --git a/apps/ops/src/App.tsx b/apps/ops/src/App.tsx index b474ed8c..6c04fab8 100644 --- a/apps/ops/src/App.tsx +++ b/apps/ops/src/App.tsx @@ -164,6 +164,13 @@ interface BookingDoc { sessionType?: "recording" | "pre_meeting"; status?: string; slotStartAt?: string; + previousSlotStartAt?: string; + rescheduleReason?: string; + podcastSlug?: string; + podcastDocId?: string; + roughCutUrl?: string; + roughCutNotes?: string; + targetPublishDate?: string; recordingUrl?: string; preMeetingStatus?: "none" | "requested" | "scheduled" | "completed" | "bypassed"; preMeetingSlotStartAt?: string; @@ -254,14 +261,93 @@ const LEAD_STATUS_OPTIONS = [ ]; const BOOKING_STATUS_OPTIONS = [ - { value: "pending", label: "Pending Review (Hold)" }, - { value: "approved", label: "Approved" }, - { value: "scheduled", label: "Confirmed on Calendar" }, - { value: "recorded", label: "Recorded" }, - { value: "published", label: "Published (Archive)" }, - { value: "cancelled", label: "Declined / Cancelled (Archive)" }, + { value: "pending", label: "โณ Pending Review (Hold)" }, + { value: "needs_reschedule", label: "๐Ÿ”„ Needs Reschedule / On Hold" }, + { value: "approved", label: "โœ“ Approved (StreamYard Sent)" }, + { value: "scheduled", label: "๐Ÿ“… Confirmed on Calendar" }, + { value: "recorded", label: "๐ŸŽฌ Recorded (Raw)" }, + { value: "editing", label: "โœ‚๏ธ In Editing / Post-Production" }, + { value: "guest_review", label: "๐Ÿ‘€ Rough Cut / Guest Review" }, + { value: "ready_to_publish", label: "๐Ÿš€ Ready to Publish" }, + { value: "published", label: "โœ… Published (Archive)" }, + { value: "cancelled", label: "โœ• Declined / Cancelled (Archive)" }, ]; +function isPostProductionBookingStatus(status?: string): boolean { + return ( + status === "recorded" || + status === "editing" || + status === "guest_review" || + status === "ready_to_publish" || + status === "published" + ); +} + +function getBookingStatusBadge(status?: string): { + label: string; + bg: string; + color: string; +} { + switch (status) { + case "needs_reschedule": + return { + label: "๐Ÿ”„ Needs Reschedule / Awaiting Slot", + bg: "#fef3c7", + color: "#92400e", + }; + case "approved": + case "scheduled": + case "confirmed": + return { + label: "โœ“ Approved (StreamYard Sent)", + bg: "#dcfce7", + color: "#065f46", + }; + case "recorded": + return { + label: "๐ŸŽฌ Recorded (Entering Edit)", + bg: "#ede9fe", + color: "#5b21b6", + }; + case "editing": + return { + label: "โœ‚๏ธ In Editing / Post-Production", + bg: "#f3e8ff", + color: "#6b21a8", + }; + case "guest_review": + return { + label: "๐Ÿ‘€ Rough Cut / Guest Review", + bg: "#dbeafe", + color: "#1e40af", + }; + case "ready_to_publish": + return { + label: "๐Ÿš€ Ready to Publish", + bg: "#d1fae5", + color: "#065f46", + }; + case "published": + return { + label: "โœ… Published", + bg: "#dcfce7", + color: "#166534", + }; + case "cancelled": + return { + label: "โœ• Cancelled", + bg: "#fee2e2", + color: "#991b1b", + }; + default: + return { + label: "โณ Hold (Pending Approval)", + bg: "#fef3c7", + color: "#92400e", + }; + } +} + function isArchivedLeadStatus(status?: string): boolean { return ( status === "paid" || status === "cancelled" || status === "archived" @@ -375,6 +461,12 @@ export function App() { } }); const [pfChatInput, setPfChatInput] = useState>({}); + const [roughCutUrlInput, setRoughCutUrlInput] = useState< + Record + >({}); + const [rescheduleReasonInput, setRescheduleReasonInput] = useState< + Record + >({}); const [vaultTitleInput, setVaultTitleInput] = useState>( {}, ); @@ -837,6 +929,10 @@ export function App() { ); if (newStatus === "approved" || newStatus === "scheduled") { await handleBookingManage(bookingId, "approve_recording"); + } else if (newStatus === "needs_reschedule") { + await handleBookingManage(bookingId, "mark_needs_reschedule"); + } else if (isPostProductionBookingStatus(newStatus)) { + await handleAdvancePostProduction(bookingId, newStatus, false); } else if (newStatus === "cancelled") { await handleBookingManage(bookingId, "cancel_booking"); } else { @@ -847,6 +943,45 @@ export function App() { } }; + const handleAdvancePostProduction = async ( + bookingId: string, + stage: string, + notifyGuest: boolean, + ) => { + const actionKey = `${bookingId}:postprod:${stage}:${notifyGuest}`; + setBusyActionId(actionKey); + try { + const booking = bookings.find((b) => b._id === bookingId); + const roughCutUrl = + roughCutUrlInput[bookingId] !== undefined + ? roughCutUrlInput[bookingId] + : booking?.roughCutUrl || ""; + const res = await fetch(`${siteOrigin}/api/book/manage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bookingId, + action: "advance_post_production", + status: stage, + roughCutUrl, + notifyGuest, + }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.message || data.error || "Failed to update stage"); + } + await fetchAllData(); + showFlash( + `Updated ${booking?.guestName || "episode"} to "${stage}"${data.podcastDocId ? ` (synced ${data.podcastDocId})` : ""}${notifyGuest ? " and emailed guest in canonical thread!" : "."}`, + ); + } catch (err: any) { + showFlash(`Post-production error: ${err?.message || "Unknown error"}`); + } finally { + setBusyActionId(null); + } + }; + const handleBookingManage = async ( bookingId: string, action: @@ -854,14 +989,21 @@ export function App() { | "cancel_booking" | "request_pre_meeting" | "request_recording_slot" - | "bypass_pre_meeting", + | "bypass_pre_meeting" + | "mark_needs_reschedule", + extraPayload?: Record, ) => { setBusyActionId(`${bookingId}:${action}`); try { const res = await fetch(`${siteOrigin}/api/book/manage`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ bookingId, action }), + body: JSON.stringify({ + bookingId, + action, + rescheduleReason: rescheduleReasonInput[bookingId] || undefined, + ...(extraPayload || {}), + }), }); const data = await res.json(); if (!res.ok) { @@ -872,6 +1014,10 @@ export function App() { showFlash( `Approved recording, synced private block to alex.patterson@sanity.io, and sent StreamYard link (${data.recordingUrl || "https://streamyard.com/c4kv26is93"}) to guest.`, ); + } else if (action === "mark_needs_reschedule") { + showFlash( + "Moved booking to Needs Reschedule, cleared old calendar hold, and sent 1-click reschedule invite in the guest's canonical Gmail thread!", + ); } else if (action === "cancel_booking") { showFlash( "Cancelled booking, archived record, and removed calendar blocks from alex@codingcat.dev and alex.patterson@sanity.io.", @@ -2989,6 +3135,461 @@ export function App() { + {/* Pipeline Row: Needs Reschedule / On Hold + Post-Production & Editing */} +
+ {/* Card 3: Needs Reschedule / Awaiting Recording Slot */} +
+

+ ๐Ÿ”„ Needs Reschedule / Awaiting Recording Slot ( + { + bookings.filter( + (b) => + b.status === "needs_reschedule" || + (!isArchivedBookingStatus(b.status) && + !isPostProductionBookingStatus(b.status) && + b.slotStartAt && + Date.parse(b.slotStartAt) < Date.now() - 3600_000), + ).length + } + ) +

+

+ Guests who missed a slot (sick / postponed) or completed a + 15m pre-call and still need to pick their 45m recording slot. +

+
+ {bookings + .filter( + (b) => + b.status === "needs_reschedule" || + (!isArchivedBookingStatus(b.status) && + !isPostProductionBookingStatus(b.status) && + b.slotStartAt && + Date.parse(b.slotStartAt) < Date.now() - 3600_000), + ) + .map((b) => { + const token = + b.portalToken || + b._id.replace(/^(private\.)?booking-/, ""); + const portalUrl = `${siteOrigin}/portal/${token}#booked-sessions`; + const gmailUrl = b.gmailThreadId + ? `https://mail.google.com/mail/u/0/#inbox/${b.gmailThreadId}` + : ""; + return ( +
+
+
+
+ {b.guestName} ({b.company || "Independent"}) +
+
+ {b.rescheduleReason || + (b.slotStartAt + ? `Missed / Past Slot: ${formatDateTime(b.slotStartAt)}` + : "Awaiting 45m recording slot")} +
+
+ + {b.status === "needs_reschedule" + ? "๐Ÿ”„ Needs Reschedule" + : "โš ๏ธ Past Unrecorded Slot"} + +
+
+ + + Portal โ†— + + {gmailUrl && ( + + Gmail โ†— + + )} + +
+ {expandedScheduleBookingId === b._id && ( +
+ {renderBookingDetailsPanel(b)} +
+ )} +
+ ); + })} +
+
+ + {/* Card 4: Post-Production & Editing Pipeline */} +
+

+ ๐ŸŽฌ Post-Production, Unlisted YouTube & Draft Pipeline ( + { + bookings.filter( + (b) => + isPostProductionBookingStatus(b.status) && + b.status !== "published", + ).length + } + ) +

+

+ Track recorded episodes through Editing,{" "} + Rough-Cut Guest Review, and{" "} + Ready to Publish. Paste an Unlisted YouTube + link to embed it directly on the Guest Portal. +

+
+ {bookings + .filter( + (b) => + isPostProductionBookingStatus(b.status) && + b.status !== "published", + ) + .map((b) => { + const token = + b.portalToken || + b._id.replace(/^(private\.)?booking-/, ""); + const portalReviewUrl = `${siteOrigin}/portal/${token}#post-production-review`; + const draftEpUrl = b.podcastSlug + ? `${siteOrigin}/podcast/${b.podcastSlug}?sanity-preview-perspective=drafts` + : ""; + const badge = getBookingStatusBadge(b.status); + const currentRoughCut = + roughCutUrlInput[b._id] !== undefined + ? roughCutUrlInput[b._id] + : b.roughCutUrl || ""; + return ( +
+
+
+
+ {b.guestName} ({b.company || "Independent"}) +
+
+ Recorded: {formatDateTime(b.slotStartAt)} + {b.podcastDocId && ( + <> + {" ยท "} + {b.podcastDocId} + + )} +
+
+ + {badge.label} + +
+ +
+ + + setRoughCutUrlInput((prev) => ({ + ...prev, + [b._id]: e.target.value, + })) + } + placeholder="Unlisted YouTube URL (https://youtu.be/...)" + style={{ + flex: "1 1 200px", + minWidth: 0, + padding: "5px 8px", + borderRadius: 6, + border: "1px solid #d1d5db", + fontSize: 12, + }} + /> + + +
+ +
+ + Portal Review โ†— + + {draftEpUrl && ( + + ๐Ÿ‘๏ธ Preview Draft Podcast โ†— + + )} + +
+ {expandedScheduleBookingId === b._id && ( +
+ {renderBookingDetailsPanel(b)} +
+ )} +
+ ); + })} +
+
+
+ {/* Ongoing Master Calendar Timeline */}

@@ -3197,40 +3798,55 @@ export function App() { > {b ? (
- - {isApproved - ? "โœ“ Approved (StreamYard Sent)" - : "โณ Hold (Pending Approval)"} - + {(() => { + const badge = getBookingStatusBadge( + b.status, + ); + return ( + + {badge.label} + + ); + })()}
) : ( diff --git a/apps/sanity/schemas/documents/booking.ts b/apps/sanity/schemas/documents/booking.ts index 510e13d0..9c693489 100644 --- a/apps/sanity/schemas/documents/booking.ts +++ b/apps/sanity/schemas/documents/booking.ts @@ -71,15 +71,19 @@ export default defineType({ }), defineField({ name: 'status', - title: 'Booking Status', + title: 'Booking & Post-Production Workflow Status', type: 'string', options: { list: [ {title: 'Pending Review / Calendar Hold (Opaque Busy)', value: 'pending'}, + {title: 'Needs Reschedule / On Hold (Awaiting Slot)', value: 'needs_reschedule'}, {title: 'Approved (StreamYard Sent)', value: 'approved'}, {title: 'Scheduled on Google Calendar', value: 'scheduled'}, {title: 'Confirmed', value: 'confirmed'}, - {title: 'Recorded', value: 'recorded'}, + {title: 'Recorded (Raw Session Complete)', value: 'recorded'}, + {title: 'In Editing / Post-Production', value: 'editing'}, + {title: 'Rough Cut / Guest Review', value: 'guest_review'}, + {title: 'Ready to Publish', value: 'ready_to_publish'}, {title: 'Published', value: 'published'}, {title: 'Declined / Cancelled', value: 'cancelled'}, ], @@ -91,6 +95,41 @@ export default defineType({ title: 'Requested Recording Slot (UTC ISO)', type: 'datetime', }), + defineField({ + name: 'previousSlotStartAt', + title: 'Previous / Missed Recording Slot (UTC ISO)', + type: 'datetime', + description: 'Archived timestamp when a booking is moved to Needs Reschedule or rebooked.', + }), + defineField({ + name: 'rescheduleReason', + title: 'Reschedule / On-Hold Reason', + type: 'string', + description: 'Context shown in Ops App and Guest Portal when a booking needs a new slot.', + }), + defineField({ + name: 'podcastSlug', + title: 'Linked Podcast Episode Slug', + type: 'string', + description: 'Slug of the linked draft or published podcast episode in Sanity.', + }), + defineField({ + name: 'podcastDocId', + title: 'Linked Podcast Document ID', + type: 'string', + description: 'Document ID of the linked draft or published podcast episode (for example, drafts.podcast-slug).', + }), + defineField({ + name: 'roughCutUrl', + title: 'Unlisted YouTube / Rough-Cut Review URL', + type: 'url', + description: 'Unlisted YouTube video URL (or Descript/Frame link) embedded in the Guest Studio Portal for rough-cut review.', + }), + defineField({ + name: 'targetPublishDate', + title: 'Target Episode Publish Date (UTC ISO)', + type: 'datetime', + }), defineField({ name: 'recordingUrl', title: 'Recording Studio URL (StreamYard)', diff --git a/apps/sanity/schemas/documents/podcast.ts b/apps/sanity/schemas/documents/podcast.ts index ec670dd8..218446ee 100644 --- a/apps/sanity/schemas/documents/podcast.ts +++ b/apps/sanity/schemas/documents/podcast.ts @@ -22,6 +22,36 @@ export default defineType({ ], fields: [ ...contentType.fields, + defineField({ + name: "workflowStage", + title: "Post-Production Workflow Stage", + type: "string", + group: "podcast", + options: { + list: [ + { title: "Recorded (Raw Session)", value: "recorded" }, + { title: "In Editing / Post-Production", value: "editing" }, + { title: "Rough Cut / Guest Review", value: "guest_review" }, + { title: "Ready to Publish", value: "ready_to_publish" }, + { title: "Published", value: "published" }, + ], + }, + initialValue: "editing", + }), + defineField({ + name: "bookingRef", + title: "Linked Booking Document ID", + type: "string", + group: "podcast", + description: "Document ID of the originating booking (for example, private.booking-...).", + }), + defineField({ + name: "roughCutNotes", + title: "Post-Production & Scrub Notes", + type: "text", + group: "podcast", + description: "Internal and guest/PR edit notes (such as visual cuts or PII checks) before publishing.", + }), defineField({ name: "podcastType", title: "Podcast Type", diff --git a/apps/sanity/structure.ts b/apps/sanity/structure.ts index dce4ea0d..85677b99 100644 --- a/apps/sanity/structure.ts +++ b/apps/sanity/structure.ts @@ -136,12 +136,40 @@ export const editorialStructure: StructureResolver = (S) => .schemaType("booking") .apiVersion("2025-02-19") .filter( - '_type == "booking" && status in ["approved", "scheduled"] && (_id in path("private.**") || _id in path("*"))', + '_type == "booking" && status in ["approved", "scheduled", "confirmed"] && (_id in path("private.**") || _id in path("*"))', ) .defaultOrdering([ { field: "slotStartAt", direction: "asc" }, ]), ), + S.listItem() + .title("Needs Reschedule / On Hold") + .child( + S.documentList() + .title("Needs Reschedule / On Hold") + .schemaType("booking") + .apiVersion("2025-02-19") + .filter( + '_type == "booking" && status == "needs_reschedule" && (_id in path("private.**") || _id in path("*"))', + ) + .defaultOrdering([ + { field: "_updatedAt", direction: "desc" }, + ]), + ), + S.listItem() + .title("Post-Production Pipeline (Recorded / Editing / Guest Review)") + .child( + S.documentList() + .title("Post-Production Pipeline") + .schemaType("booking") + .apiVersion("2025-02-19") + .filter( + '_type == "booking" && status in ["recorded", "editing", "guest_review", "ready_to_publish"] && (_id in path("private.**") || _id in path("*"))', + ) + .defaultOrdering([ + { field: "_updatedAt", direction: "desc" }, + ]), + ), S.divider(), S.listItem() .title("All Sponsor Leads & Deal Rooms") @@ -372,6 +400,45 @@ export const editorialStructure: StructureResolver = (S) => { field: "_updatedAt", direction: "desc" }, ]), ), + S.listItem() + .title("Pipeline: In Editing / Post-Production") + .child( + S.documentList() + .title("In Editing / Post-Production") + .schemaType("podcast") + .filter( + '_type == "podcast" && workflowStage in ["recorded", "editing"]', + ) + .defaultOrdering([ + { field: "_updatedAt", direction: "desc" }, + ]), + ), + S.listItem() + .title("Pipeline: Rough Cut / Guest Review") + .child( + S.documentList() + .title("Rough Cut / Guest Review") + .schemaType("podcast") + .filter( + '_type == "podcast" && workflowStage == "guest_review"', + ) + .defaultOrdering([ + { field: "_updatedAt", direction: "desc" }, + ]), + ), + S.listItem() + .title("Pipeline: Ready to Publish") + .child( + S.documentList() + .title("Ready to Publish") + .schemaType("podcast") + .filter( + '_type == "podcast" && workflowStage == "ready_to_publish"', + ) + .defaultOrdering([ + { field: "_updatedAt", direction: "desc" }, + ]), + ), S.divider(), S.listItem() .title("Needs Release Checklist") diff --git a/apps/site/src/lib/google-calendar.ts b/apps/site/src/lib/google-calendar.ts index 31f15447..b21cec35 100644 --- a/apps/site/src/lib/google-calendar.ts +++ b/apps/site/src/lib/google-calendar.ts @@ -39,9 +39,7 @@ async function syncPrivateShadowCalendarBlocks(opts: { "Private Calendar Block synced from alex@codingcat.dev", `Room: ${studioOrMeet}`, `Guest: ${opts.guestName} (${opts.company})`, - ...(opts.portalUrl - ? [`Guest Studio Portal: ${opts.portalUrl}`] - : []), + ...(opts.portalUrl ? [`Guest Studio Portal: ${opts.portalUrl}`] : []), ].join("\n"), location: studioOrMeet, status: "confirmed", diff --git a/apps/site/src/lib/sanity/preview.ts b/apps/site/src/lib/sanity/preview.ts index 7e74f240..2cb10b53 100644 --- a/apps/site/src/lib/sanity/preview.ts +++ b/apps/site/src/lib/sanity/preview.ts @@ -50,7 +50,11 @@ export function resolvePreview({ return { enabled: false }; } - const token = env?.SANITY_API_READ_TOKEN; + const token = + env?.SANITY_API_READ_TOKEN || + env?.SANITY_API_WRITE_TOKEN || + env?.SANITY_AUTH_TOKEN || + env?.SANITY_WRITE_TOKEN; if (!token) { return { enabled: false }; } diff --git a/apps/site/src/pages/api/book/manage.ts b/apps/site/src/pages/api/book/manage.ts index 52f26214..c9110af6 100644 --- a/apps/site/src/pages/api/book/manage.ts +++ b/apps/site/src/pages/api/book/manage.ts @@ -32,6 +32,8 @@ interface ManageBookingPayload { | "schedule_pre_meeting" | "request_recording_slot" | "bypass_pre_meeting" + | "mark_needs_reschedule" + | "advance_post_production" | "reconcile_calendar_holds" | "migrate_private_ids"; bookingId?: string; @@ -44,6 +46,19 @@ interface ManageBookingPayload { preMeetingSlotStartAt?: string; topics?: string; portalToken?: string; + rescheduleReason?: string; + isPostPreCall?: boolean; + sendEmail?: boolean; + emailHeadline?: string; + emailIntro?: string; + status?: string; + roughCutUrl?: string; + roughCutNotes?: string; + episodeTitle?: string; + episodeExcerpt?: string; + podcastSlug?: string; + targetPublishDate?: string; + notifyGuest?: boolean; docs?: Array>; patches?: Array<{ id: string; set: Record }>; } @@ -652,6 +667,412 @@ export const POST: APIRoute = async ({ request, url }) => { ); } + if (body.action === "mark_needs_reschedule") { + const isPostPreCall = Boolean(body.isPostPreCall); + const oldSlot = doc?.slotStartAt as string | undefined; + const reason = + body.rescheduleReason?.trim() || + (isPostPreCall + ? "15-minute pre-call completed: awaiting 60-minute StreamYard recording slot" + : "Session on hold: awaiting new 60-minute StreamYard recording slot"); + + if (googleCalendarEventId && !isPostPreCall) { + try { + await cancelGoogleCalendarBookingEvent({ + eventId: googleCalendarEventId, + startAt: slotStartAt, + durationMinutes: 60, + guestName, + }); + } catch { + // Non-fatal + } + } + + const rescheduleBookUrl = `${portalUrl}#booked-sessions`; + const directBookUrl = `${siteUrl}/book?portalToken=${encodeURIComponent(portalToken)}&bookingId=${encodeURIComponent(bookingId)}`; + + const reschedulePatch: Record = { + status: "needs_reschedule", + rescheduleReason: reason, + portalToken, + gmailSubject: canonicalSubject, + ...(oldSlot ? { previousSlotStartAt: oldSlot } : {}), + ...(isPostPreCall + ? { + preMeetingStatus: "completed", + preMeetingSlotStartAt: + body.preMeetingSlotStartAt || + doc?.preMeetingSlotStartAt || + oldSlot, + } + : {}), + }; + + if (body.sendEmail !== false && guestEmail) { + const headline = + body.emailHeadline || + (isPostPreCall + ? "Pre-Call Complete: Pick Your 60-Minute Podcast Recording Slot" + : "Whenever You Are Ready: Pick Your New Podcast Recording Slot"); + const introParagraph = + body.emailIntro || + (isPostPreCall + ? "great chatting during our pre-call! I updated your Studio Portal to mark the 15-minute pre-call complete and cleared the old placeholder date. Whenever you are ready, click below to pick your 60-minute StreamYard recording slot in one click." + : "no rush at all and hope you are feeling much better! I cleared our previous calendar hold so your Studio Portal is open whenever you are ready to pick a new 60-minute recording date in one click."); + + const card = buildPodcastPortalEmailCard({ + guestName, + company, + statusBadge: isPostPreCall + ? "CodingCat.dev Studio ยท Pre-Call Complete ยท Awaiting Recording Slot" + : "CodingCat.dev Studio ยท Reschedule Link Ready", + headline, + introParagraph, + slotSummary: reason, + portalUrl, + primaryCtaLabel: "1. Pick Your New Recording Slot in Portal ->", + primaryCtaUrl: rescheduleBookUrl, + secondaryCtaLabel: "2. Or Open Direct Calendar Picker ->", + secondaryCtaUrl: directBookUrl, + runOfShowLines, + }); + + const emailRes = await sendCodingCatEmailDetailed({ + to: guestEmail, + cc: coordinatorEmails, + subject: existingThreadId + ? `Re: ${canonicalSubject}` + : canonicalSubject, + text: card.text, + html: card.html, + ...(existingThreadId ? { threadId: existingThreadId } : {}), + ...(existingMessageId + ? { + inReplyTo: existingMessageId, + references: existingMessageId, + } + : {}), + }); + + if (emailRes.threadId) { + reschedulePatch.gmailThreadId = emailRes.threadId; + } + if (emailRes.rfc822MessageId) { + reschedulePatch.gmailMessageId = emailRes.rfc822MessageId; + } + } + + if (canWrite) { + try { + await client + .patch(bookingId) + .set(reschedulePatch) + .unset(["slotStartAt"]) + .commit(); + } catch { + // Non-fatal + } + } + + return new Response( + JSON.stringify({ + ok: true, + action: "mark_needs_reschedule", + bookingId, + status: "needs_reschedule", + rescheduleReason: reason, + previousSlotStartAt: oldSlot || null, + portalToken, + portalUrl, + rescheduleBookUrl, + gmailThreadId: reschedulePatch.gmailThreadId || existingThreadId, + }), + { status: 200, headers: CORS_HEADERS }, + ); + } + + if (body.action === "advance_post_production") { + const nextStage = (body.status || "editing").trim(); + const roughCutUrl = + body.roughCutUrl !== undefined + ? body.roughCutUrl.trim() + : (doc?.roughCutUrl as string | undefined) || ""; + const roughCutNotes = + body.roughCutNotes !== undefined + ? body.roughCutNotes.trim() + : (doc?.roughCutNotes as string | undefined) || ""; + + const rawTitle = + body.episodeTitle?.trim() || + (topics && topics !== "CodingCat.dev Podcast Session" + ? `${topics + .split("\n")[0] + .replace(/^Topic:\s*/i, "") + .trim()} with ${guestName}` + : `${company && company !== guestName ? `${company} Deep Dive` : "Engineering Deep Dive"} with ${guestName}`); + + const slugify = (val: string) => + val + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + + const podcastSlug = + body.podcastSlug?.trim() || + (doc?.podcastSlug as string | undefined) || + slugify(rawTitle); + const draftPodcastId = + (doc?.podcastDocId as string | undefined) || + `drafts.podcast-${podcastSlug}`; + const publishedPodcastId = draftPodcastId.replace(/^drafts\./, ""); + + const recordingDateIso = + (doc?.slotStartAt as string | undefined) || + (doc?.previousSlotStartAt as string | undefined) || + new Date().toISOString(); + + let createdOrUpdatedPodcastId = draftPodcastId; + + if (canWrite) { + try { + const existingPodcast = await client.fetch | null>( + "*[_id in [$draftId, $pubId]][0]", + { draftId: draftPodcastId, pubId: publishedPodcastId }, + ); + + let guestRefId = (doc?.guestDocId as string | undefined)?.replace( + /^drafts\./, + "", + ); + if (!guestRefId && doc?.guestSlug) { + guestRefId = `guest-${doc.guestSlug}`; + } + if (!guestRefId) { + const foundGuest = await client.fetch<{ _id?: string } | null>( + `*[_type == "guest" && (slug.current == $slug || title match $name)][0]{ _id }`, + { + slug: slugify(guestName), + name: guestName, + }, + ); + if (foundGuest?._id) { + guestRefId = foundGuest._id.replace(/^drafts\./, ""); + } + } + + const latestEp = await client.fetch<{ + season?: number; + episode?: number; + } | null>( + `*[_type == "podcast" && defined(season) && defined(episode)] | order(season desc, episode desc)[0]{ season, episode }`, + ); + const defaultSeason = existingPodcast?.season ?? latestEp?.season ?? 6; + const defaultEpisode = + existingPodcast?.episode ?? (latestEp?.episode ?? 20) + 1; + + const excerptText = + body.episodeExcerpt?.trim() || + existingPodcast?.excerpt || + `Alex Patterson sits down with ${guestName}${company && company !== guestName ? ` (${company})` : ""} to explore ${topics.split("\n")[0].trim()}.`; + + const rosLines = (runOfShowLines || [topics]).filter(Boolean); + const defaultContentBlocks = [ + { + _key: "intro-block", + _type: "block", + style: "normal", + markDefs: [], + children: [ + { + _key: "intro-span", + _type: "span", + marks: [], + text: excerptText, + }, + ], + }, + { + _key: "ros-heading", + _type: "block", + style: "h2", + markDefs: [], + children: [ + { + _key: "ros-heading-span", + _type: "span", + marks: [], + text: "Episode Run-of-Show & Highlights", + }, + ], + }, + ...rosLines.map((line, idx) => ({ + _key: `ros-line-${idx}`, + _type: "block", + style: "normal", + markDefs: [], + children: [ + { + _key: `ros-span-${idx}`, + _type: "span", + marks: [], + text: line, + }, + ], + })), + ]; + + const basePodcastDoc: Record = { + ...(existingPodcast || {}), + _id: draftPodcastId, + _type: "podcast", + title: existingPodcast?.title || rawTitle, + slug: existingPodcast?.slug || { + _type: "slug", + current: podcastSlug, + }, + excerpt: excerptText, + workflowStage: nextStage, + bookingRef: bookingId, + season: defaultSeason, + episode: defaultEpisode, + recordingDate: existingPodcast?.recordingDate || recordingDateIso, + date: + body.targetPublishDate || existingPodcast?.date || recordingDateIso, + ...(roughCutUrl + ? { youtube: roughCutUrl } + : existingPodcast?.youtube + ? { youtube: existingPodcast.youtube } + : {}), + ...(roughCutNotes ? { roughCutNotes } : {}), + author: existingPodcast?.author || [ + { + _key: "author-alex", + _type: "reference", + _ref: "RIgC5r4TJ0hCGOzxHszD1w", + }, + ], + ...(guestRefId && !existingPodcast?.guest + ? { + guest: [ + { + _key: "guest-primary", + _type: "reference", + _ref: guestRefId, + _weak: true, + }, + ], + } + : {}), + content: existingPodcast?.content || defaultContentBlocks, + }; + + await client.createOrReplace(basePodcastDoc as any); + createdOrUpdatedPodcastId = draftPodcastId; + } catch { + // Non-fatal if draft creation encounters a schema reference issue + } + } + + const draftEpisodePreviewUrl = `${siteUrl}/podcast/${podcastSlug}?sanity-preview-perspective=drafts`; + const portalReviewUrl = `${portalUrl}#post-production-review`; + + const bookingPatch: Record = { + status: nextStage, + podcastSlug, + podcastDocId: createdOrUpdatedPodcastId, + portalToken, + gmailSubject: canonicalSubject, + ...(roughCutUrl ? { roughCutUrl } : {}), + ...(body.targetPublishDate + ? { targetPublishDate: body.targetPublishDate } + : {}), + }; + + if (body.notifyGuest && guestEmail) { + const stageLabelMap: Record = { + recorded: "Recorded ยท Entering Post-Production", + editing: "In Editing & Post-Production", + guest_review: "Rough Cut & Draft Episode Ready for Review", + ready_to_publish: "Approved & Queued for Release", + published: "Episode Published Live", + }; + const stageLabel = stageLabelMap[nextStage] || "Post-Production Update"; + + const card = buildPodcastPortalEmailCard({ + guestName, + company, + statusBadge: `CodingCat.dev Studio ยท ${stageLabel}`, + headline: + nextStage === "guest_review" && roughCutUrl + ? "Rough Cut & Draft Episode Page Ready for Your Review" + : `Episode Status Update: ${stageLabel}`, + introParagraph: + nextStage === "guest_review" && roughCutUrl + ? "your CodingCat.dev Podcast rough cut and draft episode page are ready for review! You can watch the unlisted YouTube rough cut directly inside your Studio Portal, preview the drafted Sanity episode page, and drop any timestamped edit notes in our shared Call Notes chat." + : `thanks again for an awesome session! Your episode is now in ${stageLabel}. You can open your Studio Portal anytime to preview the drafted Sanity episode page, watch the unlisted YouTube rough cut once uploaded, or drop timestamped edit notes in our shared Call Notes chat.`, + slotSummary: roughCutUrl + ? `Unlisted YouTube Rough Cut + Draft Page: /podcast/${podcastSlug}` + : `Draft Episode Page: /podcast/${podcastSlug}`, + portalUrl: portalReviewUrl, + primaryCtaLabel: "1. Open Post-Production Portal & Video Review ->", + primaryCtaUrl: portalReviewUrl, + secondaryCtaLabel: "2. Preview Draft Episode Page ->", + secondaryCtaUrl: draftEpisodePreviewUrl, + runOfShowLines, + }); + + const emailRes = await sendCodingCatEmailDetailed({ + to: guestEmail, + cc: coordinatorEmails, + subject: existingThreadId + ? `Re: ${canonicalSubject}` + : canonicalSubject, + text: card.text, + html: card.html, + ...(existingThreadId ? { threadId: existingThreadId } : {}), + ...(existingMessageId + ? { + inReplyTo: existingMessageId, + references: existingMessageId, + } + : {}), + }); + + if (emailRes.threadId) { + bookingPatch.gmailThreadId = emailRes.threadId; + } + if (emailRes.rfc822MessageId) { + bookingPatch.gmailMessageId = emailRes.rfc822MessageId; + } + } + + if (canWrite) { + try { + await client.patch(bookingId).set(bookingPatch).commit(); + } catch { + // Non-fatal + } + } + + return new Response( + JSON.stringify({ + ok: true, + action: "advance_post_production", + bookingId, + status: nextStage, + podcastSlug, + podcastDocId: createdOrUpdatedPodcastId, + roughCutUrl: roughCutUrl || null, + draftEpisodePreviewUrl, + portalReviewUrl, + gmailThreadId: bookingPatch.gmailThreadId || existingThreadId, + }), + { status: 200, headers: CORS_HEADERS }, + ); + } + if (body.action === "cancel_booking") { await cancelGoogleCalendarBookingEvent({ eventId: googleCalendarEventId, diff --git a/apps/site/src/pages/api/booking.ts b/apps/site/src/pages/api/booking.ts index 96f830da..4c393d24 100644 --- a/apps/site/src/pages/api/booking.ts +++ b/apps/site/src/pages/api/booking.ts @@ -236,7 +236,8 @@ export const POST: APIRoute = async ({ request, url }) => { const wasAlreadyApproved = existingBookingDoc?.status === "approved" || existingBookingDoc?.status === "confirmed" || - existingBookingDoc?.status === "scheduled"; + existingBookingDoc?.status === "scheduled" || + existingBookingDoc?.status === "needs_reschedule"; // If rescheduling an existing 15m Pre-Meeting or 45m Recording, cancel the previous Google Calendar event first // so the old time slot is freed and the guest receives a clean replacement invite. @@ -354,7 +355,9 @@ export const POST: APIRoute = async ({ request, url }) => { status: isKickoff ? "scheduled" : wasAlreadyApproved - ? existingBookingDoc.status + ? existingBookingDoc.status === "needs_reschedule" + ? "approved" + : existingBookingDoc.status : "pending", slotStartAt, recordingUrl: STREAMYARD_STUDIO_URL, diff --git a/apps/site/src/pages/api/portal/notes.ts b/apps/site/src/pages/api/portal/notes.ts index d02e2ea9..cf543b30 100644 --- a/apps/site/src/pages/api/portal/notes.ts +++ b/apps/site/src/pages/api/portal/notes.ts @@ -851,7 +851,7 @@ export const POST: APIRoute = async ({ request, url }) => { headline: chatMessage && noteType === "call_note" ? `Call Notes & Next Steps for ${company} x CodingCat.dev` - : `Alex Patterson Updated Your Shared Run-of-Show & Notes`, + : "Alex Patterson Updated Your Shared Run-of-Show & Notes", introParagraph: chatMessage ? `I just posted new notes${newAttachments.length > 0 ? " and shared files" : ""} in your CodingCat.dev Studio Portal: "${chatMessage.slice(0, 280)}${chatMessage.length > 280 ? "..." : ""}". You can reply, drop files into the chat, or edit the shared Run-of-Show directly in your portal below.` : "I just updated our shared Pre-Call Notes and 5-Part Run-of-Show outline in your CodingCat.dev Studio Portal. Take a look below and feel free to edit or leave a comment directly in the portal!", @@ -913,7 +913,7 @@ export const POST: APIRoute = async ({ request, url }) => { ? `${resolvedAuthorName} Posted a Note in the Studio Portal` : `${resolvedAuthorName} Updated the Shared Run-of-Show`, introParagraph: isFeedback - ? `${resolvedAuthorName} (${verifiedEmail || guestEmail}) just submitted feedback on the CodingCat.dev Booking & Studio Portal${feedbackRating ? ` (Rating: ${feedbackRating}/5)` : ""}${feedbackTopics.length > 0 ? ` [${feedbackTopics.join(", ")}]` : ""}${newAttachments.length > 0 ? ` with an annotated screen capture synced to Sanity Media Library` : ""}.` + ? `${resolvedAuthorName} (${verifiedEmail || guestEmail}) just submitted feedback on the CodingCat.dev Booking & Studio Portal${feedbackRating ? ` (Rating: ${feedbackRating}/5)` : ""}${feedbackTopics.length > 0 ? ` [${feedbackTopics.join(", ")}]` : ""}${newAttachments.length > 0 ? " with an annotated screen capture synced to Sanity Media Library" : ""}.` : chatMessage ? `${resolvedAuthorName} (${verifiedEmail || guestEmail}) just posted a message${newAttachments.length > 0 ? ` and dropped ${newAttachments.length} attachment(s)` : ""} in the ${company} Studio Portal: "${chatMessage}".` : `${resolvedAuthorName} (${verifiedEmail || guestEmail}) just edited the shared Run-of-Show / Pre-Call Notes in the ${company} Studio Portal.`, diff --git a/apps/site/src/pages/podcast/[slug].astro b/apps/site/src/pages/podcast/[slug].astro index 479ce21a..d8ef976e 100644 --- a/apps/site/src/pages/podcast/[slug].astro +++ b/apps/site/src/pages/podcast/[slug].astro @@ -21,6 +21,7 @@ import { resolveRelatedVideo, } from "@/lib/related-content"; import { podcastQuery, relatedCandidatesQuery } from "@/lib/sanity/queries"; +import { getSanityServerClient } from "@/lib/sanity/write-client"; import { articleSchema, breadcrumbSchema, @@ -33,11 +34,24 @@ import { matchTopicsForText } from "@/lib/topics"; const { sanity } = Astro.locals; const { slug } = Astro.params; +const wantsDraftPreview = + sanity.preview.enabled || + Astro.url.searchParams.get("sanity-preview-perspective") === "drafts"; -const podcast = slug ? await sanity.loadQuery(podcastQuery, { slug }) : null; +let podcast = slug ? await sanity.loadQuery(podcastQuery, { slug }) : null; +if (!podcast?._id && slug && wantsDraftPreview) { + try { + const { client } = getSanityServerClient(); + podcast = (await client.fetch(podcastQuery, { slug })) as typeof podcast; + } catch { + // Fall through to 404 if draft fetch fails + } +} if (!podcast?._id) { return new Response(null, { status: 404 }); } +const isDraftPreview = + wantsDraftPreview || String(podcast._id).startsWith("drafts."); const candidates = ((await sanity.loadQuery(relatedCandidatesQuery, { skip: podcast._id, @@ -151,6 +165,28 @@ const faqNode = faqSchema(extractFaqsFromPortableText(podcast.content));

+ {isDraftPreview && ( +
+
+ + ๐Ÿ‘๏ธ Draft Episode Preview + + + Viewing unpublished Sanity draft ({(podcast as any).workflowStage || "in post-production"}). Review show notes, unlisted rough cut, and speaker links before release. + +
+ {podcast.youtube && ( + + โ–ถ Open Unlisted YouTube Video ↗ + + )} +
+ )} {/* Episode Badge & Matched Vendor / Topic Pills */}
diff --git a/apps/site/src/pages/portal/[token].astro b/apps/site/src/pages/portal/[token].astro index 5e671003..316df5cf 100644 --- a/apps/site/src/pages/portal/[token].astro +++ b/apps/site/src/pages/portal/[token].astro @@ -99,6 +99,16 @@ interface LeadPortalData { bookingDocId?: string; bookingStatus?: string; bookingSlotStartAt?: string; + previousSlotStartAt?: string; + rescheduleReason?: string; + podcastSlug?: string; + podcastDocId?: string; + podcastTitle?: string; + podcastExcerpt?: string; + podcastWorkflowStage?: string; + roughCutUrl?: string; + roughCutNotes?: string; + targetPublishDate?: string; appointmentScheduleUrl?: string; recordingUrl?: string; preMeetingStatus?: string; @@ -383,10 +393,65 @@ try { bookingDoc?.bookingSource === "passionfroot" || Boolean(token?.startsWith("spt_pf_")); const resolvedBookingStatus = bookingDoc?.status; + const isPostProd = [ + "recorded", + "editing", + "guest_review", + "ready_to_publish", + "published", + ].includes(resolvedBookingStatus || ""); const isRecordingApproved = resolvedBookingStatus === "approved" || resolvedBookingStatus === "scheduled" || - resolvedBookingStatus === "confirmed"; + resolvedBookingStatus === "confirmed" || + isPostProd; + + const candidatePodcastSlug = + bookingDoc?.podcastSlug || + (bookingDoc?.topics + ? slugifyGuestName( + `${String(bookingDoc.topics).split("\n")[0].replace(/^Topic:\s*/i, "")} with ${resolvedGuestName}`, + ) + : ""); + const linkedPodcastDoc = + isPostProd || bookingDoc?.podcastSlug || bookingDoc?.podcastDocId + ? await client + .fetch<{ + _id: string; + title?: string; + slug?: { current?: string }; + excerpt?: string; + workflowStage?: string; + youtube?: string; + roughCutNotes?: string; + date?: string; + } | null>( + `*[_type == "podcast" && ( + _id in [$docId, $draftId, $pubId] || + slug.current == $podcastSlug || + bookingRef == $bookingId + )] | order(_updatedAt desc)[0]{ + _id, + title, + slug, + excerpt, + workflowStage, + youtube, + roughCutNotes, + date + }`, + { + docId: + bookingDoc?.podcastDocId || + `drafts.podcast-${candidatePodcastSlug}`, + draftId: `drafts.podcast-${candidatePodcastSlug}`, + pubId: `podcast-${candidatePodcastSlug}`, + podcastSlug: candidatePodcastSlug, + bookingId: bookingDoc?._id || "", + }, + ) + .catch(() => null) + : null; portalData = { _id: doc?._id || bookingDoc?._id || `private.sponsorLead-${token}`, @@ -441,6 +506,26 @@ try { bookingDocId: bookingDoc?._id, bookingStatus: resolvedBookingStatus, bookingSlotStartAt: bookingDoc?.slotStartAt, + previousSlotStartAt: bookingDoc?.previousSlotStartAt, + rescheduleReason: bookingDoc?.rescheduleReason, + podcastSlug: + linkedPodcastDoc?.slug?.current || + bookingDoc?.podcastSlug || + (isPostProd ? candidatePodcastSlug : undefined), + podcastDocId: linkedPodcastDoc?._id || bookingDoc?.podcastDocId, + podcastTitle: + linkedPodcastDoc?.title || + (bookingDoc?.topics + ? `${String(bookingDoc.topics).split("\n")[0].replace(/^Topic:\s*/i, "").trim()} with ${resolvedGuestName}` + : undefined), + podcastExcerpt: linkedPodcastDoc?.excerpt, + podcastWorkflowStage: + linkedPodcastDoc?.workflowStage || resolvedBookingStatus, + roughCutUrl: bookingDoc?.roughCutUrl || linkedPodcastDoc?.youtube, + roughCutNotes: + linkedPodcastDoc?.roughCutNotes || bookingDoc?.roughCutNotes, + targetPublishDate: + bookingDoc?.targetPublishDate || linkedPodcastDoc?.date, appointmentScheduleUrl: bookingDoc?.appointmentScheduleUrl, recordingUrl: isRecordingApproved ? bookingDoc?.recordingUrl || "https://streamyard.com/c4kv26is93" @@ -579,14 +664,73 @@ const recordingSlotDetails = formatSessionDetails( portalData.bookingSlotStartAt, 45, ); +const previousRecordingSlotDetails = formatSessionDetails( + portalData.previousSlotStartAt, + 45, +); const preMeetingSlotDetails = formatSessionDetails( portalData.preMeetingSlotStartAt, 15, ); +const isNeedsRescheduleStatus = + portalData.bookingStatus === "needs_reschedule"; +const isPostProductionStatus = [ + "recorded", + "editing", + "guest_review", + "ready_to_publish", + "published", +].includes(portalData.bookingStatus || ""); const isRecordingApprovedStatus = portalData.bookingStatus === "approved" || portalData.bookingStatus === "scheduled" || - portalData.bookingStatus === "confirmed"; + portalData.bookingStatus === "confirmed" || + isPostProductionStatus; +const isPreMeetingCompleted = + portalData.preMeetingStatus === "completed" || + (Boolean(preMeetingSlotDetails?.isPast) && + portalData.preMeetingStatus === "scheduled"); + +const recordingCardBadgeLabel = isNeedsRescheduleStatus + ? "๐Ÿ”„ Needs Reschedule ยท Pick New Time" + : portalData.bookingStatus === "recorded" + ? "๐ŸŽฌ Recorded ยท Entering Post-Production" + : portalData.bookingStatus === "editing" + ? "โœ‚๏ธ In Editing / Post-Production" + : portalData.bookingStatus === "guest_review" + ? "๐Ÿ‘€ Rough Cut Ready for Review" + : portalData.bookingStatus === "ready_to_publish" + ? "๐Ÿš€ Approved & Queued for Release" + : portalData.bookingStatus === "published" + ? "โœ… Episode Published" + : !recordingSlotDetails + ? "โš ๏ธ No Slot Selected" + : isRecordingApprovedStatus + ? "โœ“ Confirmed & Approved" + : "โณ Hold Reserved (Pending Approval)"; + +function getYouTubeEmbedUrl(rawUrl?: string | null): string | null { + if (!rawUrl) return null; + const m = rawUrl.match( + /(?:youtube\.com\/(?:watch\?v=|embed\/|live\/|shorts\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/, + ); + return m?.[1] ? `https://www.youtube.com/embed/${m[1]}` : null; +} + +const roughCutEmbedUrl = getYouTubeEmbedUrl(portalData.roughCutUrl); +const postProductionStages = [ + { id: "recorded", label: "1. Recorded", icon: "๐ŸŽฌ" }, + { id: "editing", label: "2. In Editing", icon: "โœ‚๏ธ" }, + { id: "guest_review", label: "3. Rough-Cut Review", icon: "๐Ÿ‘€" }, + { id: "ready_to_publish", label: "4. Ready to Publish", icon: "๐Ÿš€" }, + { id: "published", label: "5. Published", icon: "โœ…" }, +] as const; +const activeStageId = + portalData.podcastWorkflowStage || portalData.bookingStatus || "recorded"; +const currentStageIndex = Math.max( + 0, + postProductionStages.findIndex((s) => s.id === activeStageId), +); const pageTitle = !portalFound ? "Studio Portal Not Found | CodingCat.dev" @@ -914,20 +1058,53 @@ const pageDescription = !portalFound
+ {isNeedsRescheduleStatus && ( +
+
+
+ + ๐Ÿ”„ Action Needed · Pick Your New Recording Slot + + {previousRecordingSlotDetails && ( + + Previous slot cleared: {previousRecordingSlotDetails.shortSummaryEt} + + )} +
+

+ {portalData.rescheduleReason || + "Whenever you are ready, pick a new conflict-free 45-minute StreamYard recording slot below."} +

+

+ Selecting a new time below automatically updates our studio calendar and sends a fresh Google Calendar invite with your StreamYard link. +

+
+ +
+ )} +
- ๐ŸŽ™๏ธ + {isPostProductionStatus ? "๐ŸŽฌ" : "๐ŸŽ™๏ธ"}
@@ -940,18 +1117,16 @@ const pageDescription = !portalFound
- {!recordingSlotDetails - ? "โš ๏ธ No Slot Selected" - : isRecordingApprovedStatus - ? "โœ“ Confirmed & Approved" - : "โณ Hold Reserved (Pending Approval)"} + {recordingCardBadgeLabel}
@@ -974,19 +1149,26 @@ const pageDescription = !portalFound
- Current Booked Recording Time + {isPostProductionStatus ? "Recorded Session Time" : "Current Booked Recording Time"} - {recordingSlotDetails.isPast ? "โš ๏ธ Past Slot โ€” Rebook If Needed" : "๐Ÿ“… Scheduled"} + {isPostProductionStatus + ? "โœ“ Session Recorded ยท In Post-Production" + : recordingSlotDetails.isPast + ? "โš ๏ธ Past Slot ยท Rebook If Needed" + : "๐Ÿ“… Scheduled"}
- No 45-Minute Recording Slot Selected Yet + {isNeedsRescheduleStatus + ? "Ready to Reschedule: Pick Your New 45-Minute Recording Slot" + : "No 45-Minute Recording Slot Selected Yet"}

- Click Select Recording Time below to pick a conflict-free 45-minute window on Alex Patterson's studio calendar. + {portalData.rescheduleReason + ? `${portalData.rescheduleReason}. Click Select Recording Time below to choose a conflict-free 45-minute window on Alex Patterson's studio calendar.` + : "Click Select Recording Time below to pick a conflict-free 45-minute window on Alex Patterson's studio calendar."}

)} @@ -1029,23 +1215,46 @@ const pageDescription = !portalFound
- {isRecordingApprovedStatus ? ( - - ๐ŸŽ™๏ธ Enter StreamYard Studio ↗ - - ) : null} - + {isPostProductionStatus ? ( + <> + + ๐ŸŽฌ Open Post-Production & Rough-Cut Review ↓ + + {portalData.podcastSlug && ( + + ๐Ÿ‘๏ธ Preview Draft Episode ↗ + + )} + + ) : ( + <> + {isRecordingApprovedStatus && recordingSlotDetails ? ( + + ๐ŸŽ™๏ธ Enter StreamYard Studio ↗ + + ) : null} + + + )}
@@ -1068,7 +1277,7 @@ const pageDescription = !portalFound
- {preMeetingSlotDetails - ? "โœ“ Scheduled on Google Meet" - : "Optional ยท Not Booked"} + {isPreMeetingCompleted + ? "โœ“ Pre-Call Completed" + : preMeetingSlotDetails + ? "โœ“ Scheduled on Google Meet" + : "Optional ยท Not Booked"}
@@ -1120,19 +1331,26 @@ const pageDescription = !portalFound
- Current Booked Pre-Meeting Time + {isPreMeetingCompleted ? "Completed Pre-Meeting Time" : "Current Booked Pre-Meeting Time"} - {preMeetingSlotDetails.isPast ? "โš ๏ธ Past Slot" : "๐Ÿ“… Scheduled"} + {isPreMeetingCompleted + ? "โœ“ Completed" + : preMeetingSlotDetails.isPast + ? "โš ๏ธ Past Slot" + : "๐Ÿ“… Scheduled"}
- No 15-Minute Pre-Meeting Currently Booked + {isPreMeetingCompleted + ? "โœ“ 15-Minute Pre-Call Completed" + : "No 15-Minute Pre-Meeting Currently Booked"}

- Most guests go straight into the 45-minute StreamYard recording (we spend the first 5 minutes doing a relaxed mic & outline check before hitting record). If you want a separate 15-minute Google Meet alignment call beforehand, click below to book one in 1 click. + {isPreMeetingCompleted + ? "Our 15-minute alignment pre-call is complete! You can review our shared pre-call notes below and pick your 45-minute StreamYard recording window on the left card." + : "Most guests go straight into the 45-minute StreamYard recording (we spend the first 5 minutes doing a relaxed mic & outline check before hitting record). If you want a separate 15-minute Google Meet alignment call beforehand, click below to book one in 1 click."}

)} @@ -1177,7 +1399,7 @@ const pageDescription = !portalFound
- {portalData.preMeetingMeetUrl ? ( + {portalData.preMeetingMeetUrl && !isPreMeetingCompleted ? (