mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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 ────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user