feat(portal): wire procurement "Schedule a call" to Calendly (#6920)

## What

The procurement flow's **Schedule a call** action (deal-status hero →
side modal) was a mock: a fake "SE" avatar and four hardcoded time-slot
buttons that just closed the dialog. This wires it up to the real
Calendly booking widget the admin provided.

## How

- **New `CalendlyInline` component**
(`portal/components/procurement/CalendlyInline.tsx`)
- Lazily loads `assets.calendly.com/assets/external/widget.js` via the
existing `@app/utils/scriptLoader` — only when the modal actually opens,
deduped across reopens.
- Calls `Calendly.initInlineWidget()` explicitly so it rebuilds on
reopen / theme change.
- Colours track the portal's light/dark theme (`useTheme`) via
Calendly's `background_color` / `text_color` / `primary_color` params,
mapped to the portal design tokens (surface / text-1 / primary), plus
`hide_event_type_details=1`.
- Graceful fallback to an "open in a new tab" link if the script fails
to load.
- Base URL overridable via `VITE_CALENDLY_URL` (defaults to the
group-discussion link).
- **`ScheduleCallModal`** now renders `<CalendlyInline />` instead of
the mock; copy moved into i18n (`portal.procurement.schedule.*`).
- `SideModal` gains a `wide` variant so the embed has room; removed the
now-dead `.portal-se*` / `.portal-slots*` CSS and `SLOTS` constant.

## Notes / follow-ups

- No app-level CSP blocks `calendly.com`, so the embed loads without
config changes.
- Verified with the portal typecheck (`tsc -p src/portal/tsconfig.json`)
and ESLint on the changed files; only pre-existing Storybook/msw dev-dep
type errors remain.


