mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
feat(sms): implement SMS messaging and metered billing
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:
@@ -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)} Kč`;
|
||||
}
|
||||
|
||||
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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user