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>
443 lines
17 KiB
TypeScript
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;
|