mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 23:51:17 +00:00
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>
164 lines
10 KiB
TypeScript
164 lines
10 KiB
TypeScript
import { Show, createEffect, createResource, createSignal } from "solid-js";
|
|
import { apiClient } from "../../lib/api-client";
|
|
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";
|
|
|
|
function SettingsPage() {
|
|
const data = useDashboardData();
|
|
const [brandColor, setBrandColor] = createSignal(data.resolvedBootstrap()?.brand?.primaryColor ?? "#c25e3a");
|
|
const [brandSaving, setBrandSaving] = createSignal(false);
|
|
const [activeEmailType, setActiveEmailType] = createSignal("confirmation");
|
|
const [emailSubject, setEmailSubject] = createSignal("");
|
|
const [emailBody, setEmailBody] = createSignal("");
|
|
const [emailSaving, setEmailSaving] = createSignal(false);
|
|
|
|
const [emailTemplates, { refetch: refetchTemplates }] = createResource(
|
|
() => data.token(),
|
|
async (bearer) => {
|
|
if (!bearer || bearer.startsWith("demo.")) return [];
|
|
try {
|
|
const response = await (apiClient as any).GET("/v1/tenants/email-templates", { headers: { Authorization: `Bearer ${bearer}` } });
|
|
return (response.data?.templates as any[]) ?? [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
);
|
|
|
|
// Prefill the editor from the stored template whenever the type changes.
|
|
createEffect(() => {
|
|
const tpl = (emailTemplates() ?? []).find((t: any) => t.type === activeEmailType());
|
|
setEmailSubject(tpl?.subject ?? "");
|
|
setEmailBody(tpl?.bodyText ?? tpl?.bodyHtml ?? "");
|
|
});
|
|
|
|
const presetColors = [
|
|
{ name: data.i18n.locale() === "cs" ? "Hnědá" : "Terracotta", color: "#c25e3a" },
|
|
{ name: data.i18n.locale() === "cs" ? "Zelená" : "Sage", color: "#5a7c5a" },
|
|
{ name: data.i18n.locale() === "cs" ? "Modrá" : "Ocean", color: "#3a6a8a" },
|
|
{ name: data.i18n.locale() === "cs" ? "Fialová" : "Plum", color: "#6a4a7a" },
|
|
];
|
|
|
|
const handleSaveBrand = async () => {
|
|
const bearer = data.token();
|
|
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
|
setBrandSaving(true);
|
|
try {
|
|
await (apiClient as any).PUT("/v1/tenants/brand", { headers: { Authorization: `Bearer ${bearer}` }, body: { primaryColor: brandColor() } });
|
|
} catch { /* ignore */ }
|
|
finally { setBrandSaving(false); }
|
|
};
|
|
|
|
return (
|
|
<div class="space-y-6">
|
|
<div>
|
|
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.settings")}</h1>
|
|
<p class="text-ink-muted mt-1">{data.i18n.locale() === "cs" ? "Spravujte svůj podnik, branding a předvolby." : "Manage your business, branding and preferences."}</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div class="surface-card p-6">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent"><PaletteIcon /></div>
|
|
<h3 class="text-lg font-semibold text-ink">{data.i18n.t("dashboard.settings.branding")}</h3>
|
|
</div>
|
|
<div class="mb-6">
|
|
<label class="block text-sm font-medium text-ink-muted mb-3">{data.i18n.t("dashboard.settings.branding")}</label>
|
|
<div class="grid grid-cols-4 gap-2 mb-3">
|
|
{presetColors.map((preset) => (
|
|
<button onClick={() => setBrandColor(preset.color)} class={`p-2 rounded-lg border-2 transition-all ${brandColor() === preset.color ? "border-ink" : "border-transparent hover:border-ink/20"}`}>
|
|
<div class="w-full h-8 rounded-md shadow-sm" style={{ background: preset.color }} />
|
|
<p class="text-xs text-ink-muted mt-1">{preset.name}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<input type="color" value={brandColor()} onInput={(e) => setBrandColor(e.currentTarget.value)} class="w-12 h-10 rounded-lg border border-border cursor-pointer" />
|
|
<input type="text" value={brandColor()} onInput={(e) => setBrandColor(e.currentTarget.value)} class="flex-1 px-4 py-2.5 bg-canvas-subtle border border-border rounded-xl text-ink text-sm font-mono uppercase" />
|
|
</div>
|
|
</div>
|
|
<div class="p-4 bg-canvas-subtle rounded-xl">
|
|
<p class="text-sm text-ink-muted mb-3">{data.i18n.t("dashboard.preview")}</p>
|
|
<div class="p-4 rounded-xl border border-border" style={{ "border-left": `4px solid ${brandColor()}` }}>
|
|
<div class="flex items-center gap-3 mb-3">
|
|
<div class="w-10 h-10 rounded-full flex items-center justify-center text-canvas font-bold" style={{ background: brandColor() }}>B</div>
|
|
<div>
|
|
<p class="font-semibold text-ink">Bookra</p>
|
|
<p class="text-xs text-ink-muted">{data.i18n.locale() === "cs" ? "Rezervujte si termín" : "Book an appointment"}</p>
|
|
</div>
|
|
</div>
|
|
<button class="w-full py-2.5 rounded-lg text-canvas font-medium text-sm" style={{ background: brandColor() }}>{data.i18n.locale() === "cs" ? "Rezervovat" : "Book Now"}</button>
|
|
</div>
|
|
</div>
|
|
<button onClick={handleSaveBrand} disabled={brandSaving()} class="mt-4 w-full py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors font-medium disabled:opacity-60 flex items-center justify-center gap-2">
|
|
<Show when={brandSaving()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
|
|
{brandSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="surface-card p-6">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent"><MailIcon /></div>
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-ink">{data.i18n.t("dashboard.settings.emailNotifications")}</h3>
|
|
<p class="text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Upravte e-maily posílané zákazníkům." : "Customize emails sent to customers."}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 mb-4 overflow-x-auto pb-2">
|
|
{[
|
|
{ key: "confirmation", label: data.i18n.t("dashboard.confirmed"), icon: CheckCircleIcon },
|
|
{ key: "reminder", label: data.i18n.t("dashboard.notifications"), icon: BellIcon },
|
|
{ key: "cancellation", label: data.i18n.t("dashboard.cancelled"), icon: XCircleIcon },
|
|
{ key: "reschedule", label: data.i18n.t("dashboard.edit"), icon: CalendarDaysIcon },
|
|
].map((type) => (
|
|
<button onClick={() => setActiveEmailType(type.key)} class={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${activeEmailType() === type.key ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>
|
|
<type.icon /> {type.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div class="space-y-4">
|
|
<Input type="text" label={data.i18n.t("dashboard.settings.emailSubject")} value={emailSubject()} onInput={(e) => setEmailSubject(e.currentTarget.value)} placeholder={activeEmailType() === "confirmation" ? (data.i18n.locale() === "cs" ? "Potvrzení rezervace" : "Booking Confirmation") : ""} />
|
|
<Textarea label={data.i18n.t("dashboard.settings.emailBody")} value={emailBody()} onInput={(e) => setEmailBody(e.currentTarget.value)} rows={6} resize="none" placeholder={data.i18n.locale() === "cs" ? "Obsah e-mailu..." : "Email body..."} />
|
|
</div>
|
|
<button onClick={async () => {
|
|
const bearer = data.token();
|
|
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
|
setEmailSaving(true);
|
|
try {
|
|
await (apiClient as any).PUT("/v1/tenants/email-template", { headers: { Authorization: `Bearer ${bearer}` }, body: { type: activeEmailType(), subject: emailSubject(), body: emailBody(), isEnabled: true } });
|
|
void refetchTemplates();
|
|
data.setDemoNotice(data.i18n.locale() === "cs" ? "Šablona uložena." : "Template saved.");
|
|
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Chyba při ukládání." : "Save failed."); }
|
|
finally { setEmailSaving(false); }
|
|
}} disabled={emailSaving()} class="mt-4 w-full py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors font-medium disabled:opacity-60 flex items-center justify-center gap-2">
|
|
<Show when={emailSaving()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
|
|
{emailSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
|
|
</button>
|
|
</div>
|
|
</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",
|
|
tenantName: data.resolvedSummary()?.tenantName || "Demo Studio",
|
|
primaryColor: data.resolvedBootstrap()?.brand?.primaryColor,
|
|
}} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function SettingsRoute() {
|
|
return (
|
|
<DashboardLayout>
|
|
<SettingsPage />
|
|
</DashboardLayout>
|
|
);
|
|
}
|