calcomblock/web/editor.tsx
Alex Dunmow 18b7825592 Extract calcomblock into a standalone wasm plugin repo
Convert the bundled backend/internal/plugins/calcomblock package into a
standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms
import (forbidden in standalone plugins):

- package calcomblock -> package main; add wasip1 main.go with
  wasmguest.Serve(Registration). go.mod module
  git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace
  directives.
- Settings persistence: replace the pool-backed poolQuerier (direct SQL against
  the public-schema `settings` table, which a sandboxed plugin role cannot read)
  with capabilityQuerier over the SDK settings.Settings / settings.Updater
  capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side.
- Vendor the small CMS-internal helpers the plugin used into internal/helpers
  (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier
  settings helpers, PluginCrypto for tests) and internal/db (minimal Setting /
  UpsertSettingParams), each with a provenance header.
- Inline the captcha widget: vendor blocks.CaptchaWidget as a local
  CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the
  block/cms/blocks import from booking.templ.
- blockConfig (server-authoritative captcha requirement via a
  page_block_snapshots scan) has no wasm-ABI capability, so it is left nil:
  honeypot + per-IP rate limit still apply. Documented as a follow-up.
- web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js
  repointed at ../../../cms/web/eslint.config.js; rebuild web/dist.
- Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore.

Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp;
check-safety passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:04:18 +08:00

