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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user