mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(calendar): read-only ICS feeds for tenant and staff
GET /v1/public/calendar/{token} serves an RFC 5545 calendar under a
token-as-secret URL (private Google/Outlook link model). Staff feeds
contain only that member's bookings; the tenant feed tags each event
with the staff member. Window is -30d to +180d; cancelled bookings map
to STATUS:CANCELLED, waitlisted/pending to TENTATIVE + TRANSPARENT.
Summaries resolve service or class-session titles, descriptions carry
localized contact labels, locations resolve to names.
Feed tokens live on tenants/staff (migration 00015) and are managed via
GET /v1/calendar/feed plus POST /v1/calendar/feed/rotate — rotating a
scope invalidates the old URL. ICS rendering is hand-rolled: TEXT
escaping, CRLF, 75-octet folding on rune boundaries.
Settings gains a "Kalendář (ICS)" card with copyable URLs and two-step
rotation per scope; demo mode points at the real seeded tokens.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
796c06ae66
commit
6c3b2bf6bf
14 files changed
+1354
-3
No files matched your search
@@ -0,0 +1,197 @@
|
||||
import { createResource, createSignal, Show, For, Accessor } from "solid-js";
|
||||
import { apiClient, apiBaseUrl } from "../lib/api-client";
|
||||
import { useI18n } from "../providers/i18n-provider";
|
||||
import { CalendarDaysIcon } from "./dashboard/icons";
|
||||
|
||||
interface FeedEntry {
|
||||
token: string;
|
||||
feedPath: string;
|
||||
}
|
||||
|
||||
interface StaffFeedEntry {
|
||||
staffId: string;
|
||||
displayName: string;
|
||||
token: string;
|
||||
feedPath: string;
|
||||
}
|
||||
|
||||
interface FeedInfo {
|
||||
tenant: FeedEntry;
|
||||
staff: StaffFeedEntry[];
|
||||
}
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RotateIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface CalendarFeedProps {
|
||||
token: Accessor<string | null | undefined>;
|
||||
onDemoNotice?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function CalendarFeed(props: CalendarFeedProps) {
|
||||
const i18n = useI18n();
|
||||
const token = props.token;
|
||||
const cs = () => i18n.locale() === "cs";
|
||||
|
||||
const [copiedKey, setCopiedKey] = createSignal<string | null>(null);
|
||||
const [armedRotate, setArmedRotate] = createSignal<string | null>(null);
|
||||
const [rotating, setRotating] = createSignal(false);
|
||||
const [notice, setNotice] = createSignal<string | null>(null);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
const [feedInfo, { refetch }] = createResource(token, async (bearer) => {
|
||||
if (!bearer || bearer.startsWith("demo.")) {
|
||||
return {
|
||||
tenant: { token: "demo-tenant-feed-token", feedPath: "/v1/public/calendar/demo-tenant-feed-token.ics" },
|
||||
staff: [{ staffId: "demo-staff", displayName: "Demo Stylist", token: "demo-staff-feed-token", feedPath: "/v1/public/calendar/demo-staff-feed-token.ics" }],
|
||||
} as FeedInfo;
|
||||
}
|
||||
const response = await (apiClient as any).GET("/v1/calendar/feed", {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
});
|
||||
return response.data as FeedInfo;
|
||||
});
|
||||
|
||||
const feedUrl = (entry: FeedEntry) => `${apiBaseUrl}${entry.feedPath}`;
|
||||
|
||||
const handleCopy = async (key: string, entry: FeedEntry) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(feedUrl(entry));
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey((current) => (current === key ? null : current)), 2000);
|
||||
} catch {
|
||||
setError(cs() ? "Kopírování se nezdařilo." : "Copy failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotate = async (key: string, scope: "tenant" | "staff", staffId?: string) => {
|
||||
const bearer = token();
|
||||
if (!bearer || bearer.startsWith("demo.")) {
|
||||
props.onDemoNotice?.(cs() ? "V demo režimu nelze měnit." : "Cannot change in demo mode.");
|
||||
return;
|
||||
}
|
||||
if (armedRotate() !== key) {
|
||||
setArmedRotate(key);
|
||||
return;
|
||||
}
|
||||
setArmedRotate(null);
|
||||
setRotating(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await (apiClient as any).POST("/v1/calendar/feed/rotate", {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
body: { scope, staffId },
|
||||
});
|
||||
await refetch();
|
||||
setNotice(cs() ? "Adresa kalendáře obnovena — starý odkaz přestal fungovat." : "Calendar address rotated — the old link stopped working.");
|
||||
} catch {
|
||||
setError(cs() ? "Obnovení adresy se nezdařilo." : "Failed to rotate the feed link.");
|
||||
} finally {
|
||||
setRotating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const FeedRow = (rowProps: { label: string; hint: string; entry: FeedEntry; scope: "tenant" | "staff"; staffId?: string }) => {
|
||||
const key = () => rowProps.scope + ":" + (rowProps.staffId ?? "tenant");
|
||||
return (
|
||||
<div class="p-4 bg-canvas-subtle rounded-xl">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-ink">{rowProps.label}</p>
|
||||
<p class="text-xs text-ink-muted mt-0.5">{rowProps.hint}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleRotate(key(), rowProps.scope, rowProps.staffId)}
|
||||
disabled={rotating()}
|
||||
class={`shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${armedRotate() === key() ? "bg-error text-canvas" : "bg-canvas text-ink-muted hover:text-ink border border-border"}`}
|
||||
>
|
||||
<RotateIcon />
|
||||
{armedRotate() === key()
|
||||
? (cs() ? "Potvrdit?" : "Confirm?")
|
||||
: (cs() ? "Nová adresa" : "New address")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={feedUrl(rowProps.entry)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
class="flex-1 min-w-0 px-3 py-2 bg-canvas border border-border rounded-lg text-xs font-mono text-ink-muted"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void handleCopy(key(), rowProps.entry)}
|
||||
class={`shrink-0 flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${copiedKey() === key() ? "bg-success text-canvas" : "bg-accent text-canvas hover:bg-accent-hover"}`}
|
||||
>
|
||||
<CopyIcon />
|
||||
{copiedKey() === key() ? (cs() ? "Zkopírováno" : "Copied") : (cs() ? "Kopírovat" : "Copy")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="surface-card p-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-ink">{cs() ? "Kalendář (ICS)" : "Calendar feed (ICS)"}</h3>
|
||||
<p class="text-sm text-ink-muted">
|
||||
{cs()
|
||||
? "Zobrazte rezervace ve svém kalendáři — Google, Apple nebo Outlook."
|
||||
: "See bookings in your own calendar — Google, Apple or Outlook."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-ink-muted mb-5">
|
||||
{cs()
|
||||
? "Zkopírujte adresu a přidejte ji v kalendáři jako „Odebírat z URL“. Odkaz je tajný — kdokoliv s ním vidí rezervace. Pokud odkaz unikne, vygenerujte nový."
|
||||
: "Copy the address and add it in your calendar as “Subscribe from URL”. The link is secret — anyone holding it can see bookings. If it leaks, generate a new one."}
|
||||
</p>
|
||||
|
||||
<Show when={feedInfo()}>
|
||||
{(info) => (
|
||||
<div class="space-y-3">
|
||||
<FeedRow
|
||||
label={cs() ? "Celý podnik" : "Whole business"}
|
||||
hint={cs() ? "Všechny rezervace všech pracovníků." : "Every booking across all staff."}
|
||||
entry={info().tenant}
|
||||
scope="tenant"
|
||||
/>
|
||||
<For each={info().staff}>
|
||||
{(staff) => (
|
||||
<FeedRow
|
||||
label={staff.displayName}
|
||||
hint={cs() ? "Pouze rezervace tohoto pracovníka." : "Only this staff member's bookings."}
|
||||
entry={staff}
|
||||
scope="staff"
|
||||
staffId={staff.staffId}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={notice()}>
|
||||
<p class="mt-4 text-sm text-success">{notice()}</p>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<p class="mt-4 text-sm text-error">{error()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "@bookra/api-client/generated/types";
|
||||
|
||||
const baseUrl = import.meta.env.VITE_BOOKRA_API_URL ?? "http://localhost:8080";
|
||||
export const apiBaseUrl = import.meta.env.VITE_BOOKRA_API_URL ?? "http://localhost:8080";
|
||||
|
||||
export const apiClient = createClient<paths>({
|
||||
baseUrl,
|
||||
baseUrl: apiBaseUrl,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { PaletteIcon, MailIcon, CheckCircleIcon, BellIcon, XCircleIcon, CalendarDaysIcon } from "../../components/dashboard/icons";
|
||||
import { SMSSettings } from "../../components/sms-settings";
|
||||
import { CalendarFeed } from "../../components/calendar-feed";
|
||||
import { WidgetBuilder } from "../../components/widget-builder";
|
||||
import { DashboardLayout, useDashboardData } from "./layout";
|
||||
|
||||
@@ -142,6 +143,7 @@ function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<SMSSettings token={data.token} />
|
||||
<CalendarFeed token={data.token} onDemoNotice={data.setDemoNotice} />
|
||||
<WidgetBuilder config={{
|
||||
tenantSlug: data.resolvedSummary()?.tenantSlug || "demo-studio",
|
||||
publicBookingUrl: data.resolvedSummary()?.publicBookingUrl || "https://bookra.eu/book/demo-studio",
|
||||
|
||||
Reference in new issue
Block a user