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