calcomblock/web/editor.tsx
Alex Dunmow fe5d11754a feat(editor): expose the captchaEnabled toggle — v2.0.5
The schema declares captchaEnabled (x-editor checkbox) but the custom
MF editor replaces schema-driven rendering and never drew a control for
it, so the captcha gate could not be enabled through the visual editor
at all. Add the switch beside Show Timezone. pnpm build-script approval
for esbuild (package.json onlyBuiltDependencies) so the editor bundle
rebuilds non-interactively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:15:30 +08:00

443 lines
17 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>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label>{t("calcom.editor.captchaEnabled.label", "Enable captcha")}</Label>
<p className="text-xs text-muted-foreground">
{t("calcom.editor.captchaEnabled.help", "Require visitors to solve a privacy-friendly proof-of-work captcha before booking (spam protection).")}
</p>
</div>
<Switch
checked={getBool("captchaEnabled", false)}
onCheckedChange={(v) => handleFieldChange("captchaEnabled", v)}
aria-label={t("calcom.editor.captchaEnabled.label", "Enable captcha")}
/>
</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;