feat(sms): implement SMS messaging and metered billing
CI / Frontend (push) Successful in 9m50s
CI / Go - apps/auth-service (push) Failing after 4s
CI / Go - apps/backend (push) Successful in 10m18s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped

Implement a complete SMS messaging system including:
- Integration with SMS Manager.cz API for sending messages.
- Metered billing via Stripe using monthly aggregate invoice items.
- Backend services for managing SMS settings, usage logging, and monthly reporting.
- Database migrations for tenant settings, usage logs, and monthly reports.
- Frontend dashboard components for SMS configuration, usage tracking, and history.
- Support for customer phone numbers in the booking flow.

Includes new migrations, backend services, and frontend UI components.
This commit is contained in:
Tomas Dvorak
2026-05-10 11:40:53 +02:00
parent 164a37e997
commit 7d3e3448cf
28 changed files with 3633 additions and 3190 deletions
@@ -0,0 +1,239 @@
import { createSignal, createMemo, Show, For } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "./icons";
export function CalendarView(props: { bookings: any[]; locale?: string; onBookingClick?: (b: any) => void }) {
const i18n = useI18n();
const [currentDate, setCurrentDate] = createSignal(new Date());
const [selectedDay, setSelectedDay] = createSignal<number | null>(null);
const [modalDay, setModalDay] = createSignal<number | null>(null);
const [isAnimating, setIsAnimating] = createSignal(false);
const isCs = () => props.locale === "cs";
const weekDays = isCs()
? ["Po", "Út", "St", "Čt", "Pá", "So", "Ne"]
: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const monthYear = createMemo(() =>
new Intl.DateTimeFormat(isCs() ? "cs-CZ" : "en-US", { month: "long", year: "numeric" }).format(currentDate())
);
const calendarDays = createMemo(() => {
const date = currentDate();
const year = date.getFullYear();
const month = date.getMonth();
const firstDayOfMonth = new Date(year, month, 1).getDay();
// Adjust so Monday = 0
const firstDay = (firstDayOfMonth + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const days: Array<{ day: number | null; bookings: any[]; isToday: boolean }> = [];
for (let i = 0; i < firstDay; i++) days.push({ day: null, bookings: [], isToday: false });
const today = new Date();
for (let day = 1; day <= daysInMonth; day++) {
const dayBookings = props.bookings.filter((b) => {
const bd = new Date(b.startsAt);
return bd.getDate() === day && bd.getMonth() === month && bd.getFullYear() === year;
});
const isToday = today.getDate() === day && today.getMonth() === month && today.getFullYear() === year;
days.push({ day, bookings: dayBookings, isToday });
}
return days;
});
const changeMonth = (direction: number) => {
setIsAnimating(true);
setTimeout(() => {
setCurrentDate(new Date(currentDate().getFullYear(), currentDate().getMonth() + direction, 1));
setIsAnimating(false);
setSelectedDay(null);
setModalDay(null);
}, 150);
};
const handleDayClick = (day: number | null) => {
if (!day) return;
const dayData = calendarDays().find((d) => d.day === day);
if (dayData && dayData.bookings.length > 0) {
setModalDay(day);
} else {
setSelectedDay(selectedDay() === day ? null : day);
}
};
const handleBookingClick = (booking: any) => {
props.onBookingClick?.(booking);
setModalDay(null);
};
const modalBookings = createMemo(() => {
const day = modalDay();
if (!day) return [];
return calendarDays().find((d) => d.day === day)?.bookings ?? [];
});
return (
<>
<div class="surface-card p-6 shadow-sm hover:shadow-md transition-all duration-500">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-ink">{monthYear()}</h3>
<p class="text-sm text-ink-muted mt-0.5">
{props.bookings.length} {isCs() ? i18n.t("dashboard.calendar.bookingsCount") : "bookings"}
</p>
</div>
<div class="flex gap-1">
<button
onClick={() => changeMonth(-1)}
aria-label={i18n.t("dashboard.prevMonth")}
class="p-2 hover:bg-canvas-subtle rounded-xl transition-all hover:scale-105 active:scale-95"
>
<ChevronLeftIcon />
</button>
<button
onClick={() => changeMonth(1)}
aria-label={i18n.t("dashboard.nextMonth")}
class="p-2 hover:bg-canvas-subtle rounded-xl transition-all hover:scale-105 active:scale-95"
>
<ChevronRightIcon />
</button>
</div>
</div>
<div class="grid grid-cols-7 gap-1 mb-2">
{weekDays.map((day) => (
<div class="text-center text-[10px] sm:text-xs font-semibold text-ink-subtle uppercase tracking-wider py-2">
{day}
</div>
))}
</div>
<div class={`grid grid-cols-7 gap-1 transition-opacity duration-200 ${isAnimating() ? "opacity-0" : "opacity-100"}`}>
{calendarDays().map(({ day, bookings, isToday }, index) => (
<button
onClick={() => handleDayClick(day)}
class={`
aspect-square p-1 sm:p-1.5 rounded-xl border transition-all duration-200 relative overflow-hidden
${day ? "bg-canvas border-border/60 hover:border-accent/40 hover:shadow-sm" : "bg-transparent border-transparent pointer-events-none"}
${isToday ? "ring-1 ring-accent bg-accent-subtle/40" : ""}
${selectedDay() === day ? "ring-2 ring-accent ring-offset-1 ring-offset-canvas" : ""}
${bookings.length > 0 && !isToday ? "bg-accent-subtle/20" : ""}
`}
style={{ "animation-delay": `${index * 15}ms` }}
disabled={!day}
>
{day && (
<>
<span class={`text-xs sm:text-sm font-medium ${isToday ? "text-accent" : "text-ink"}`}>{day}</span>
{bookings.length > 0 && (
<div class="absolute bottom-1 left-1 right-1 flex gap-0.5 justify-center">
{bookings.slice(0, 3).map((b: any) => (
<span
class={`w-1 h-1 rounded-full ${
b.status === "confirmed" ? "bg-ink/60" : b.status === "pending" ? "bg-ink/40" : "bg-ink/20"
}`}
/>
))}
{bookings.length > 3 && (
<span class="text-[7px] font-bold text-ink/50 leading-none">+</span>
)}
</div>
)}
</>
)}
</button>
))}
</div>
{/* Inline selected day preview for empty days */}
<Show when={selectedDay() && !modalDay()}>
<div class="mt-4 p-4 bg-canvas-subtle/50 rounded-xl border border-border/60 animate-fade-in">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-ink">
{selectedDay()}. {monthYear()}
</span>
<button onClick={() => setSelectedDay(null)} class="text-xs text-ink-muted hover:text-ink">
{i18n.t("dashboard.close")}
</button>
</div>
<p class="text-sm text-ink-muted">{i18n.t("dashboard.calendar.noBookings")}</p>
</div>
</Show>
</div>
{/* Day bookings modal */}
<Show when={modalDay()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/40 backdrop-blur-sm" onClick={() => setModalDay(null)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] overflow-hidden animate-scale-in">
<div class="p-5 border-b border-border flex items-center justify-between">
<div>
<h3 class="text-lg font-bold text-ink">
{modalDay()}. {monthYear()}
</h3>
<p class="text-xs text-ink-muted mt-0.5">
{modalBookings().length} {isCs() ? "rezervací" : "bookings"}
</p>
</div>
<button
onClick={() => setModalDay(null)}
class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors"
aria-label={i18n.t("dashboard.close")}
>
<XIcon />
</button>
</div>
<div class="p-5 overflow-y-auto max-h-[60vh] space-y-3">
<For each={modalBookings()}>
{(booking) => (
<button
onClick={() => handleBookingClick(booking)}
class="w-full text-left flex items-center gap-3 p-3 rounded-xl border border-border/60 hover:border-accent/30 hover:bg-accent-subtle/20 transition-all"
>
<div
class={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${
booking.status === "confirmed"
? "bg-accent text-canvas"
: booking.status === "pending"
? "bg-canvas-muted text-ink"
: "bg-canvas-subtle text-ink-muted"
}`}
>
{(booking.customerName ?? "?")
.split(" ")
.map((n: string) => n[0])
.join("")
.slice(0, 2)
.toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink truncate">{booking.customerName}</p>
<p class="text-xs text-ink-muted">{booking.service}</p>
</div>
<div class="text-right shrink-0">
<p class="text-xs font-medium text-ink">
{new Date(booking.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</p>
<span
class={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
booking.status === "confirmed"
? "bg-accent/10 text-accent"
: booking.status === "pending"
? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}
>
{i18n.t(`dashboard.${booking.status}`)}
</span>
</div>
</button>
)}
</For>
</div>
</div>
</div>
</Show>
</>
);
}
@@ -176,3 +176,43 @@ export const MailIcon = () => (
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
);
export const EyeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
</svg>
);
export const EditIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>
</svg>
);
export const Trash2Icon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>
</svg>
);
export const XCircleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>
</svg>
);
export const MoreHorizontalIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
);
export const BarChart3Icon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
);
export const CheckIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
);
export const GlobeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
);
@@ -0,0 +1,166 @@
import { createSignal, createMemo, Show, For } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { BellIcon } from "./icons";
interface NotificationItem {
id: string;
type: "booking" | "reminder" | "upgrade" | "trial" | "system";
title: string;
message: string;
time: string;
read: boolean;
}
export function NotificationDropdown(props: { bookings?: any[]; billing?: any }) {
const i18n = useI18n();
const [open, setOpen] = createSignal(false);
const notifications = createMemo<NotificationItem[]>(() => {
const items: NotificationItem[] = [];
const cs = i18n.locale() === "cs";
const bookings = props.bookings ?? [];
// Recent bookings as notifications
bookings.slice(0, 3).forEach((b: any, i: number) => {
items.push({
id: `booking-${i}`,
type: "booking",
title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`,
message: `${b.service}${new Date(b.startsAt).toLocaleDateString()}`,
time: new Date(b.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
read: false,
});
});
// Plan/trial notifications
const billing = props.billing;
if (billing?.subscriptionStatus === "trialing") {
const daysLeft = billing?.trialDaysRemaining ?? 3;
items.push({
id: "trial",
type: "trial",
title: cs ? "Zkušební doba končí" : "Trial ending",
message: cs ? `Zbývá ${daysLeft} dní` : `${daysLeft} days remaining`,
time: "",
read: false,
});
}
if (items.length === 0) {
items.push({
id: "welcome",
type: "system",
title: cs ? "Vítejte v Bookra" : "Welcome to Bookra",
message: cs ? "Začněte vytvořením první rezervace." : "Start by creating your first booking.",
time: "",
read: true,
});
}
return items;
});
const [readIds, setReadIds] = createSignal<Set<string>>(new Set());
const filteredNotifications = createMemo(() =>
notifications().map((n) => ({ ...n, read: n.read || readIds().has(n.id) }))
);
const unreadCount = createMemo(() => filteredNotifications().filter((n) => !n.read).length);
const markAllRead = () => {
setReadIds(new Set(filteredNotifications().map((n) => n.id)));
};
const markRead = (id: string) => {
setReadIds((prev) => { const next = new Set(prev); next.add(id); return next; });
};
const typeIcon = (type: string) => {
const base = "w-8 h-8 rounded-full flex items-center justify-center shrink-0";
switch (type) {
case "booking":
return (
<div class={`${base} bg-accent-subtle text-accent`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
</div>
);
case "reminder":
return (
<div class={`${base} bg-canvas-muted text-ink-muted`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
);
case "upgrade":
case "trial":
return (
<div class={`${base} bg-canvas-muted text-ink-muted`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
</div>
);
default:
return (
<div class={`${base} bg-canvas-muted text-ink-muted`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
</div>
);
}
};
return (
<div class="relative">
<button
onClick={() => setOpen(!open())}
class="p-2 text-ink-subtle hover:text-ink hover:bg-canvas-subtle rounded-xl transition-all relative"
aria-label={i18n.t("dashboard.notifications")}
>
<BellIcon />
<Show when={unreadCount() > 0}>
<span class="absolute top-1.5 right-1.5 w-2 h-2 bg-accent rounded-full" />
</Show>
</button>
<Show when={open()}>
<div class="absolute right-0 top-full mt-2 w-80 bg-canvas rounded-2xl shadow-xl border border-border z-50 overflow-hidden animate-scale-in">
<div class="p-4 border-b border-border flex items-center justify-between">
<h3 class="font-semibold text-ink">{i18n.t("dashboard.notifications.title")}</h3>
<Show when={unreadCount() > 0}>
<button onClick={markAllRead} class="text-xs text-accent hover:text-accent-hover font-medium">
{i18n.t("dashboard.markAllRead")}
</button>
</Show>
</div>
<div class="max-h-80 overflow-y-auto">
<Show
when={filteredNotifications().length > 0}
fallback={
<div class="p-6 text-center text-sm text-ink-muted">
{i18n.t("dashboard.noNotifications")}
</div>
}
>
<For each={filteredNotifications()}>
{(n) => (
<button
onClick={() => markRead(n.id)}
class={`w-full text-left flex items-start gap-3 p-4 hover:bg-canvas-subtle/50 transition-colors border-b border-border/50 last:border-0 ${n.read ? "opacity-60" : ""}`}
>
{typeIcon(n.type)}
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink truncate">{n.title}</p>
<p class="text-xs text-ink-muted mt-0.5">{n.message}</p>
<Show when={n.time}>
<p class="text-[10px] text-ink-subtle mt-1">{n.time}</p>
</Show>
</div>
{!n.read && <span class="w-2 h-2 rounded-full bg-accent shrink-0 mt-1" />}
</button>
)}
</For>
</Show>
</div>
</div>
<div class="fixed inset-0 z-40" onClick={() => setOpen(false)} />
</Show>
</div>
);
}
@@ -0,0 +1,73 @@
import { createMemo } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
interface DataPoint {
label: string;
value: number;
}
export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) {
const i18n = useI18n();
const chartData = createMemo(() => {
if (props.data && props.data.length > 0) return props.data;
// Build last 7 days from bookings
const days: DataPoint[] = [];
const bookings = props.bookings ?? [];
const today = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split("T")[0];
const count = bookings.filter((b: any) => {
const bd = new Date(b.startsAt).toISOString().split("T")[0];
return bd === dateStr;
}).length;
days.push({
label: d.toLocaleDateString(i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short" }),
value: count,
});
}
return days;
});
const maxValue = createMemo(() => Math.max(1, ...chartData().map((d) => d.value)));
const total = createMemo(() => chartData().reduce((s, d) => s + d.value, 0));
return (
<div class="surface-card p-6">
<div class="flex items-start justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-ink">{i18n.t("dashboard.revenueTitle")}</h3>
<p class="text-sm text-ink-muted mt-0.5">{i18n.t("dashboard.revenueSubtitle")}</p>
</div>
<div class="text-right">
<p class="text-2xl font-bold text-ink tracking-tight">{total()}</p>
<p class="text-xs text-ink-muted">{i18n.t("dashboard.totalBookings")}</p>
</div>
</div>
<div class="flex items-end gap-2 sm:gap-3 h-40">
{chartData().map((point, i) => {
const heightPct = Math.round((point.value / maxValue()) * 100);
return (
<div class="flex-1 flex flex-col items-center gap-2">
<div class="w-full flex-1 flex items-end">
<div
class="w-full rounded-t-md bg-accent/80 hover:bg-accent transition-all duration-300 relative group"
style={{ height: `${Math.max(heightPct, 4)}%` }}
>
<div class="absolute -top-7 left-1/2 -translate-x-1/2 px-2 py-0.5 rounded-md bg-ink text-canvas text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
{point.value}
</div>
</div>
</div>
<span class="text-[10px] sm:text-xs text-ink-muted font-medium uppercase">{point.label}</span>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,329 @@
import { createResource, createSignal, Show, For, Accessor } from "solid-js";
import { apiClient } from "../lib/api-client";
import { Input } from "./ui/input";
import { useI18n } from "../providers/i18n-provider";
interface SMSSettingsData {
enabled: boolean;
senderName: string;
monthlyLimit: number;
messagesSent: number;
totalCostCents: number;
available: boolean;
}
interface SMSReport {
yearMonth: string;
messageCount: number;
totalCostCents: number;
stripeInvoiceId?: string;
invoiceSentAt?: string;
}
interface SMSLog {
id: string;
recipientPhone: string;
status: string;
costCents: number;
createdAt: string;
}
function formatCents(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function formatMonth(yearMonth: string) {
const [y, m] = yearMonth.split("-");
return `${m}/${y}`;
}
interface SMSSettingsProps {
token: Accessor<string | null | undefined>;
}
export function SMSSettings(props: SMSSettingsProps) {
const i18n = useI18n();
const token = props.token;
const [saving, setSaving] = createSignal(false);
const [notice, setNotice] = createSignal<string | null>(null);
const [error, setError] = createSignal<string | null>(null);
const [settings, { refetch: refetchSettings }] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) {
return {
enabled: false,
senderName: "",
monthlyLimit: 0,
messagesSent: 0,
totalCostCents: 0,
available: true,
} as SMSSettingsData;
}
const response = await (apiClient as any).GET("/v1/sms/settings", {
headers: { Authorization: `Bearer ${bearer}` },
});
return response.data as SMSSettingsData;
});
const [reports] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return [] as SMSReport[];
const response = await (apiClient as any).GET("/v1/sms/invoices", {
headers: { Authorization: `Bearer ${bearer}` },
});
return (response.data as any)?.reports ?? [];
});
const [logs] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return [] as SMSLog[];
const response = await (apiClient as any).GET("/v1/sms/history", {
headers: { Authorization: `Bearer ${bearer}` },
});
return (response.data as any)?.logs ?? [];
});
const handleToggle = async () => {
const current = settings();
if (!current) return;
const bearer = token();
if (!bearer) return;
setSaving(true);
setError(null);
setNotice(null);
try {
await (apiClient as any).POST("/v1/sms/settings", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
enabled: !current.enabled,
senderName: current.senderName,
monthlyLimit: current.monthlyLimit,
},
});
await refetchSettings();
setNotice(
i18n.locale() === "cs"
? `SMS ${!current.enabled ? "aktivováno" : "deaktivováno"}`
: `SMS ${!current.enabled ? "enabled" : "disabled"}`
);
} catch (e: any) {
setError(
e?.response?.data?.error ||
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
);
} finally {
setSaving(false);
}
};
const handleSaveSettings = async (e: Event) => {
e.preventDefault();
const current = settings();
if (!current) return;
const bearer = token();
if (!bearer) return;
setSaving(true);
setError(null);
setNotice(null);
try {
await (apiClient as any).POST("/v1/sms/settings", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
enabled: current.enabled,
senderName: current.senderName,
monthlyLimit: current.monthlyLimit,
},
});
await refetchSettings();
setNotice(i18n.locale() === "cs" ? "Nastavení uloženo." : "Settings saved.");
} catch (e: any) {
setError(
e?.response?.data?.error ||
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
);
} finally {
setSaving(false);
}
};
const cs = () => i18n.locale() === "cs";
return (
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-ink">{cs() ? "SMS zprávy" : "SMS Messages"}</h3>
<p class="text-sm text-ink-muted">
{cs()
? "1.50 Kč / SMS. Fakturováno měsíčně přes Stripe."
: "1.50 CZK / SMS. Billed monthly via Stripe."}
</p>
</div>
</div>
<Show when={!settings.loading} fallback={<div class="text-ink-muted">{cs() ? "Načítání..." : "Loading..."}</div>}>
<Show when={settings()?.available === false}>
<div class="p-4 bg-canvas-subtle rounded-xl text-ink-muted text-sm">
{cs()
? "SMS není v této instanci nakonfigurováno. Kontaktujte administrátora."
: "SMS is not configured for this instance. Contact your administrator."}
</div>
</Show>
<Show when={settings()?.available === true}>
<Show when={notice()}>
<div class="mb-4 p-3 bg-emerald-50 text-emerald-700 rounded-lg text-sm">{notice()}</div>
</Show>
<Show when={error()}>
<div class="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error()}</div>
</Show>
{/* Toggle */}
<div class="flex items-center justify-between mb-6 p-4 bg-canvas-subtle rounded-xl">
<div>
<p class="font-medium text-ink">{cs() ? "SMS odesílání" : "SMS sending"}</p>
<p class="text-sm text-ink-muted">
{settings()?.enabled
? (cs() ? "Aktivní — účtováno za každou zprávu" : "Active — charged per message")
: (cs() ? "Neaktivní" : "Inactive")}
</p>
</div>
<button
onClick={handleToggle}
disabled={saving()}
class={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 ${
settings()?.enabled ? "bg-accent" : "bg-gray-300"
}`}
>
<span
class={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
settings()?.enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
<Show when={settings()?.enabled}>
<form onSubmit={handleSaveSettings} class="space-y-4">
<Input
type="text"
label={cs() ? "Jméno odesílatele (max 11 znaků)" : "Sender name (max 11 chars)"}
value={settings()?.senderName || ""}
onInput={(e) => {
const s = settings();
if (s) s.senderName = e.currentTarget.value;
}}
maxLength={11}
placeholder="Bookra"
/>
<Input
type="number"
label={cs() ? "Měsíční limit (0 = bez limitu)" : "Monthly limit (0 = unlimited)"}
value={settings()?.monthlyLimit || 0}
onInput={(e) => {
const s = settings();
if (s) s.monthlyLimit = parseInt(e.currentTarget.value) || 0;
}}
min={0}
/>
<div class="flex justify-end">
<button
type="submit"
disabled={saving()}
class="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{saving() ? (cs() ? "Ukládání..." : "Saving...") : cs() ? "Uložit" : "Save"}
</button>
</div>
</form>
{/* Current month stats */}
<div class="mt-6 p-4 bg-canvas-subtle rounded-xl">
<h4 class="text-sm font-medium text-ink mb-3">
{cs() ? "Aktuální měsíc" : "Current month"}
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-2xl font-bold text-ink">{settings()?.messagesSent ?? 0}</p>
<p class="text-xs text-ink-muted">{cs() ? "Odeslaných zpráv" : "Messages sent"}</p>
</div>
<div>
<p class="text-2xl font-bold text-ink">{formatCents(settings()?.totalCostCents ?? 0)}</p>
<p class="text-xs text-ink-muted">{cs() ? "Celková cena" : "Total cost"}</p>
</div>
</div>
</div>
{/* Recent logs */}
<Show when={logs() && logs()!.length > 0}>
<div class="mt-6">
<h4 class="text-sm font-medium text-ink mb-3">
{cs() ? "Historie odesílání" : "Send history"}
</h4>
<div class="space-y-2 max-h-48 overflow-y-auto">
<For each={logs()}>
{(log) => (
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
<div class="flex items-center gap-3">
<span class="text-ink-muted">{log.recipientPhone}</span>
<span
class={`px-2 py-0.5 rounded-full text-xs ${
log.status === "sent"
? "bg-emerald-100 text-emerald-700"
: "bg-red-100 text-red-700"
}`}
>
{log.status}
</span>
</div>
<span class="text-ink-muted">{formatCents(log.costCents)}</span>
</div>
)}
</For>
</div>
</div>
</Show>
{/* Monthly invoice reports */}
<Show when={reports() && reports()!.length > 0}>
<div class="mt-6">
<h4 class="text-sm font-medium text-ink mb-3">
{cs() ? "Fakturační přehledy" : "Invoice reports"}
</h4>
<div class="space-y-2">
<For each={reports()}>
{(report) => (
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
<div>
<span class="font-medium text-ink">{formatMonth(report.yearMonth)}</span>
<span class="text-ink-muted ml-2">{report.messageCount} SMS</span>
</div>
<div class="flex items-center gap-3">
<span class="font-medium text-ink">{formatCents(report.totalCostCents)}</span>
<Show when={report.stripeInvoiceId}>
<span class="text-xs text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
Stripe
</span>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
</Show>
</Show>
</Show>
</div>
);
}
@@ -322,6 +322,88 @@ const dictionaries = {
"dashboard.onboarding.timezone": "Časové pásmo",
"dashboard.onboarding.submit": "Vytvořit prostor",
"dashboard.onboarding.pending": "Vytvářím prostor...",
"dashboard.revenueTitle": "Trend rezervací",
"dashboard.revenueSubtitle": "Počet rezervací za posledních 7 dní",
"dashboard.noNotifications": "Žádná nová oznámení",
"dashboard.markAllRead": "Označit vše jako přečtené",
"dashboard.notifications.title": "Oznámení",
"dashboard.demoBanner": "Demo režim",
"dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.",
"dashboard.demoBannerCTA": "Vytvořit účet",
"dashboard.tryDemo": "Vyzkoušet demo",
"dashboard.language": "Jazyk",
"dashboard.calendar.noBookings": "Tento den nemáte žádné rezervace.",
"dashboard.calendar.bookingsCount": "rezervací",
"dashboard.chart.bookingsTrend": "Rezervace",
"dashboard.chart.revenueTrend": "Obrat",
"dashboard.recentActivity.empty": "Zatím žádná aktivita. Rezervace se zobrazí zde.",
"dashboard.filter.all": "Vše",
"dashboard.filter.today": "Dnes",
"dashboard.filter.week": "Týden",
"dashboard.filter.month": "Měsíc",
"dashboard.search.placeholder": "Hledat...",
"dashboard.search.bookings": "Hledat podle jména, služby nebo reference...",
"dashboard.search.customers": "Hledat podle jména, emailu nebo telefonu...",
"dashboard.actions": "Akce",
"dashboard.customer.email": "E-mail",
"dashboard.customer.phone": "Telefon",
"dashboard.customer.totalBookings": "Celkem rezervací",
"dashboard.customer.lastVisit": "Poslední návštěva",
"dashboard.customer.status": "Stav",
"dashboard.customer.notes": "Poznámky",
"dashboard.customer.noNotes": "Žádné poznámky",
"dashboard.customer.bookings": "Rezervace zákazníka",
"dashboard.customer.noBookings": "Žádné rezervace",
"dashboard.zone.add": "Přidat zónu",
"dashboard.zone.name": "Název",
"dashboard.zone.type": "Typ",
"dashboard.zone.capacity": "Kapacita",
"dashboard.zone.limitReached": "Dosáhli jste limitu",
"dashboard.zone.rooms": "Místnost",
"dashboard.zone.private": "Soukromá místnost",
"dashboard.zone.hall": "Sál",
"dashboard.zone.blockedDays": "Blokované dny",
"dashboard.zone.workingHours": "Pracovní doba",
"dashboard.zone.open": "Otevřeno",
"dashboard.zone.close": "Zavřeno",
"dashboard.zone.addBlocked": "Přidat blokovaný den",
"dashboard.zone.reason": "Důvod",
"dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.",
"dashboard.billing.planUsage": "Využití plánu",
"dashboard.billing.locations": "Lokace",
"dashboard.billing.bookings": "Rezervace",
"dashboard.billing.staff": "Zaměstnanci",
"dashboard.billing.period": "Období",
"dashboard.billing.currentPlan": "Aktuální plán",
"dashboard.billing.nextBilling": "Další fakturace",
"dashboard.settings.businessInfo": "Informace o podniku",
"dashboard.settings.branding": "Branding a vzhled",
"dashboard.settings.emailNotifications": "E-mailová oznámení",
"dashboard.settings.emailSubject": "Předmět",
"dashboard.settings.emailBody": "Obsah",
"dashboard.settings.save": "Uložit",
"dashboard.bookingModal.customer": "Zákazník",
"dashboard.bookingModal.service": "Služba",
"dashboard.bookingModal.location": "Místo",
"dashboard.bookingModal.dateTime": "Datum a čas",
"dashboard.bookingModal.duration": "Délka",
"dashboard.bookingModal.status": "Stav",
"dashboard.bookingModal.reference": "Reference",
"dashboard.bookingModal.notes": "Poznámky",
"dashboard.bookingModal.reschedule": "Přeplánovat",
"dashboard.bookingModal.confirmCancel": "Opravdu chcete zrušit tuto rezervaci?",
"dashboard.bookingModal.createdAt": "Vytvořeno",
"dashboard.bookingModal.assignedTo": "Přiřazeno",
"dashboard.bookingModal.noAssign": "Nepřiřazeno",
"dashboard.bookingModal.email": "E-mail",
"dashboard.bookingModal.phone": "Telefon",
"dashboard.empty.title": "Zatím prázdné",
"dashboard.empty.bookingsDesc": "Žádné rezervace neodpovídají vašemu filtru.",
"dashboard.empty.customersDesc": "Zatím nemáte žádné zákazníky.",
"dashboard.notification.newBooking": "Nová rezervace od",
"dashboard.notification.reminder": "Připomínka: rezervace u",
"dashboard.notification.upgrade": "Blížíte se limitu plánu",
"dashboard.notification.trialEnding": "Vaše zkušební doba končí za 3 dny",
"booking.title": "Rezervace",
"booking.body": "Vyberte dostupný termín, doplňte kontaktní údaje a potvrzení přijde e-mailem.",
"booking.slots": "Dostupné termíny",
@@ -340,6 +422,7 @@ const dictionaries = {
"booking.customer.body": "Tyto údaje použijeme pro potvrzení rezervace a připomenutí.",
"booking.customer.name": "Jméno",
"booking.customer.email": "E-mail",
"booking.customer.phone": "Telefon",
"booking.customer.notes": "Poznámka",
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
"booking.failed": "Rezervaci se nepodařilo vytvořit",
@@ -719,6 +802,88 @@ const dictionaries = {
"dashboard.onboarding.timezone": "Timezone",
"dashboard.onboarding.submit": "Create workspace",
"dashboard.onboarding.pending": "Creating workspace...",
"dashboard.revenueTitle": "Booking trend",
"dashboard.revenueSubtitle": "Bookings over the last 7 days",
"dashboard.noNotifications": "No new notifications",
"dashboard.markAllRead": "Mark all as read",
"dashboard.notifications.title": "Notifications",
"dashboard.demoBanner": "Demo mode",
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
"dashboard.demoBannerCTA": "Create account",
"dashboard.tryDemo": "Try demo",
"dashboard.language": "Language",
"dashboard.calendar.noBookings": "No bookings for this day.",
"dashboard.calendar.bookingsCount": "bookings",
"dashboard.chart.bookingsTrend": "Bookings",
"dashboard.chart.revenueTrend": "Revenue",
"dashboard.recentActivity.empty": "No activity yet. Bookings will appear here.",
"dashboard.filter.all": "All",
"dashboard.filter.today": "Today",
"dashboard.filter.week": "Week",
"dashboard.filter.month": "Month",
"dashboard.search.placeholder": "Search...",
"dashboard.search.bookings": "Search by name, service or reference...",
"dashboard.search.customers": "Search by name, email or phone...",
"dashboard.actions": "Actions",
"dashboard.customer.email": "Email",
"dashboard.customer.phone": "Phone",
"dashboard.customer.totalBookings": "Total bookings",
"dashboard.customer.lastVisit": "Last visit",
"dashboard.customer.status": "Status",
"dashboard.customer.notes": "Notes",
"dashboard.customer.noNotes": "No notes",
"dashboard.customer.bookings": "Customer bookings",
"dashboard.customer.noBookings": "No bookings",
"dashboard.zone.add": "Add zone",
"dashboard.zone.name": "Name",
"dashboard.zone.type": "Type",
"dashboard.zone.capacity": "Capacity",
"dashboard.zone.limitReached": "Limit reached",
"dashboard.zone.rooms": "Room",
"dashboard.zone.private": "Private room",
"dashboard.zone.hall": "Hall",
"dashboard.zone.blockedDays": "Blocked days",
"dashboard.zone.workingHours": "Working hours",
"dashboard.zone.open": "Open",
"dashboard.zone.close": "Close",
"dashboard.zone.addBlocked": "Add blocked day",
"dashboard.zone.reason": "Reason",
"dashboard.zone.noZones": "No zones yet. Add your first one.",
"dashboard.billing.planUsage": "Plan usage",
"dashboard.billing.locations": "Locations",
"dashboard.billing.bookings": "Bookings",
"dashboard.billing.staff": "Staff",
"dashboard.billing.period": "Period",
"dashboard.billing.currentPlan": "Current plan",
"dashboard.billing.nextBilling": "Next billing",
"dashboard.settings.businessInfo": "Business information",
"dashboard.settings.branding": "Branding & appearance",
"dashboard.settings.emailNotifications": "Email notifications",
"dashboard.settings.emailSubject": "Subject",
"dashboard.settings.emailBody": "Body",
"dashboard.settings.save": "Save",
"dashboard.bookingModal.customer": "Customer",
"dashboard.bookingModal.service": "Service",
"dashboard.bookingModal.location": "Location",
"dashboard.bookingModal.dateTime": "Date & time",
"dashboard.bookingModal.duration": "Duration",
"dashboard.bookingModal.status": "Status",
"dashboard.bookingModal.reference": "Reference",
"dashboard.bookingModal.notes": "Notes",
"dashboard.bookingModal.reschedule": "Reschedule",
"dashboard.bookingModal.confirmCancel": "Are you sure you want to cancel this booking?",
"dashboard.bookingModal.createdAt": "Created",
"dashboard.bookingModal.assignedTo": "Assigned to",
"dashboard.bookingModal.noAssign": "Not assigned",
"dashboard.bookingModal.email": "Email",
"dashboard.bookingModal.phone": "Phone",
"dashboard.empty.title": "Nothing here yet",
"dashboard.empty.bookingsDesc": "No bookings match your filter.",
"dashboard.empty.customersDesc": "You don't have any customers yet.",
"dashboard.notification.newBooking": "New booking from",
"dashboard.notification.reminder": "Reminder: booking at",
"dashboard.notification.upgrade": "You're nearing your plan limit",
"dashboard.notification.trialEnding": "Your trial ends in 3 days",
"booking.title": "Book a visit",
"booking.body": "Choose an available time, add your contact details, and receive confirmation by email.",
"booking.slots": "Available times",
@@ -737,6 +902,7 @@ const dictionaries = {
"booking.customer.body": "These details are used for confirmation and reminders.",
"booking.customer.name": "Name",
"booking.customer.email": "Email",
"booking.customer.phone": "Phone",
"booking.customer.notes": "Note",
"booking.customerRequired": "Add your name and email before booking.",
"booking.failed": "Booking failed",
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,7 @@ export function PublicBookingRoute() {
const [submittingSlot, setSubmittingSlot] = createSignal<string | null>(null);
const [customerName, setCustomerName] = createSignal("");
const [customerEmail, setCustomerEmail] = createSignal("");
const [customerPhone, setCustomerPhone] = createSignal("");
const [notes, setNotes] = createSignal("");
const [highlightContact, setHighlightContact] = createSignal(false);
let contactFormRef: HTMLDivElement | undefined;
@@ -59,6 +60,7 @@ export function PublicBookingRoute() {
locationId: slot.locationId ?? undefined,
customerName: customerName().trim(),
customerEmail: customerEmail().trim(),
customerPhone: customerPhone().trim() || undefined,
notes: notes().trim(),
startsAt: slot.startsAt,
endsAt: slot.endsAt,
@@ -132,6 +134,13 @@ export function PublicBookingRoute() {
type="email"
value={customerEmail()}
/>
<Input
autocomplete="tel"
label={i18n.t("booking.customer.phone")}
onInput={(event) => setCustomerPhone(event.currentTarget.value)}
type="tel"
value={customerPhone()}
/>
<Textarea
label={i18n.t("booking.customer.notes")}
onInput={(event) => setNotes(event.currentTarget.value)}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/bookra-character.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/shell.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/paddle.ts","./src/lib/sentry.ts","./src/lib/stripe.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/dashboard-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./vite.config.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/bookra-character.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/paddle.ts","./src/lib/sentry.ts","./src/lib/stripe.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/dashboard-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./vite.config.ts"],"version":"5.9.3"}