mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
Services declare up to 8 intake fields (text/yesno, required) stored as JSONB; shared/intake.go is the single normalization point — slug keys, label/dup/unknown-key rules, yes/no canonicalization. Booking create validates answers against the service declaration (missing required -> 400), unknown keys are dropped before persist. Answers surface on UpcomingBooking and CustomerBookingView; the services page gains a compact editor and the public form renders declared questions under contact details. Verified live end-to-end. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
337 lines
17 KiB
TypeScript
337 lines
17 KiB
TypeScript
import { createResource, createMemo, createSignal, useContext, createContext } from "solid-js";
|
|
import { apiClient } from "../../lib/api-client";
|
|
import { useAuth } from "../../providers/auth-provider";
|
|
import { useI18n } from "../../providers/i18n-provider";
|
|
import { getInitials, getBookingDuration } from "../../components/dashboard/types";
|
|
|
|
export type Section = "overview" | "bookings" | "customers" | "services" | "staff" | "zones" | "billing" | "settings";
|
|
|
|
function createDemoData(i18n: ReturnType<typeof useI18n>) {
|
|
const cs = i18n.locale() === "cs";
|
|
const fmt = (d: Date) => d.toISOString();
|
|
// Exact local clock time on a day relative to today.
|
|
const at = (dayOffset: number, hour: number, minute = 0) => {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + dayOffset);
|
|
d.setHours(hour, minute, 0, 0);
|
|
return d;
|
|
};
|
|
|
|
const services = cs
|
|
? ["Masáž", "Kosmetika", "Fyzioterapie", "Manikúra"]
|
|
: ["Massage", "Cosmetics", "Physiotherapy", "Manicure"];
|
|
const customers = [
|
|
{ name: cs ? "Martina Nováková" : "Martina Novakova", email: "[email protected]" },
|
|
{ name: "David Svoboda", email: "[email protected]" },
|
|
{ name: cs ? "Jana Kovářová" : "Jana Kovarova", email: "[email protected]" },
|
|
{ name: "Alice Johnson", email: "[email protected]" },
|
|
{ name: "Bob Smith", email: "[email protected]" },
|
|
];
|
|
const salonA = cs ? "Hlavní sál" : "Main hall";
|
|
const salonB = "Studio B";
|
|
const staff1 = "Petra M.";
|
|
const staff2 = "Lucie K.";
|
|
|
|
const allBookings = [
|
|
// past days - completed/cancelled history for the trend chart
|
|
{ id: "b10", customerName: customers[4].name, customerEmail: customers[4].email, service: services[1], status: "completed", startsAt: fmt(at(-4, 10)), endsAt: fmt(at(-4, 11)), reference: "BOK-010", location: salonB, staffName: "", notes: "" },
|
|
{ id: "b5", customerName: customers[4].name, customerEmail: customers[4].email, service: services[0], status: "completed", startsAt: fmt(at(-2, 13, 30)), endsAt: fmt(at(-2, 14, 30)), reference: "BOK-005", location: salonB, staffName: staff2, notes: "" },
|
|
{ id: "b12", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "completed", startsAt: fmt(at(-2, 9)), endsAt: fmt(at(-2, 10)), reference: "BOK-012", location: salonA, staffName: staff1, notes: "" },
|
|
{ id: "b4", customerName: customers[3].name, customerEmail: customers[3].email, service: services[3], status: "cancelled", startsAt: fmt(at(-1, 11)), endsAt: fmt(at(-1, 12)), reference: "BOK-004", location: salonA, staffName: staff1, notes: "" },
|
|
{ id: "b11", customerName: customers[2].name, customerEmail: customers[2].email, service: services[0], status: "completed", startsAt: fmt(at(-1, 15, 30)), endsAt: fmt(at(-1, 16, 30)), reference: "BOK-011", location: salonA, staffName: staff2, notes: "" },
|
|
// today
|
|
{ id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(at(0, 10)), endsAt: fmt(at(0, 11)), reference: "BOK-001", location: salonA, staffName: staff1, notes: "", intake: cs ? { "zdravotni_poznamky": "Citlivá pokožka", "prvni_navsteva": "yes" } : { "health_notes": "Sensitive skin", "first_visit": "yes" } },
|
|
{ id: "b7", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "confirmed", startsAt: fmt(at(0, 16)), endsAt: fmt(at(0, 17)), reference: "BOK-007", location: salonB, staffName: "", notes: "" },
|
|
// upcoming
|
|
{ id: "b2", customerName: customers[1].name, customerEmail: customers[1].email, service: services[1], status: "pending", startsAt: fmt(at(1, 14)), endsAt: fmt(at(1, 15)), reference: "BOK-002", location: salonA, staffName: staff2, notes: "" },
|
|
{ id: "b3", customerName: customers[2].name, customerEmail: customers[2].email, service: services[2], status: "confirmed", startsAt: fmt(at(2, 9)), endsAt: fmt(at(2, 10)), reference: "BOK-003", location: salonB, staffName: "", notes: cs ? "Bolest zad" : "Back pain" },
|
|
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" },
|
|
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" },
|
|
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" },
|
|
{ id: "b13", customerName: customers[0].name, customerEmail: customers[0].email, service: cs ? "Jóga pro začátečníky" : "Beginner yoga", status: "waitlisted", waitlistPosition: 1, startsAt: fmt(at(3, 18)), endsAt: fmt(at(3, 19)), reference: "BOK-013", location: salonA, staffName: "", notes: "" },
|
|
];
|
|
|
|
return {
|
|
summary: {
|
|
tenantName: cs ? "Demo Studio" : "Demo Studio",
|
|
tenantSlug: "studio-atelier",
|
|
publicBookingUrl: "http://localhost:3000/book/studio-atelier",
|
|
kpis: [
|
|
{ label: i18n.t("dashboard.kpi.bookings"), value: 42, change: "+12%", trend: "up" },
|
|
{ label: i18n.t("dashboard.kpi.cancelled"), value: 3, change: "-5%", trend: "down" },
|
|
{ label: i18n.t("dashboard.kpi.completed"), value: 38, change: "+8%", trend: "up" },
|
|
{ label: i18n.t("dashboard.kpi.newClients"), value: 12, change: "+24%", trend: "up" },
|
|
],
|
|
staff: [{ id: "s1", name: cs ? "Petra M." : "Petra M.", role: cs ? "Specialista" : "Specialist", email: "[email protected]" }],
|
|
upcomingBookings: allBookings.filter(b => b.status !== "cancelled" && b.status !== "completed"),
|
|
allBookings,
|
|
recentActivity: [
|
|
{ action: cs ? "Nová rezervace" : "New booking", detail: `${customers[0].name} - ${services[0]}`, time: cs ? "Dnes, 10:00" : "Today, 10:00", type: "booking" },
|
|
{ action: cs ? "Zrušení rezervace" : "Booking cancelled", detail: `${customers[3].name} - ${services[3]}`, time: cs ? "Včera" : "Yesterday", type: "cancel" },
|
|
{ action: cs ? "Dokončená rezervace" : "Booking completed", detail: `${customers[4].name} - ${services[1]}`, time: cs ? "Před 2 dny" : "2 days ago", type: "reminder" },
|
|
],
|
|
},
|
|
bootstrap: {
|
|
tenantName: cs ? "Demo Studio" : "Demo Studio",
|
|
slug: "studio-atelier",
|
|
brand: { primaryColor: "#c25e3a" },
|
|
locale: cs ? "cs" : "en",
|
|
timezone: "Europe/Prague",
|
|
},
|
|
billing: {
|
|
planCode: "pro",
|
|
planName: "Pro",
|
|
subscriptionStatus: "trialing",
|
|
trialDaysRemaining: 12,
|
|
entitlements: { maxLocations: 3, maxBookings: -1, maxStaff: 10 },
|
|
billingProvider: "stripe",
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface DashboardData {
|
|
i18n: ReturnType<typeof useI18n>;
|
|
auth: ReturnType<typeof useAuth>;
|
|
token: () => string | null | undefined;
|
|
isDemoMode: () => boolean;
|
|
resolvedSummary: () => any;
|
|
refetchSummary: () => void;
|
|
resolvedBootstrap: () => any;
|
|
resolvedBilling: () => any;
|
|
normalizedKpis: () => any[];
|
|
normalizedUpcomingBookings: () => any[];
|
|
normalizedRecentActivity: () => any[];
|
|
normalizedAllBookings: () => any[];
|
|
isDashboardReady: () => boolean;
|
|
locationData: () => number;
|
|
smsUsage: () => any;
|
|
demoNotice: () => string | null;
|
|
setDemoNotice: (v: string | null) => void;
|
|
billingNotice: () => string | null;
|
|
setBillingNotice: (v: string | null) => void;
|
|
billingError: () => string | null;
|
|
setBillingError: (v: string | null) => void;
|
|
billingAction: () => "checkout" | "refresh" | "portal" | null;
|
|
setBillingAction: (v: "checkout" | "refresh" | "portal" | null) => void;
|
|
billingInterval: () => "monthly" | "yearly";
|
|
setBillingInterval: (v: "monthly" | "yearly") => void;
|
|
openCheckout: (planCode?: string) => Promise<void>;
|
|
openPortal: () => Promise<void>;
|
|
refreshBilling: () => Promise<void>;
|
|
showIntegrationModal: () => boolean;
|
|
setShowIntegrationModal: (v: boolean) => void;
|
|
showBookingDetail: () => boolean;
|
|
setShowBookingDetail: (v: boolean) => void;
|
|
selectedBooking: () => any;
|
|
setSelectedBooking: (v: any) => void;
|
|
openBookingDetail: (booking: any) => void;
|
|
}
|
|
|
|
const DashboardContext = createContext<DashboardData>();
|
|
|
|
export function useDashboardData() {
|
|
const ctx = useContext(DashboardContext);
|
|
if (!ctx) throw new Error("useDashboardData must be used within DashboardContext provider");
|
|
return ctx;
|
|
}
|
|
|
|
export function DashboardDataProvider(props: { children: any }) {
|
|
const i18n = useI18n();
|
|
const auth = useAuth();
|
|
const [billingInterval, setBillingInterval] = createSignal<"monthly" | "yearly">("monthly");
|
|
const [billingNotice, setBillingNotice] = createSignal<string | null>(null);
|
|
const [billingError, setBillingError] = createSignal<string | null>(null);
|
|
const [billingAction, setBillingAction] = createSignal<"checkout" | "refresh" | "portal" | null>(null);
|
|
const [showIntegrationModal, setShowIntegrationModal] = createSignal(false);
|
|
const [demoNotice, setDemoNotice] = createSignal<string | null>(null);
|
|
const [showBookingDetail, setShowBookingDetail] = createSignal(false);
|
|
const [selectedBooking, setSelectedBooking] = createSignal<any>(null);
|
|
|
|
const [token] = createResource(() => auth.session()?.session?.id, () => auth.getToken());
|
|
|
|
const isDemoMode = () => token()?.startsWith("demo.") ?? false;
|
|
const demoData = createMemo(() => createDemoData(i18n));
|
|
|
|
const [summary, { refetch: refetchSummary }] = createResource(token, async (bearer) => {
|
|
if (!bearer) return null;
|
|
if (bearer.startsWith("demo.")) return demoData().summary;
|
|
const response = await apiClient.GET("/v1/dashboard/summary", { headers: { Authorization: `Bearer ${bearer}` } });
|
|
return response.data ?? null;
|
|
});
|
|
|
|
const [bootstrap] = createResource(token, async (bearer) => {
|
|
if (!bearer) return null;
|
|
if (bearer.startsWith("demo.")) return demoData().bootstrap;
|
|
const response = await apiClient.GET("/v1/tenants/bootstrap", { headers: { Authorization: `Bearer ${bearer}` } });
|
|
return response.data ?? null;
|
|
});
|
|
|
|
const [billing, { refetch: refetchBilling }] = createResource(token, async (bearer) => {
|
|
if (!bearer) return null;
|
|
if (bearer.startsWith("demo.")) return demoData().billing;
|
|
const response = await apiClient.GET("/v1/billing/subscription", { headers: { Authorization: `Bearer ${bearer}` } });
|
|
return response.data ?? null;
|
|
});
|
|
|
|
const [locationData] = createResource(token, async (bearer) => {
|
|
if (!bearer) return 0;
|
|
if (bearer.startsWith("demo.")) return 1;
|
|
try {
|
|
const response = await (apiClient as any).GET("/v1/catalog/locations", { headers: { Authorization: `Bearer ${bearer}` } });
|
|
return (response.data as any[])?.length ?? 0;
|
|
} catch { return 0; }
|
|
});
|
|
|
|
const [smsUsage] = createResource(token, async (bearer) => {
|
|
if (!bearer || bearer.startsWith("demo.")) return null;
|
|
try {
|
|
const response = await (apiClient as any).GET("/v1/sms/usage", { headers: { Authorization: `Bearer ${bearer}` } });
|
|
return response.data as any ?? null;
|
|
} catch { return null; }
|
|
});
|
|
|
|
// Demo data is reactive to locale changes — prefer it over the stale resource value.
|
|
const resolvedSummary = () => (isDemoMode() ? demoData().summary : (summary.latest as any)) ?? undefined;
|
|
const resolvedBootstrap = () => (isDemoMode() ? demoData().bootstrap : (bootstrap.latest as any)) ?? undefined;
|
|
|
|
const planNames: Record<string, string> = { starter: "Starter", pro: "Pro", business: "Business" };
|
|
const resolvedBilling = () => {
|
|
const raw = (isDemoMode() ? demoData().billing : (billing.latest as any)) ?? undefined;
|
|
if (!raw) return undefined;
|
|
const ent = raw.entitlements ?? {};
|
|
return {
|
|
...raw,
|
|
subscriptionStatus: raw.status ?? raw.subscriptionStatus ?? "inactive",
|
|
planName: planNames[raw.planCode] ?? raw.planCode ?? "Starter",
|
|
trialDaysRemaining: raw.trialDays ?? raw.trialDaysRemaining ?? 0,
|
|
entitlements: {
|
|
...ent,
|
|
maxBookings: ent.maxBookingsMonth ?? ent.maxBookings,
|
|
},
|
|
};
|
|
};
|
|
|
|
const kpiLabelKeys: Record<string, string> = {
|
|
bookings_this_week: "dashboard.kpi.bookingsThisWeek",
|
|
completed: "dashboard.kpi.completed",
|
|
cancellations: "dashboard.kpi.cancellations",
|
|
utilization: "dashboard.kpi.utilization",
|
|
new_clients: "dashboard.kpi.newClients",
|
|
};
|
|
const normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({
|
|
...kpi,
|
|
label: kpi.code && kpiLabelKeys[kpi.code] ? i18n.t(kpiLabelKeys[kpi.code]) : kpi.label,
|
|
trend: kpi.trend ?? "neutral",
|
|
change: kpi.change ?? "—",
|
|
})));
|
|
|
|
const normalizedUpcomingBookings = createMemo(() => (resolvedSummary()?.upcomingBookings ?? []).map((booking: any) => ({
|
|
...booking,
|
|
avatar: booking.avatar ?? getInitials(booking.customerName),
|
|
service: booking.service ?? booking.label ?? booking.reference ?? (i18n.locale() === "cs" ? "Rezervace" : "Booking"),
|
|
duration: booking.duration ?? getBookingDuration(booking.startsAt, booking.endsAt),
|
|
})));
|
|
|
|
const normalizedRecentActivity = createMemo(() => {
|
|
if (resolvedSummary()?.recentActivity?.length) return resolvedSummary().recentActivity;
|
|
return normalizedUpcomingBookings().slice(0, 5).map((booking: any) => ({
|
|
action: i18n.locale() === "cs" ? "Nadcházející rezervace" : "Upcoming booking",
|
|
detail: `${booking.customerName} \u2022 ${booking.service}`,
|
|
time: new Date(booking.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
|
|
type: "booking",
|
|
}));
|
|
});
|
|
|
|
const normalizedAllBookings = createMemo(() => resolvedSummary()?.allBookings ?? normalizedUpcomingBookings());
|
|
|
|
const isDashboardReady = () => !!token() && !bootstrap.loading && !summary.loading && !billing.loading;
|
|
|
|
const openCheckout = async (planCode?: string) => {
|
|
const bearer = token();
|
|
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze upravovat předplatné." : "Cannot modify billing in demo mode."); return; }
|
|
setBillingAction("checkout");
|
|
setBillingError(null);
|
|
try {
|
|
const response = await (apiClient as any).POST("/v1/billing/checkout", {
|
|
headers: { Authorization: `Bearer ${bearer}` },
|
|
body: { planCode: planCode ?? resolvedBilling()?.planCode ?? "pro", currency: "czk", billingInterval: billingInterval() },
|
|
});
|
|
if (response.data?.checkoutUrl) window.location.href = response.data.checkoutUrl;
|
|
else throw new Error(response.error ? String(response.error) : "No checkout URL");
|
|
} catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); }
|
|
finally { setBillingAction(null); }
|
|
};
|
|
|
|
const openPortal = async () => {
|
|
const bearer = token();
|
|
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze upravovat předplatné." : "Cannot modify billing in demo mode."); return; }
|
|
setBillingAction("portal");
|
|
setBillingError(null);
|
|
try {
|
|
const response = await (apiClient as any).POST("/v1/billing/portal", { headers: { Authorization: `Bearer ${bearer}` } });
|
|
if (response.data?.url) window.location.href = response.data.url;
|
|
else throw new Error("No portal URL");
|
|
} catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); }
|
|
finally { setBillingAction(null); }
|
|
};
|
|
|
|
const refreshBilling = async () => {
|
|
const bearer = token();
|
|
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze obnovit předplatné." : "Cannot refresh billing in demo mode."); return; }
|
|
setBillingAction("refresh");
|
|
setBillingError(null);
|
|
try {
|
|
const response = await (apiClient as any).POST("/v1/billing/refresh", { headers: { Authorization: `Bearer ${bearer}` } });
|
|
if (response.data) { setBillingNotice(i18n.locale() === "cs" ? "Předplatné obnoveno." : "Billing refreshed."); void refetchBilling(); }
|
|
} catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); }
|
|
finally { setBillingAction(null); }
|
|
};
|
|
|
|
const openBookingDetail = (booking: any) => {
|
|
setSelectedBooking(booking);
|
|
setShowBookingDetail(true);
|
|
};
|
|
|
|
const value: DashboardData = {
|
|
i18n,
|
|
auth,
|
|
token: () => token(),
|
|
isDemoMode,
|
|
resolvedSummary,
|
|
refetchSummary,
|
|
resolvedBootstrap,
|
|
resolvedBilling,
|
|
normalizedKpis,
|
|
normalizedUpcomingBookings,
|
|
normalizedRecentActivity,
|
|
normalizedAllBookings,
|
|
isDashboardReady,
|
|
locationData: () => locationData() ?? 0,
|
|
smsUsage: () => smsUsage.latest,
|
|
demoNotice,
|
|
setDemoNotice,
|
|
billingNotice,
|
|
setBillingNotice,
|
|
billingError,
|
|
setBillingError,
|
|
billingAction,
|
|
setBillingAction,
|
|
billingInterval,
|
|
setBillingInterval,
|
|
openCheckout,
|
|
openPortal,
|
|
refreshBilling,
|
|
showIntegrationModal,
|
|
setShowIntegrationModal,
|
|
showBookingDetail,
|
|
setShowBookingDetail,
|
|
selectedBooking,
|
|
setSelectedBooking,
|
|
openBookingDetail,
|
|
};
|
|
|
|
return value;
|
|
}
|
|
|
|
export { DashboardContext };
|