<img width="1160" height="642" alt="image"
src="https://github.com/user-attachments/assets/d9b5d92d-ea7e-4862-9f35-a71f65392a2c"
/>
<img width="3744" height="1990" alt="image"
src="https://github.com/user-attachments/assets/0b8002c6-dd3e-41e6-8e8b-6faf6090314c"
/>
<img width="2620" height="1928" alt="image"
src="https://github.com/user-attachments/assets/781b7a60-7065-4a7a-b9f7-1cdb0dece7b3"
/>
This commit is contained in:
ConnorYoh
2026-07-08 15:06:55 +00:00
committed by GitHub
parent 514b020f74
commit 1759e0bdd5
10 changed files with 189 additions and 89 deletions
+3
View File
@@ -26,3 +26,6 @@ VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Q56W2P9mY5IAnSnp3kcxG50uyFMLuhM4fFs774DAP3
# PostHog analytics
VITE_PUBLIC_POSTHOG_KEY=phc_VOdeYnlevc2T63m3myFGjeBlRcIusRgmhfx6XL5a1iz
VITE_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
# Calendly scheduling link (portal "Schedule a call")
VITE_CALENDLY_URL=https://calendly.com/d/cm4p-zz5-yy8/stirling-pdf-15-minute-group-discussion
@@ -7660,6 +7660,12 @@ simulate = "Simulate payment received (demo)"
title = "Subscription created"
viewInvoice = "View & pay invoice"
[portal.procurement.schedule]
fallback = "Couldn't load the scheduler."
fallbackLink = "Open scheduling in a new tab"
subtitle = "Your solutions engineer will walk your team through the rollout. Pick a time that suits you."
title = "Schedule a call"
[portal.procurement.status]
action = "Action needed"
available = "Available"
@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { loadScript } from "@app/utils/scriptLoader";
/**
* Inline Calendly scheduler. Lazily loads Calendly's widget.js (only once the embed mounts, i.e. when
* the modal opens) and initialises an inline widget. Falls back to a plain "open in a new tab" link if
* the script can't load (offline, blocked, etc.).
*/
const CALENDLY_SCRIPT = "https://assets.calendly.com/assets/external/widget.js";
// Base scheduling link; overridable per-environment without a code change.
export const CALENDLY_URL: string =
import.meta.env.VITE_CALENDLY_URL ||
"https://calendly.com/d/cm4p-zz5-yy8/stirling-pdf-15-minute-group-discussion";
// Calendly takes bare hex (no leading #). Its embed always renders form inputs on a white background
// regardless of these params, so a dark background_color leaves light input text on white — unreadable.
// We therefore keep the widget on one light, high-contrast palette in both portal themes; it reads as a
// clean card inside the (possibly dark) modal. Only the accent tracks the brand primary.
const WIDGET_COLORS = {
background: "ffffff",
text: "0f172a",
primary: "2383e2",
} as const;
interface CalendlyWindow extends Window {
Calendly?: {
initInlineWidget: (opts: {
url: string;
parentElement: HTMLElement;
prefill?: { name?: string; email?: string };
}) => void;
};
}
function buildUrl(base: string): string {
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}hide_event_type_details=1&background_color=${WIDGET_COLORS.background}&text_color=${WIDGET_COLORS.text}&primary_color=${WIDGET_COLORS.primary}`;
}
export function CalendlyInline({
url = CALENDLY_URL,
height = 760,
email,
}: {
url?: string;
height?: number;
/** Prefills the booking form's email (the linked account's email). */
email?: string | null;
}) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [failed, setFailed] = useState(false);
const fullUrl = buildUrl(url);
useEffect(() => {
let cancelled = false;
setFailed(false);
loadScript({ src: CALENDLY_SCRIPT, id: "calendly-widget-script" })
.then(() => {
const el = containerRef.current;
const calendly = (window as CalendlyWindow).Calendly;
if (cancelled || !el || !calendly) return;
// Re-init explicitly (rather than relying on widget.js auto-scan) so the widget rebuilds on
// reopen and whenever the URL or prefill changes.
el.innerHTML = "";
calendly.initInlineWidget({
url: fullUrl,
parentElement: el,
prefill: email ? { email } : undefined,
});
})
.catch(() => !cancelled && setFailed(true));
return () => {
cancelled = true;
};
}, [fullUrl, email]);
if (failed) {
return (
<p className="portal-sidemodal__text">
{t("portal.procurement.schedule.fallback")}{" "}
<a href={url} target="_blank" rel="noopener noreferrer">
{t("portal.procurement.schedule.fallbackLink")}
</a>
</p>
);
}
return (
<div
ref={containerRef}
className="portal-calendly"
style={{ minWidth: 320, height }}
/>
);
}
@@ -18,6 +18,7 @@ const meta: Meta<typeof DealStatusHero> = {
component: DealStatusHero,
parameters: { layout: "padded" },
args: {
canSchedule: true,
onExpand: () => {},
onKeyDocs: () => {},
onInvite: () => {},
@@ -14,6 +14,7 @@ import "@portal/views/Procurement.css";
export function DealStatusHero({
snapshot,
busy = false,
canSchedule,
onExpand,
onKeyDocs,
onInvite,
@@ -23,6 +24,9 @@ export function DealStatusHero({
}: {
snapshot: ProcurementSnapshot;
busy?: boolean;
/** Booking a call runs through the linked account (its email prefills Calendly), so the
* "Schedule a call" action only appears when the org has linked its account. */
canSchedule: boolean;
onExpand: () => void;
onKeyDocs: () => void;
onInvite: () => void;
@@ -103,13 +107,15 @@ export function DealStatusHero({
{t("portal.procurement.hero.inviteTeammates")}
</button>
)}
<button
type="button"
className="portal-hero__chip portal-hero__chip--action"
onClick={onSchedule}
>
{t("portal.procurement.hero.scheduleCall")}
</button>
{canSchedule && (
<button
type="button"
className="portal-hero__chip portal-hero__chip--action"
onClick={onSchedule}
>
{t("portal.procurement.hero.scheduleCall")}
</button>
)}
</div>
</div>
@@ -20,6 +20,7 @@ export function ControlledDealStatusHero({
<DealStatusHero
snapshot={controller.data}
busy={controller.busy}
canSchedule={controller.isLinked}
onExpand={() => controller.setOpen(true)}
onKeyDocs={() => controller.setExtra("docs")}
onInvite={() => setActiveView("users")}
@@ -3,13 +3,15 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import type { ProcurementSnapshot } from "@portal/api/procurement";
import { CalendlyInline } from "@portal/components/procurement/CalendlyInline";
import { useFocusTrap } from "@portal/components/procurement/ProcurementModal";
import "@portal/views/Procurement.css";
/**
* Small centred dialogs that hang off the deal-status hero's quick actions — Key documents, Schedule
* a call, and trial management. Content is mocked for the pilot (static demo data); the shells and
* wiring are real so the hero behaves like the marketing prototype.
* a call, and trial management. Schedule a call embeds the live Calendly scheduler; Key documents is
* still mocked for the pilot (static demo data). The shells and wiring are real so the hero behaves
* like the marketing prototype.
*/
function SideModal({
@@ -19,6 +21,7 @@ function SideModal({
subtitle,
children,
footer,
wide = false,
}: {
open: boolean;
onClose: () => void;
@@ -26,6 +29,7 @@ function SideModal({
subtitle?: string;
children: React.ReactNode;
footer?: React.ReactNode;
wide?: boolean;
}) {
const { t } = useTranslation();
const trapRef = useFocusTrap(open);
@@ -45,7 +49,7 @@ function SideModal({
>
<div
ref={trapRef}
className="portal-sidemodal__panel"
className={`portal-sidemodal__panel${wide ? " portal-sidemodal__panel--wide" : ""}`}
role="dialog"
aria-modal="true"
tabIndex={-1}
@@ -182,50 +186,26 @@ export function KeyDocumentsModal({
}
// ── Schedule a call ──────────────────────────────────────────────────────────
const SLOTS = [
"Tomorrow · 10:00",
"Tomorrow · 15:30",
"Thursday · 11:00",
"Friday · 09:30",
];
export function ScheduleCallModal({
open,
onClose,
email,
}: {
open: boolean;
onClose: () => void;
/** Linked account's email; prefills the Calendly booking form. */
email?: string | null;
}) {
const { t } = useTranslation();
return (
<SideModal
open={open}
onClose={onClose}
title="Schedule a call"
subtitle="Your solutions engineer will walk your team through the rollout."
title={t("portal.procurement.schedule.title")}
subtitle={t("portal.procurement.schedule.subtitle")}
wide
>
<div className="portal-se">
<span className="portal-se__avatar" aria-hidden>
SE
</span>
<div>
<div className="portal-se__name">Your solutions engineer</div>
<div className="portal-se__role">
Dedicated to your evaluation and rollout
</div>
</div>
</div>
<div className="portal-slots">
{SLOTS.map((s) => (
<button
key={s}
type="button"
className="portal-slots__slot"
onClick={onClose}
>
{s}
</button>
))}
</div>
<CalendlyInline email={email} />
</SideModal>
);
}
@@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import { Banner, Button, EmptyState, Skeleton } from "@app/ui";
import { useUI } from "@portal/contexts/UIContext";
import { useLinkedAccountEmail } from "@portal/hooks/useLinkedAccountEmail";
import { JOURNEY } from "@portal/api/procurement";
import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
import {
@@ -32,6 +33,7 @@ export function ProcurementFlow({
}) {
const { t } = useTranslation();
const { openLinkModal } = useUI();
const scheduleEmail = useLinkedAccountEmail();
const {
isLinked,
loading,
@@ -159,6 +161,7 @@ export function ProcurementFlow({
<ScheduleCallModal
open={extra === "schedule"}
onClose={() => setExtra(null)}
email={scheduleEmail}
/>
{data && (
<TrialManageModal
@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
/**
* The email of the linked SaaS account, read from the in-app SaaS Supabase
* session (the same session the account-link flow establishes). Null when there
* is no SaaS session — not linked, SaaS Supabase unconfigured, or the attended
* session has expired — in which case callers treat it as "prefill unavailable"
* and degrade gracefully.
*
* Deliberately does NOT depend on LinkContext: the SaaS flavor has no
* LinkProvider (see usePortalLinked's SaaS shadow), and this hook is reached from
* shared procurement code compiled into every flavor.
*/
export function useLinkedAccountEmail(): string | null {
const [email, setEmail] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const supabase = ensureSaasSupabase();
if (!supabase) {
setEmail(null);
return;
}
void supabase.auth.getSession().then(({ data }) => {
if (!cancelled) setEmail(data.session?.user?.email ?? null);
});
return () => {
cancelled = true;
};
}, []);
return email;
}
@@ -1243,6 +1243,8 @@
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation: portal-procmodal-fade 0.15s ease-out;
/* Portaled to <body>, outside .portal-scope, so set the portal UI font explicitly. */
font-family: var(--font-sans);
}
.portal-sidemodal__panel {
position: relative;
@@ -1256,6 +1258,11 @@
max-height: 86vh;
overflow-y: auto;
}
/* Wide enough for Calendly's two-pane layout (its single-column layout below ~680px inner width is
tall and scrolls); paired with the embed's taller fixed height so the time view needs no scroll. */
.portal-sidemodal__panel--wide {
max-width: 52rem;
}
.portal-sidemodal__header {
margin-bottom: 1rem;
padding-right: 2rem;
@@ -1355,53 +1362,12 @@
color: var(--color-text-5);
}
/* Solutions-engineer + time slots (Schedule a call). */
.portal-se {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.portal-se__avatar {
width: 2.4rem;
height: 2.4rem;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
color: var(--color-primary, #2383e2);
background: var(--color-primary-light, #eaf2fb);
flex-shrink: 0;
}
.portal-se__name {
font-size: 0.875rem;
font-weight: 650;
color: var(--color-text-1);
}
.portal-se__role {
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-slots {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.portal-slots__slot {
padding: 0.6rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 9px;
cursor: pointer;
}
.portal-slots__slot:hover {
border-color: var(--color-primary, #2383e2);
color: var(--color-primary, #2383e2);
/* Calendly scheduler embed (Schedule a call). The widget is always light (Calendly renders inputs on
white), so give it a white surface — it reads as a clean card even inside the dark-mode modal. */
.portal-calendly {
border-radius: 10px;
overflow: hidden;
background: #ffffff;
}
/* ── Agreement (security) step ────────────────────────────────────────────── */