429 lines
16 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import {
Alert,
AlertDescription,
type BlockEditorProps,
Button,
type ReactNode,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Input,
Label,
Textarea,
Switch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Settings,
Edit3,
CheckCircle2,
XCircle,
Loader2,
Key,
AlertCircle,
ExternalLink,
RefreshCw,
t,
} from "@block-ninja/ui";
type EventTypeSummary = {
id: number;
slug: string;
title: string;
lengthInMinutes: number;
};
function renderConnectedAccount(apiKeyConfigured: boolean | null, calcomUsername: string) {
if (apiKeyConfigured === false) {
return <p className="text-xs text-muted-foreground">{t("calcom.editor.connectedAs.noKey", "Configure the API key in plugin settings to connect an account.")}</p>;
}
if (calcomUsername) {
return <p className="text-sm font-mono">{calcomUsername}</p>;
}
return <p className="text-xs text-muted-foreground">{t("calcom.editor.connectedAs.resolving", "Resolving account from API key…")}</p>;
}
function CalcomBlockEditor({ content, onChange }: BlockEditorProps) {
const [activeTab, setActiveTab] = useState("config");
const [apiKeyConfigured, setApiKeyConfigured] = useState<boolean | null>(null);
const [calcomUsername, setCalcomUsername] = useState<string>("");
const [eventTypes, setEventTypes] = useState<EventTypeSummary[]>([]);
const [loadingEventTypes, setLoadingEventTypes] = useState(false);
const [fetchFailed, setFetchFailed] = useState(false);
const [retryCounter, setRetryCounter] = useState(0);
const handleFieldChange = useCallback(
(field: string, value: unknown) => {
onChange({ ...content, [field]: value });
},
[content, onChange],
);
const getString = (field: string, defaultValue = "") => {
return (content[field] as string) || defaultValue;
};
const getNumber = (field: string, defaultValue = 0) => {
const val = content[field];
if (typeof val === "number") return val;
if (typeof val === "string") return parseInt(val, 10) || defaultValue;
return defaultValue;
};
const getBool = (field: string, defaultValue = false) => {
const val = content[field];
if (typeof val === "boolean") return val;
return defaultValue;
};
const eventTypeSlug = getString("eventTypeSlug");
useEffect(() => {
fetch(`/api/plugins/calcomblock/settings`)
.then((r) => r.json())
.then((d: { api_key_configured?: boolean; username?: string }) => {
setApiKeyConfigured(!!d.api_key_configured);
setCalcomUsername(d.username ?? "");
})
.catch(() => setApiKeyConfigured(false));
}, []);
// Stamp the resolved Cal.com username into block content so the public
// render path (block.go BookingConfig.Username) keeps working without any
// backend rewiring. Existing blocks with a stale username get refreshed
// automatically the next time the editor opens.
useEffect(() => {
if (calcomUsername && content.username !== calcomUsername) {
onChange({ ...content, username: calcomUsername });
}
}, [calcomUsername, content, onChange]);
useEffect(() => {
if (!apiKeyConfigured) {
setEventTypes([]);
setFetchFailed(false);
return;
}
const timer = setTimeout(() => {
setLoadingEventTypes(true);
setFetchFailed(false);
fetch(`/api/plugins/calcomblock/event-types?username=${encodeURIComponent(calcomUsername)}`)
.then((r) => r.json())
.then((d: { success?: boolean; event_types?: EventTypeSummary[] }) => {
if (d.success) {
setEventTypes(d.event_types ?? []);
} else {
setEventTypes([]);
setFetchFailed(true);
}
})
.catch(() => {
setEventTypes([]);
setFetchFailed(true);
})
.finally(() => setLoadingEventTypes(false));
}, 300);
return () => clearTimeout(timer);
}, [apiKeyConfigured, calcomUsername, retryCounter]);
const handleRetry = useCallback(() => {
setRetryCounter((n) => n + 1);
}, []);
const selectPlaceholder = (() => {
if (apiKeyConfigured === false) {
return (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Key className="h-3 w-3" />
{t("calcom.editor.eventType.placeholder.noApiKey", "Configure API key first")}
</span>
);
}
if (loadingEventTypes) {
return (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{t("calcom.editor.eventType.placeholder.loading", "Loading…")}
</span>
);
}
if (!calcomUsername) {
return (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{t("calcom.editor.eventType.placeholder.resolvingUser", "Resolving account…")}
</span>
);
}
if (eventTypes.length === 0) {
return (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<XCircle className="h-3 w-3" />
{t("calcom.editor.eventType.placeholder.noEventTypes", "No event types found")}
</span>
);
}
return t("calcom.editor.eventType.placeholder.select", "Select event type");
})();
const apiKeyReady = apiKeyConfigured === true;
const usernameReady = calcomUsername.length > 0;
const eventTypeReady = eventTypeSlug.length > 0;
const canOpenInCalcom = usernameReady && eventTypeReady;
const renderStatusRow = (ok: boolean, label: string, action?: ReactNode) => (
<div className="flex items-center gap-2 text-sm">
{ok ?
<CheckCircle2 className="h-4 w-4 text-primary" aria-hidden="true" />
: <XCircle className="h-4 w-4 text-destructive" aria-hidden="true" />}
<span className={ok ? "text-foreground" : "text-muted-foreground"}>{label}</span>
{action ?
<span className="ml-auto">{action}</span>
: null}
</div>
);
return (
<div className="space-y-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="config" className="gap-2">
<Settings className="h-4 w-4" />
<span>{t("calcom.editor.tab.configuration", "Configuration")}</span>
</TabsTrigger>
<TabsTrigger value="content" className="gap-2">
<Edit3 className="h-4 w-4" />
<span>{t("calcom.editor.tab.content", "Content")}</span>
</TabsTrigger>
</TabsList>
{/* Configuration Tab */}
<TabsContent value="config" className="space-y-4 mt-4">
{apiKeyConfigured === false && (
<Alert>
<AlertDescription>
{t("calcom.editor.alert.apiKeyMissing.prefix", "Configure your Cal.com API key in")}{" "}
<a href="/admin/plugins?plugin=calcomblock" className="text-primary hover:underline">
{t("calcom.editor.alert.apiKeyMissing.linkLabel", "plugin settings")}
</a>{" "}
{t("calcom.editor.alert.apiKeyMissing.suffix", "to enable the event-type dropdown.")}
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("calcom.editor.section.config.title", "Cal.com Settings")}</CardTitle>
<CardDescription>{t("calcom.editor.section.config.description", "Connect to your Cal.com account and select an event type")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t("calcom.editor.connectedAs.label", "Connected Account")}</Label>
{renderConnectedAccount(apiKeyConfigured, calcomUsername)}
</div>
<div className="space-y-2">
<Label htmlFor="eventTypeSlug">
{t("calcom.editor.eventType.label", "Event Type")} <span className="text-destructive">*</span>
</Label>
{fetchFailed ?
<>
<div className="flex items-center gap-2">
<Input
id="eventTypeSlug"
value={eventTypeSlug}
onChange={(e) => handleFieldChange("eventTypeSlug", e.target.value)}
placeholder={t("calcom.editor.eventType.fallbackPlaceholder", "30min")}
className="flex-1"
/>
<Button
type="button"
size="sm"
variant="outline"
action="retry"
entity="event-types"
onClick={handleRetry}
disabled={loadingEventTypes}
aria-label={t("calcom.editor.eventType.retryAria", "Retry loading event types")}>
<RefreshCw className={`h-4 w-4 ${loadingEventTypes ? "animate-spin" : ""}`} aria-hidden="true" />
<span className="ml-1">{t("calcom.editor.eventType.retryLabel", "Retry")}</span>
</Button>
</div>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" aria-hidden="true" />
<AlertDescription>{t("calcom.editor.eventType.fetchFailed", "Couldn't load event types from Cal.com. Enter the slug manually or retry.")}</AlertDescription>
</Alert>
</>
: <>
<Select
value={eventTypeSlug}
onValueChange={(v) => handleFieldChange("eventTypeSlug", v)}
disabled={loadingEventTypes || !apiKeyConfigured || eventTypes.length === 0}>
<SelectTrigger aria-label={t("calcom.editor.eventType.label", "Event Type")}>
<SelectValue placeholder={selectPlaceholder} />
</SelectTrigger>
<SelectContent>
{eventTypes.map((et) => (
<SelectItem key={et.id} value={et.slug}>
{et.title}
<span className="text-muted-foreground">
{" "}
({et.slug} · {et.lengthInMinutes}m)
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("calcom.editor.eventType.help", "Fetched from your Cal.com account")}</p>
</>
}
</div>
<div className="space-y-2">
<Label htmlFor="weeksToShow">{t("calcom.editor.weeksToShow.label", "Weeks to Display")}</Label>
<Select value={String(getNumber("weeksToShow", 2))} onValueChange={(v) => handleFieldChange("weeksToShow", parseInt(v, 10))}>
<SelectTrigger aria-label={t("calcom.editor.weeksToShow.label", "Weeks to Display")}>
<SelectValue placeholder={t("calcom.editor.weeksToShow.placeholder", "Select weeks")} />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8].map((n) => (
<SelectItem key={n} value={String(n)}>
{n} {n > 1 ? t("calcom.editor.weeksToShow.unitPlural", "weeks") : t("calcom.editor.weeksToShow.unitSingular", "week")}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("calcom.editor.weeksToShow.help", "How many weeks of dates to show in the calendar")}</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label>{t("calcom.editor.showTimezone.label", "Show Timezone Selector")}</Label>
<p className="text-xs text-muted-foreground">
{t("calcom.editor.showTimezone.help", "Show a label above the date grid indicating which timezone times are displayed in.")}
</p>
</div>
<Switch
checked={getBool("showTimezone", true)}
onCheckedChange={(v) => handleFieldChange("showTimezone", v)}
aria-label={t("calcom.editor.showTimezone.label", "Show Timezone Selector")}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("calcom.editor.status.title", "Configuration status")}</CardTitle>
<CardDescription>{t("calcom.editor.status.description", "Quick check of the values needed before this block renders bookings.")}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{renderStatusRow(
apiKeyReady,
t("calcom.editor.status.apiKey", "API key configured"),
!apiKeyReady ?
<a href="/admin/plugins?plugin=calcomblock" className="text-xs text-primary hover:underline">
{t("calcom.editor.status.apiKey.configureLink", "Configure")}
</a>
: null,
)}
{renderStatusRow(usernameReady, t("calcom.editor.status.username", "Account resolved"))}
{renderStatusRow(eventTypeReady, t("calcom.editor.status.eventType", "Event type selected"))}
<div className="flex items-center gap-2 text-sm">
<span className="inline-flex h-4 w-4 items-center justify-center text-muted-foreground">·</span>
<span className="text-muted-foreground">
{t("calcom.editor.status.eventTypesAvailable", "{count} event types available").replace("{count}", String(eventTypes.length))}
</span>
</div>
<div className="pt-2">
<Button
type="button"
size="sm"
variant="outline"
action="open"
entity="calcom-page"
asChild={canOpenInCalcom}
disabled={!canOpenInCalcom}
aria-label={t("calcom.editor.status.openExternalAria", "Open Cal.com booking page in a new tab")}>
{canOpenInCalcom ?
<a href={`https://cal.com/${calcomUsername}/${eventTypeSlug}`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" aria-hidden="true" />
<span className="ml-1">{t("calcom.editor.status.openExternal", "Open in Cal.com")}</span>
</a>
: <>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
<span className="ml-1">{t("calcom.editor.status.openExternal", "Open in Cal.com")}</span>
</>
}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Content Tab */}
<TabsContent value="content" className="space-y-4 mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("calcom.editor.section.content.title", "Widget Content")}</CardTitle>
<CardDescription>{t("calcom.editor.section.content.description", "Customize the text shown in the booking widget")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{t("calcom.editor.title.label", "Title")}</Label>
<Input
id="title"
value={getString("title")}
onChange={(e) => handleFieldChange("title", e.target.value)}
placeholder={t("calcom.editor.title.placeholder", "Book a Meeting")}
/>
<p className="text-xs text-muted-foreground">{t("calcom.editor.title.help", "Main heading shown above the calendar")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("calcom.editor.description.label", "Description")}</Label>
<Textarea
id="description"
value={getString("description")}
onChange={(e) => handleFieldChange("description", e.target.value)}
placeholder={t("calcom.editor.description.placeholder", "Choose a convenient time for your consultation...")}
rows={3}
/>
<p className="text-xs text-muted-foreground">{t("calcom.editor.description.help", "Optional text shown below the title")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="unavailableMessage">{t("calcom.editor.unavailableMessage.label", "Booking unavailable message")}</Label>
<Textarea
id="unavailableMessage"
value={getString("unavailableMessage")}
onChange={(e) => handleFieldChange("unavailableMessage", e.target.value)}
placeholder={t(
"calcom.editor.unavailableMessage.placeholder",
"Sorry — we couldn't complete your booking online. Please use our contact form and we'll get you booked in.",
)}
rows={3}
/>
<p className="text-xs text-muted-foreground">
{t("calcom.editor.unavailableMessage.help", "Shown if an online booking can't be completed. Leave blank to use the default.")}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
export default CalcomBlockEditor;