diff --git a/frontend/editor/.env b/frontend/editor/.env index 961da5d840..264a01e952 100644 --- a/frontend/editor/.env +++ b/frontend/editor/.env @@ -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 diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 20fde0cc3f..d4d41a9257 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/portal/components/procurement/CalendlyInline.tsx b/frontend/editor/src/portal/components/procurement/CalendlyInline.tsx new file mode 100644 index 0000000000..3cac3750fb --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/CalendlyInline.tsx @@ -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(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 ( +

+ {t("portal.procurement.schedule.fallback")}{" "} + + {t("portal.procurement.schedule.fallbackLink")} + +

+ ); + } + + return ( +
+ ); +} diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx index dc4d7cb368..2192d3b2ec 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx @@ -18,6 +18,7 @@ const meta: Meta = { component: DealStatusHero, parameters: { layout: "padded" }, args: { + canSchedule: true, onExpand: () => {}, onKeyDocs: () => {}, onInvite: () => {}, diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx index 730fb768ec..56e9de6d70 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx @@ -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")} )} - + {canSchedule && ( + + )}
diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx index fffc2479be..902bb14c0d 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx @@ -20,6 +20,7 @@ export function ControlledDealStatusHero({ controller.setOpen(true)} onKeyDocs={() => controller.setExtra("docs")} onInvite={() => setActiveView("users")} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx index d1f13ca0ee..3046e895f2 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx @@ -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({ >
void; + /** Linked account's email; prefills the Calendly booking form. */ + email?: string | null; }) { + const { t } = useTranslation(); return ( -
- - SE - -
-
Your solutions engineer
-
- Dedicated to your evaluation and rollout -
-
-
-
- {SLOTS.map((s) => ( - - ))} -
+
); } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx index 011902c35d..9ba3ebeef2 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx @@ -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({ setExtra(null)} + email={scheduleEmail} /> {data && ( (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; +} diff --git a/frontend/editor/src/portal/views/Procurement.css b/frontend/editor/src/portal/views/Procurement.css index 8294709e5d..0f827f9146 100644 --- a/frontend/editor/src/portal/views/Procurement.css +++ b/frontend/editor/src/portal/views/Procurement.css @@ -1243,6 +1243,8 @@ backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: portal-procmodal-fade 0.15s ease-out; + /* Portaled to , 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 ────────────────────────────────────────────── */