mirror of
https://github.com/hanzoai/cal.git
synced 2026-10-16 20:43:01 +00:00
embed the booker in any page, named by a key, measured as a funnel
A host page gets a booker in two lines: a [data-hanzo-cal] element carrying a
pk_ key and one ~2 kB classic script. Add [data-hanzo-cal-popup] and the same
key opens it over the page instead.
The key is a public selector, not a credential: pk_ + base64url(host/eventSlug),
the same pair the /{host}/{eventSlug} URL already names, so it resolves with no
lookup and no registry and the public config still comes from the event-type
endpoint <Booker> was always calling. Base reserves the hyphenated pk- for
read-only API credentials; this is a different kind of value and is never sent
as one.
Each booker is a frame at the new /embed route, which is what keeps a host's CSS
out — reusing the same <Booker> and the same /v1/calendar calls as the full-page
flow, which is untouched. What a frame cannot do is size itself, so /embed posts
its height and the loader applies it; the same channel carries close, because
Escape pressed inside a frame never reaches the page around it.
Every surface that shows a booker now reports reaching a visitor and creating a
booking, distinguished by one `surface` property: page, hero, inline, popup.
@hanzo/event is the fleet's client tracker, and its taxonomy is closed on
purpose — a dimension is a property, never a new event name — so the funnel
rides the conversion names qualified by kind:'booking', which also keeps free
intro calls out of the Sale goal (order_completed{kind:'plan'}). A key that
names no calendar is not a funnel step but a host misconfiguration, so it goes
to the error plane where someone is watching.
Only the two public identifiers ride along. Never the booking uid: that is the
capability that reveals the meeting location and cancels the booking. DNT and
GPC are honoured because the SDK reads neither, and a booker sitting in someone
else's page should be the polite one. Interior steps stay unreported —
<Booker>'s only lifecycle prop is onBooked, and slot_selected and
details_entered live in its private state machine; reaching them means one prop
upstream in hanzoai/gui, not a fork of the UI here.
hanzoai/spa refuses framing by default, so the image now sets ALLOW_FRAMING.
tsconfig repeats the Vite alias for hanzogui, without which the typecheck this
adds cannot resolve @hanzo/booker's peer.
This commit is contained in:
@@ -31,4 +31,8 @@ ENV ROOT=/public
|
||||
# Allow same-origin + inline bootstrap (React hydration) and connect-src https:
|
||||
# so the browser can reach the Base booking API at cal.hanzo.ai/v1/calendar.
|
||||
ENV HANZO_STATIC_CSP="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https:; frame-src 'self'; media-src 'self' https:"
|
||||
# /embed is meant to be framed by other people's pages, which hanzoai/spa
|
||||
# otherwise refuses with X-Frame-Options: DENY + frame-ancestors 'none'. Nothing
|
||||
# here holds a session or a credential, so there is no clickjacking to steal.
|
||||
ENV ALLOW_FRAMING=true
|
||||
EXPOSE 3000
|
||||
|
||||
+3
-1
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vite build && vite build -c vite.embed.config.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview --host --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -13,6 +14,7 @@
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "6.28.0",
|
||||
"@hanzo/booker": "^0.1.0",
|
||||
"@hanzo/event": "^0.3.21",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"react-native-web": "^0.20.0",
|
||||
"@hanzo/gui": "7.3.0",
|
||||
|
||||
@@ -2,10 +2,12 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { Booking } from "./pages/Booking";
|
||||
import { Confirmation } from "./pages/Confirmation";
|
||||
import { Embed } from "./pages/Embed";
|
||||
import { Home } from "./pages/Home";
|
||||
|
||||
// Routes:
|
||||
// / landing
|
||||
// /embed?pk= the framed Booker a host page embeds
|
||||
// /booking/:uid booking confirmation / manage (more specific — matched first)
|
||||
// /:owner/:slug the public Booker for one event type
|
||||
export function App() {
|
||||
@@ -13,6 +15,7 @@ export function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/embed" element={<Embed />} />
|
||||
<Route path="/booking/:uid" element={<Confirmation />} />
|
||||
<Route path="/:owner/:slug" element={<Booking />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// The booking funnel, on the fleet's analytics plane: @hanzo/event batches to
|
||||
// POST {host}/v1/event. One client for the whole app; AnalyticsProvider in
|
||||
// main.tsx starts it.
|
||||
//
|
||||
// The taxonomy in @hanzo/event is closed on purpose — a dimension is a property,
|
||||
// never a new event name — so the funnel rides the conversion names it shares a
|
||||
// shape with, qualified by kind:'booking'. That keeps free intro calls out of
|
||||
// the Sale goal, which reads order_completed{kind:'plan'}.
|
||||
//
|
||||
// booker presented -> pricing_viewed { kind:'booking', surface, host, eventType }
|
||||
// booking created -> order_completed { kind:'booking', surface, host, eventType }
|
||||
//
|
||||
// Only the two public identifiers ride along. Never the booking uid: it is the
|
||||
// capability that reveals the meeting location and cancels the booking.
|
||||
|
||||
import { EVENTS, createAnalytics } from "@hanzo/event";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { Target } from "./pk";
|
||||
|
||||
/** Where the booker was presented to the visitor. */
|
||||
export type Surface = "page" | "hero" | "inline" | "popup";
|
||||
|
||||
// Do-not-track and Global Privacy Control. The SDK reads neither — each surface
|
||||
// decides its own consent, and a booker embedded in someone else's page should
|
||||
// be the polite one.
|
||||
function permitted(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const n = navigator as Navigator & { globalPrivacyControl?: boolean };
|
||||
return n.doNotTrack !== "1" && n.globalPrivacyControl !== true;
|
||||
}
|
||||
|
||||
export const analytics = createAnalytics({
|
||||
product: "cal",
|
||||
host: import.meta.env.VITE_EVENT_HOST || "https://api.hanzo.ai",
|
||||
// A pk- ingest key attributes anonymous writes; without one the plane is
|
||||
// inert, which is the intended default for a fork nobody has keyed yet.
|
||||
ingestKey: import.meta.env.VITE_PUBLISHABLE_KEY || undefined,
|
||||
enabled: permitted(),
|
||||
});
|
||||
|
||||
function step(surface: Surface, t: Target) {
|
||||
return { kind: "booking", surface, host: t.host, eventType: t.eventSlug };
|
||||
}
|
||||
|
||||
/** shown reports that the booker reached the visitor — the top of the funnel. */
|
||||
export function useShown(surface: Surface, t: Target | null): void {
|
||||
const host = t?.host;
|
||||
const eventSlug = t?.eventSlug;
|
||||
useEffect(() => {
|
||||
if (host && eventSlug) {
|
||||
analytics.capture(EVENTS.PRICING_VIEWED, step(surface, { host, eventSlug }));
|
||||
}
|
||||
}, [surface, host, eventSlug]);
|
||||
}
|
||||
|
||||
/** booked reports the conversion. */
|
||||
export function booked(surface: Surface, t: Target): void {
|
||||
analytics.capture(EVENTS.ORDER_COMPLETED, step(surface, t));
|
||||
}
|
||||
|
||||
/** keyRejected reports a host page carrying a key that names nothing. Not a
|
||||
* funnel step — an integration fault, and the booker renders empty until it is
|
||||
* fixed, so it belongs on the error plane where someone is watching. */
|
||||
export function keyRejected(pk: string): void {
|
||||
analytics.captureError(new Error("publishable key names no calendar"), {
|
||||
properties: { pk },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// The host-page loader. One classic script, no framework, no dependencies:
|
||||
//
|
||||
// <div data-hanzo-cal="pk_aGFuem8vaW50cm8"></div>
|
||||
// <button data-hanzo-cal="pk_aGFuem8vaW50cm8" data-hanzo-cal-popup>Book</button>
|
||||
// <script async src="https://cal.hanzo.ai/embed.js"></script>
|
||||
//
|
||||
// Every booker is a frame, so the host's stylesheets cannot reach the UI and
|
||||
// ours cannot reach theirs. What a frame cannot do is size itself, so the
|
||||
// embedded document reports its height and we apply it here.
|
||||
//
|
||||
// Also on window.HanzoCal for pages that build their DOM after load:
|
||||
// HanzoCal.mount(el), HanzoCal.open(pk), HanzoCal.close().
|
||||
|
||||
const KEY = "data-hanzo-cal";
|
||||
const POPUP = "data-hanzo-cal-popup";
|
||||
const MOUNTED = "data-hanzo-cal-mounted";
|
||||
|
||||
type Style = Record<string, string>;
|
||||
|
||||
const FRAME: Style = {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
border: "0",
|
||||
background: "transparent",
|
||||
};
|
||||
const INLINE: Style = { ...FRAME, height: "640px" };
|
||||
const MODAL: Style = {
|
||||
...FRAME,
|
||||
"max-width": "900px",
|
||||
height: "90vh",
|
||||
"max-height": "760px",
|
||||
"border-radius": "12px",
|
||||
};
|
||||
const SCRIM: Style = {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
"z-index": "2147483647",
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
padding: "16px",
|
||||
background: "rgba(0,0,0,.6)",
|
||||
};
|
||||
|
||||
// `all:initial` drops everything the element was inheriting, and !important on
|
||||
// an inline declaration is the one thing a host's `iframe { border: … }` cannot
|
||||
// outrank — including their own !important.
|
||||
function apply(el: HTMLElement, style: Style): void {
|
||||
el.style.setProperty("all", "initial", "important");
|
||||
for (const k in style) el.style.setProperty(k, style[k], "important");
|
||||
}
|
||||
|
||||
// Where this script came from is where the booker is served from, so one build
|
||||
// works on any deployment.
|
||||
const origin = new URL(
|
||||
(document.currentScript as HTMLScriptElement | null)?.src || location.href
|
||||
).origin;
|
||||
|
||||
const inline: HTMLIFrameElement[] = [];
|
||||
let scrim: HTMLElement | null = null;
|
||||
|
||||
function frame(pk: string, surface: "inline" | "popup", style: Style): HTMLIFrameElement {
|
||||
const el = document.createElement("iframe");
|
||||
el.src = `${origin}/embed?pk=${encodeURIComponent(pk)}&surface=${surface}`;
|
||||
el.title = "Book a time";
|
||||
apply(el, style);
|
||||
return el;
|
||||
}
|
||||
|
||||
/** mount turns one [data-hanzo-cal] element into a booker — inline by default,
|
||||
* or a click that opens the popup when it also carries [data-hanzo-cal-popup]. */
|
||||
export function mount(el: Element): void {
|
||||
const pk = el.getAttribute(KEY);
|
||||
if (!pk || el.hasAttribute(MOUNTED)) return;
|
||||
el.setAttribute(MOUNTED, "");
|
||||
|
||||
if (el.hasAttribute(POPUP)) {
|
||||
el.addEventListener("click", () => open(pk));
|
||||
return;
|
||||
}
|
||||
const f = frame(pk, "inline", INLINE);
|
||||
el.appendChild(f);
|
||||
inline.push(f);
|
||||
}
|
||||
|
||||
/** open shows the booker over the page. */
|
||||
export function open(pk: string): void {
|
||||
close();
|
||||
const root = document.createElement("div");
|
||||
apply(root, SCRIM);
|
||||
root.appendChild(frame(pk, "popup", MODAL));
|
||||
root.addEventListener("click", (e) => {
|
||||
if (e.target === root) close();
|
||||
});
|
||||
document.body.appendChild(root);
|
||||
document.addEventListener("keydown", onKey);
|
||||
scrim = root;
|
||||
}
|
||||
|
||||
/** close dismisses the popup. */
|
||||
export function close(): void {
|
||||
if (!scrim) return;
|
||||
scrim.remove();
|
||||
scrim = null;
|
||||
document.removeEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === "Escape") close();
|
||||
}
|
||||
|
||||
// The embedded document is the only thing we listen to, and only for the two
|
||||
// things it is allowed to ask for.
|
||||
window.addEventListener("message", (e: MessageEvent) => {
|
||||
if (e.origin !== origin) return;
|
||||
const msg = e.data as { source?: string; type?: string; height?: number } | null;
|
||||
if (!msg || msg.source !== "hanzo-cal") return;
|
||||
|
||||
if (msg.type === "close") {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (msg.type === "height" && typeof msg.height === "number") {
|
||||
const f = inline.find((x) => x.contentWindow === e.source);
|
||||
if (f) f.style.setProperty("height", `${msg.height}px`, "important");
|
||||
}
|
||||
});
|
||||
|
||||
function scan(): void {
|
||||
document.querySelectorAll(`[${KEY}]`).forEach(mount);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", scan);
|
||||
} else {
|
||||
scan();
|
||||
}
|
||||
+7
-3
@@ -1,17 +1,21 @@
|
||||
import "@fontsource-variable/geist";
|
||||
import "@fontsource-variable/geist-mono";
|
||||
|
||||
import { AnalyticsProvider } from "@hanzo/event/react";
|
||||
import { GuiProvider } from "hanzogui";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import { analytics } from "./analytics";
|
||||
import { config } from "./gui";
|
||||
import "./styles.css";
|
||||
|
||||
// No StrictMode: its double-invoke trips a Tamagui update-depth loop on the
|
||||
// marketing page's embedded booker. The Booker itself is idempotent in prod.
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<GuiProvider config={config} defaultTheme="dark">
|
||||
<App />
|
||||
</GuiProvider>
|
||||
<AnalyticsProvider client={analytics}>
|
||||
<GuiProvider config={config} defaultTheme="dark">
|
||||
<App />
|
||||
</GuiProvider>
|
||||
</AnalyticsProvider>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Booker } from "@hanzo/booker";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { calendarApi } from "../api";
|
||||
import { booked, useShown } from "../analytics";
|
||||
|
||||
// The public booking page: the native Hanzo <Booker> (universal Tamagui, on
|
||||
// @hanzo/booker) for /{owner}/{slug}, pointed at the native /v1/calendar backend.
|
||||
@@ -9,6 +10,9 @@ import { calendarApi } from "../api";
|
||||
export function Booking() {
|
||||
const { owner = "", slug = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const target = { host: owner, eventSlug: slug };
|
||||
|
||||
useShown("page", target);
|
||||
|
||||
return (
|
||||
<Booker
|
||||
@@ -16,6 +20,7 @@ export function Booking() {
|
||||
eventSlug={slug}
|
||||
apiUrl={calendarApi}
|
||||
onBooked={(booking) => {
|
||||
booked("page", target);
|
||||
if (booking?.uid) navigate(`/booking/${booking.uid}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Booker } from "@hanzo/booker";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { calendarApi } from "../api";
|
||||
import { booked, keyRejected, useShown, type Surface } from "../analytics";
|
||||
import { resolve } from "../pk";
|
||||
|
||||
// The document a host page frames: the same <Booker> against the same
|
||||
// /v1/calendar backend, with nothing else on the page. The publishable key in
|
||||
// the query names the calendar; the frame is what keeps the host's CSS out.
|
||||
export function Embed() {
|
||||
const [params] = useSearchParams();
|
||||
const pk = params.get("pk") ?? "";
|
||||
const surface: Surface = params.get("surface") === "popup" ? "popup" : "inline";
|
||||
const target = resolve(pk);
|
||||
|
||||
useTransparentPage();
|
||||
usePostedHeight();
|
||||
useEscapeToClose();
|
||||
useShown(surface, target);
|
||||
|
||||
useEffect(() => {
|
||||
if (!target) keyRejected(pk);
|
||||
}, [target, pk]);
|
||||
|
||||
if (!target) {
|
||||
return (
|
||||
<main className="embed">
|
||||
<p className="muted">This booking key doesn't name a calendar.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="embed">
|
||||
<Booker
|
||||
host={target.host}
|
||||
eventSlug={target.eventSlug}
|
||||
apiUrl={calendarApi}
|
||||
onBooked={() => booked(surface, target)}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// An embedded document paints no background of its own, so the booker sits on
|
||||
// whatever the host page already has.
|
||||
function useTransparentPage() {
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add("framed");
|
||||
return () => document.documentElement.classList.remove("framed");
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Escape pressed inside a frame never reaches the page around it, so a visitor
|
||||
// typing in the form would have no way out of the popup. Pass it up; the loader
|
||||
// ignores it unless a popup is open.
|
||||
function useEscapeToClose() {
|
||||
useEffect(() => {
|
||||
if (window.parent === window) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
window.parent.postMessage({ source: "hanzo-cal", type: "close" }, "*");
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
}
|
||||
|
||||
// An iframe cannot size itself to its content, so the content reports its
|
||||
// height and the loader on the host page applies it.
|
||||
function usePostedHeight() {
|
||||
const last = useRef(0);
|
||||
useEffect(() => {
|
||||
if (window.parent === window) return;
|
||||
const post = () => {
|
||||
const height = Math.ceil(document.documentElement.scrollHeight);
|
||||
if (height === last.current) return;
|
||||
last.current = height;
|
||||
window.parent.postMessage({ source: "hanzo-cal", type: "height", height }, "*");
|
||||
};
|
||||
const observer = new ResizeObserver(post);
|
||||
observer.observe(document.documentElement);
|
||||
post();
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}
|
||||
+22
-2
@@ -2,11 +2,17 @@ import { Booker } from "@hanzo/booker";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { calendarApi } from "../api";
|
||||
import { booked, useShown } from "../analytics";
|
||||
import { mint } from "../pk";
|
||||
|
||||
// hanzo.id is the IAM login. Creating/managing your own calendars is auth-gated,
|
||||
// so both "Sign in" and "Create your calendar" enter through it.
|
||||
const LOGIN_URL = "https://hanzo.id";
|
||||
|
||||
// The calendar the landing page books into, and the one it hands out an embed
|
||||
// key for — the same pair, so the snippet below always matches what is shown.
|
||||
const HERO = { host: "hanzo", eventSlug: "intro" };
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
title: "Native, not glued together",
|
||||
@@ -32,6 +38,12 @@ const FEATURES = [
|
||||
export function Home() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useShown("hero", HERO);
|
||||
|
||||
const snippet =
|
||||
`<div data-hanzo-cal="${mint(HERO)}"></div>\n` +
|
||||
`<script async src="${location.origin}/embed.js"></script>`;
|
||||
|
||||
return (
|
||||
<main className="landing">
|
||||
<header className="nav">
|
||||
@@ -60,16 +72,24 @@ export function Home() {
|
||||
<section className="booker-stage">
|
||||
<div className="booker-frame">
|
||||
<Booker
|
||||
host="hanzo"
|
||||
eventSlug="intro"
|
||||
host={HERO.host}
|
||||
eventSlug={HERO.eventSlug}
|
||||
apiUrl={calendarApi}
|
||||
onBooked={(booking) => {
|
||||
booked("hero", HERO);
|
||||
if (booking?.uid) navigate(`/booking/${booking.uid}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="embed-hint">
|
||||
<p className="muted">Put this booker on your own page — two lines, no account:</p>
|
||||
<pre className="snippet">
|
||||
<code>{snippet}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section className="features">
|
||||
{FEATURES.map((f) => (
|
||||
<div className="feature" key={f.title}>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// The publishable key: a PUBLIC selector naming which calendar and which event
|
||||
// type an embed books against. It is not a credential — it authorizes nothing,
|
||||
// carries no identity, and is safe to sit in a host page's HTML, exactly like
|
||||
// the /{host}/{eventSlug} path it stands for.
|
||||
//
|
||||
// pk_<base64url("<host>/<eventSlug>")> hanzo/intro -> pk_aGFuem8vaW50cm8
|
||||
//
|
||||
// Self-describing, so a key resolves with no lookup and no registry; the public
|
||||
// config it names is then read from Base at
|
||||
// GET /v1/calendar/atoms/event-types/{eventSlug}/public?username={host}, which
|
||||
// <Booker> already calls. Base reserves the hyphenated `pk-` for read-only API
|
||||
// credentials — a different kind of value, and this one is never sent as one.
|
||||
|
||||
/** The calendar and event type a key names — <Booker>'s own props. */
|
||||
export interface Target {
|
||||
/** Calendar owner handle: Base's public booking identifier for a host. */
|
||||
host: string;
|
||||
/** Event-type slug. */
|
||||
eventSlug: string;
|
||||
}
|
||||
|
||||
const PREFIX = "pk_";
|
||||
|
||||
// Handles and slugs are URL segments. Anything else in a decoded key is junk
|
||||
// from a mistyped host page, not a calendar we could ever resolve.
|
||||
const SEGMENT = /^[\w.-]{1,64}$/;
|
||||
|
||||
/** mint returns the key that names a target. */
|
||||
export function mint(t: Target): string {
|
||||
return PREFIX + b64url(`${t.host}/${t.eventSlug}`);
|
||||
}
|
||||
|
||||
/** resolve reads a key back, or null when it names nothing bookable. */
|
||||
export function resolve(pk: string): Target | null {
|
||||
if (!pk.startsWith(PREFIX)) return null;
|
||||
const plain = unb64url(pk.slice(PREFIX.length));
|
||||
if (plain === null) return null;
|
||||
const [host, eventSlug, ...rest] = plain.split("/");
|
||||
if (rest.length || !SEGMENT.test(host ?? "") || !SEGMENT.test(eventSlug ?? "")) return null;
|
||||
return { host, eventSlug };
|
||||
}
|
||||
|
||||
function b64url(s: string): string {
|
||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function unb64url(s: string): string | null {
|
||||
try {
|
||||
const b64 = s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "=");
|
||||
return atob(b64);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -305,6 +305,38 @@ code {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The copy-paste embed snippet under the landing booker. */
|
||||
.embed-hint {
|
||||
margin: 0 0 56px;
|
||||
text-align: center;
|
||||
}
|
||||
.embed-hint .muted {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.snippet {
|
||||
margin: 0;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
overflow-x: auto;
|
||||
text-align: left;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Framed in someone else's page: paint no background of our own, and give the
|
||||
booker the whole document. */
|
||||
html.framed,
|
||||
html.framed body {
|
||||
background: transparent;
|
||||
}
|
||||
.embed {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* FORCE GEIST — Tamagui/hanzogui set font-family inline, so override it. */
|
||||
#root, #root * { font-family: var(--sans) !important; }
|
||||
code, .hero-note code, .foot code { font-family: var(--mono) !important; }
|
||||
|
||||
+4
-1
@@ -6,6 +6,9 @@
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
// @hanzo/booker imports its peer under the name `hanzogui`, which the Vite
|
||||
// build aliases to @hanzo/gui. Say the same thing to the type checker.
|
||||
"paths": { "hanzogui": ["./node_modules/@hanzo/gui"] },
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
@@ -16,5 +19,5 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "../packages/calendar/src"]
|
||||
"include": ["src", "vite.config.ts", "vite.embed.config.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// The host-page loader ships as one classic script at a stable URL, /embed.js,
|
||||
// so it builds on its own: no React, no hashed chunk, nothing a third party has
|
||||
// to know the name of. It lands in the SPA's dist, so it runs after the SPA
|
||||
// build and must not empty the directory.
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: false,
|
||||
target: "es2018",
|
||||
lib: {
|
||||
entry: "src/embed.ts",
|
||||
formats: ["iife"],
|
||||
name: "HanzoCal",
|
||||
fileName: () => "embed.js",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "cal.hanzo.ai — Cal.com's Booker (debranded as @hanzo/calendar) over the native Hanzo Base /v1/calendar backend.",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"dev": "pnpm --filter @hanzo/cal-app dev"
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
|
||||
Generated
+19
@@ -19,6 +19,9 @@ importers:
|
||||
'@hanzo/booker':
|
||||
specifier: ^0.1.0
|
||||
version: 0.1.0([email protected]([email protected])([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected](@babel/[email protected])(@react-native/[email protected](@babel/[email protected]))(@types/[email protected])([email protected]))([email protected])([email protected]))([email protected](@babel/[email protected])(@react-native/[email protected](@babel/[email protected]))(@types/[email protected])([email protected]))([email protected])
|
||||
'@hanzo/event':
|
||||
specifier: ^0.3.21
|
||||
version: 0.3.21([email protected])
|
||||
'@hanzo/gui':
|
||||
specifier: 7.3.0
|
||||
version: 7.3.0([email protected])([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected](@babel/[email protected])(@react-native/[email protected](@babel/[email protected]))(@types/[email protected])([email protected]))([email protected])([email protected])
|
||||
@@ -772,6 +775,14 @@ packages:
|
||||
react-native:
|
||||
optional: true
|
||||
|
||||
'@hanzo/[email protected]':
|
||||
resolution: {integrity: sha512-gUEql7ZteyNIkxdX9G44plCNYMNppdU3K9s9uc3IBtwdMjCS6iRM7Z7EP9aOtZm8LqPnAJU9l7IRYYFKhG9cRQ==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@hanzo/[email protected]':
|
||||
resolution: {integrity: sha512-VJw3CocBJztLIil55NpCv2XxL7mIQHebTnhR/R7EDoJ19RUWATqqzn6r+jalPnzRR33D0eUKT7VR0IWz9GhQ8w==}
|
||||
peerDependencies:
|
||||
@@ -2026,10 +2037,12 @@ packages:
|
||||
'@xmldom/[email protected]':
|
||||
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
'@xmldom/[email protected]':
|
||||
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
|
||||
engines: {node: '>=14.6'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
@@ -4544,6 +4557,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react-native: 0.86.0(@babel/[email protected])(@react-native/[email protected](@babel/[email protected]))(@types/[email protected])([email protected])
|
||||
|
||||
'@hanzo/[email protected]([email protected])':
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@hanzo/[email protected]([email protected])([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected](@babel/[email protected])(@react-native/[email protected](@babel/[email protected]))(@types/[email protected])([email protected]))([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@hanzogui/accordion': 7.3.0([email protected])([email protected]([email protected]))([email protected](@babel/[email protected])(@react-native/[email protected](@babel/[email protected]))(@types/[email protected])([email protected]))([email protected])([email protected])
|
||||
@@ -8403,7 +8420,9 @@ snapshots:
|
||||
metro-runtime: 0.84.4
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@react-native/[email protected]': {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user