Files
Bookra/apps/frontend/src/components/sms-settings.tsx
T
Tomas Dvorak 7d3e3448cf
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
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.
2026-05-10 11:40:53 +02:00

330 lines
12 KiB
TypeScript

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>
);
}