mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
refactor(db): replace sqlc with handwritten pgx repos
- Finish Paddle-to-Stripe billing migration; drop paddle/stripe libs - Renumber migrations; add 00012 memberships+manage_token, 00013 location_type_capacity - Add tests: admin, auth, bookings customer service - Remove graphify-out artifacts and stale env files Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2039669e2c
commit
f2f7439050
93 files changed
+6987
-69060
No files matched your search
@@ -1,10 +1,13 @@
|
||||
VITE_BOOKRA_APP_ENV=staging
|
||||
VITE_BOOKRA_API_URL=http://localhost:8080
|
||||
|
||||
# Primary production auth path: Neon Auth.
|
||||
VITE_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
|
||||
VITE_PADDLE_ENV=sandbox
|
||||
VITE_PADDLE_CLIENT_TOKEN=
|
||||
VITE_NEON_AUTH_URL=https://ep-example.neonauth.region.aws.neon.tech/neondb/auth
|
||||
|
||||
VITE_DEFAULT_LOCALE=cs
|
||||
|
||||
# Demo Mode: Set to true to show demo UI indicators
|
||||
VITE_BOOKRA_DEMO_MODE=false
|
||||
|
||||
# Optional error monitoring
|
||||
VITE_SENTRY_DSN=
|
||||
@@ -12,11 +12,9 @@
|
||||
"dependencies": {
|
||||
"@bookra/api-client": "0.1.0",
|
||||
"@bookra/shared-types": "0.1.0",
|
||||
"@neondatabase/neon-js": "^0.2.0-beta.1",
|
||||
"@paddle/paddle-js": "^1.3.2",
|
||||
"@neondatabase/neon-js": "^0.7.0-beta",
|
||||
"@sentry/react": "^10.52.0",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@stripe/stripe-js": "^4.0.0",
|
||||
"solid-js": "^1.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -83,17 +83,29 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin
|
||||
return calendarDays().find((d) => d.day === day)?.bookings ?? [];
|
||||
});
|
||||
|
||||
// Booking visual density for bars
|
||||
const eventDot = (status: string) =>
|
||||
status === "confirmed" ? "bg-accent" : status === "pending" ? "bg-ink-subtle" : "bg-border-strong";
|
||||
|
||||
// Up to two readable event chips, then a "+N" overflow marker.
|
||||
const getBookingBars = (bookings: any[]) => {
|
||||
if (bookings.length === 0) return null;
|
||||
const count = bookings.length;
|
||||
const hasMultiple = count >= 2;
|
||||
const hasMany = count >= 3;
|
||||
const visible = bookings.slice(0, 2);
|
||||
const overflow = bookings.length - visible.length;
|
||||
return (
|
||||
<div class="mt-2 space-y-1.5">
|
||||
<div class="h-2 bg-accent/30 rounded-full w-full group-hover:bg-accent/40 transition-colors" />
|
||||
{hasMultiple && <div class="h-2 bg-accent/20 rounded-full w-3/4 group-hover:bg-accent/30 transition-colors" />}
|
||||
{hasMany && <div class="h-2 bg-accent/10 rounded-full w-1/2 group-hover:bg-accent/20 transition-colors" />}
|
||||
<div class="mt-1.5 space-y-1">
|
||||
<For each={visible}>
|
||||
{(b) => (
|
||||
<div class="flex items-center gap-1 rounded-md bg-accent-subtle/60 px-1.5 py-0.5 group-hover:bg-accent-subtle transition-colors">
|
||||
<span class={`w-1.5 h-1.5 rounded-full shrink-0 ${eventDot(b.status)}`} />
|
||||
<span class="text-[10px] font-medium text-ink leading-tight whitespace-nowrap">
|
||||
{new Date(b.startsAt).toLocaleTimeString(isCs() ? "cs-CZ" : "en-US", { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={overflow > 0}>
|
||||
<p class="text-[10px] font-medium text-ink-subtle pl-1">+{overflow} {isCs() ? "další" : "more"}</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -145,29 +157,25 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin
|
||||
<div class={`grid grid-cols-7 gap-px bg-border/50 transition-opacity duration-200 ${isAnimating() ? "opacity-0" : "opacity-100"}`}>
|
||||
{calendarDays().map(({ day, bookings, isToday }) => {
|
||||
if (!day) {
|
||||
return <div class="p-3 min-h-[80px] lg:min-h-[100px] bg-canvas/50" />;
|
||||
return <div class="p-2 min-h-[80px] lg:min-h-[96px] bg-canvas/50" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleDayClick(day)}
|
||||
class={`
|
||||
p-3 min-h-[80px] lg:min-h-[100px] bg-canvas transition-all duration-200 hover:bg-canvas-subtle group relative text-left
|
||||
${isToday ? "ring-2 ring-inset ring-accent bg-accent-soft" : ""}
|
||||
${selectedDay() === day ? "ring-2 ring-inset ring-accent bg-accent-subtle/30" : ""}
|
||||
${bookings.length > 0 && !isToday ? "bg-accent-subtle/10" : ""}
|
||||
p-2 min-h-[80px] lg:min-h-[96px] bg-canvas transition-all duration-200 hover:bg-canvas-subtle group relative text-left flex flex-col
|
||||
${isToday ? "bg-accent-soft" : ""}
|
||||
${selectedDay() === day ? "bg-accent-subtle/40" : ""}
|
||||
`}
|
||||
>
|
||||
<span class={`text-sm transition-colors ${isToday ? "font-semibold text-accent" : "text-ink-muted group-hover:text-ink"}`}>
|
||||
<span class={`inline-flex items-center justify-center w-6 h-6 rounded-full text-sm transition-colors ${
|
||||
isToday
|
||||
? "bg-accent text-canvas font-semibold"
|
||||
: "text-ink-muted group-hover:text-ink"
|
||||
}`}>
|
||||
{day}
|
||||
</span>
|
||||
{getBookingBars(bookings)}
|
||||
{bookings.length > 0 && (
|
||||
<div class="absolute top-2 right-2">
|
||||
<span class="text-[10px] font-semibold text-accent bg-accent/10 px-1.5 py-0.5 rounded-full">
|
||||
{bookings.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -26,8 +26,8 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any })
|
||||
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" }),
|
||||
message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US")}`,
|
||||
time: new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US", { month: "short", day: "numeric" }),
|
||||
read: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
import { createMemo } from "solid-js";
|
||||
import { createMemo, Show } from "solid-js";
|
||||
import { useI18n } from "../../providers/i18n-provider";
|
||||
|
||||
interface DataPoint {
|
||||
label: string;
|
||||
dateLabel: string;
|
||||
value: number;
|
||||
isToday: boolean;
|
||||
isFuture: boolean;
|
||||
}
|
||||
|
||||
export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) {
|
||||
const i18n = useI18n();
|
||||
const isCs = () => i18n.locale() === "cs";
|
||||
|
||||
const chartData = createMemo(() => {
|
||||
const chartData = createMemo<DataPoint[]>(() => {
|
||||
if (props.data && props.data.length > 0) return props.data;
|
||||
|
||||
// Build last 7 days from bookings
|
||||
const days: DataPoint[] = [];
|
||||
// Bucket bookings into the current calendar week (Mon-Sun) so both
|
||||
// completed and upcoming bookings are visible.
|
||||
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 monday = new Date(today);
|
||||
monday.setDate(today.getDate() - ((today.getDay() + 6) % 7));
|
||||
|
||||
const days: DataPoint[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(monday);
|
||||
d.setDate(monday.getDate() + i);
|
||||
const isToday = d.toDateString() === today.toDateString();
|
||||
const count = bookings.filter((b: any) => {
|
||||
const bd = new Date(b.startsAt).toISOString().split("T")[0];
|
||||
return bd === dateStr;
|
||||
const bd = new Date(b.startsAt);
|
||||
return bd.getFullYear() === d.getFullYear() && bd.getMonth() === d.getMonth() && bd.getDate() === d.getDate();
|
||||
}).length;
|
||||
days.push({
|
||||
label: d.toLocaleDateString(i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short" }),
|
||||
label: d.toLocaleDateString(isCs() ? "cs-CZ" : "en-US", { weekday: "short" }),
|
||||
dateLabel: d.toLocaleDateString(isCs() ? "cs-CZ" : "en-US", { day: "numeric", month: "numeric" }),
|
||||
value: count,
|
||||
isToday,
|
||||
isFuture: d > today,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
@@ -36,34 +47,46 @@ export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) {
|
||||
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 class="surface-card p-6 h-full flex flex-col">
|
||||
<div class="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-ink">{i18n.t("dashboard.revenueTitle")}</h3>
|
||||
<h3 class="text-lg font-semibold text-ink font-display">{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>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-3xl font-bold text-ink tracking-tight font-display">{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) => {
|
||||
<div class="flex items-end gap-2 sm:gap-3 flex-1 min-h-40">
|
||||
{chartData().map((point) => {
|
||||
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="flex-1 flex flex-col items-center gap-2 h-full" title={`${point.dateLabel}: ${point.value}`}>
|
||||
<div class="w-full flex-1 flex items-end relative group">
|
||||
<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">
|
||||
class={`w-full rounded-t-lg transition-all duration-300 ${
|
||||
point.isToday
|
||||
? "bg-accent"
|
||||
: point.isFuture
|
||||
? "bg-accent/40 group-hover:bg-accent/60"
|
||||
: "bg-accent/70 group-hover:bg-accent"
|
||||
}`}
|
||||
style={{ height: `${Math.max(heightPct, point.value > 0 ? 8 : 3)}%`, "min-height": point.value > 0 ? "6px" : "3px", opacity: point.value === 0 ? 0.15 : 1 }}
|
||||
/>
|
||||
<Show when={point.value > 0}>
|
||||
<span class={`absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-semibold ${point.isToday ? "text-accent" : "text-ink-muted"} opacity-0 group-hover:opacity-100 transition-opacity`}>
|
||||
{point.value}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<span class={`text-[11px] font-medium uppercase tracking-wide ${point.isToday ? "text-accent font-semibold" : "text-ink-subtle"}`}>
|
||||
{point.label}
|
||||
</span>
|
||||
<p class={`text-[10px] leading-tight ${point.isToday ? "text-accent" : "text-ink-subtle/70"}`}>{point.dateLabel}</p>
|
||||
</div>
|
||||
<span class="text-[10px] sm:text-xs text-ink-muted font-medium uppercase">{point.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -82,21 +82,21 @@ export function PinnedList(props: PinnedListProps) {
|
||||
const ItemCard = (p: { item: PinnedListItem; pinned: boolean }) => {
|
||||
const content = (
|
||||
<div
|
||||
class={`flex items-center gap-3 rounded-xl px-3 py-3 border transition-all duration-200 ${
|
||||
class={`flex items-center gap-2.5 rounded-lg px-2.5 py-2.5 border transition-all duration-200 ${
|
||||
p.pinned
|
||||
? "bg-accent-soft border-accent/20"
|
||||
: "bg-canvas-subtle/40 border-border/50 hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<Show when={p.item.icon}>
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-canvas border border-border text-ink-muted">
|
||||
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-canvas border border-border text-ink-muted">
|
||||
{p.item.icon}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-ink">{p.item.name}</p>
|
||||
<p class="truncate text-xs text-ink-muted">{p.item.subtitle}</p>
|
||||
<p class="truncate text-[13px] font-medium text-ink">{p.item.name}</p>
|
||||
<p class="truncate text-[11px] text-ink-muted">{p.item.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<DotToggle id={p.item.id} />
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { initializePaddle, type Paddle } from "@paddle/paddle-js";
|
||||
|
||||
const paddleToken = import.meta.env.VITE_PADDLE_CLIENT_TOKEN ?? "";
|
||||
const paddleEnvironment = (import.meta.env.VITE_PADDLE_ENV ?? "sandbox").toLowerCase() === "live" ? "production" : "sandbox";
|
||||
|
||||
let paddlePromise: Promise<Paddle | undefined> | null = null;
|
||||
|
||||
export function paddleConfigured() {
|
||||
return paddleToken.trim() !== "";
|
||||
}
|
||||
|
||||
export async function getPaddle() {
|
||||
if (!paddleConfigured()) {
|
||||
return undefined;
|
||||
}
|
||||
if (!paddlePromise) {
|
||||
paddlePromise = initializePaddle({
|
||||
environment: paddleEnvironment,
|
||||
token: paddleToken,
|
||||
});
|
||||
}
|
||||
return paddlePromise;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||
|
||||
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
|
||||
let stripePromise: Promise<Stripe | null> | null = null;
|
||||
|
||||
export function stripeConfigured() {
|
||||
return stripePublishableKey.trim() !== "";
|
||||
}
|
||||
|
||||
export async function getStripe() {
|
||||
if (!stripeConfigured()) {
|
||||
return null;
|
||||
}
|
||||
if (!stripePromise) {
|
||||
stripePromise = loadStripe(stripePublishableKey);
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
@@ -348,7 +348,11 @@ const dictionaries = {
|
||||
"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.revenueSubtitle": "Počet rezervací v tomto týdnu",
|
||||
"dashboard.customerStatus.active": "Aktivní",
|
||||
"dashboard.customerStatus.inactive": "Neaktivní",
|
||||
"dashboard.bookingModal.date": "Datum",
|
||||
"dashboard.bookingModal.time": "Čas",
|
||||
"dashboard.noNotifications": "Žádná nová oznámení",
|
||||
"dashboard.markAllRead": "Označit vše jako přečtené",
|
||||
"dashboard.notifications.title": "Oznámení",
|
||||
@@ -512,7 +516,7 @@ const dictionaries = {
|
||||
"legal.terms.service.title": "Používání služby",
|
||||
"legal.terms.service.body": "Službu je nutné používat v souladu se zákonem, bez zneužití rezervačních formulářů, obcházení zabezpečení nebo nahrávání zakázaného obsahu.",
|
||||
"legal.terms.billing.title": "Předplatné",
|
||||
"legal.terms.billing.body": "Placené plány se účtují přes Paddle. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce.",
|
||||
"legal.terms.billing.body": "Placené plány se účtují přes Stripe. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce.",
|
||||
},
|
||||
en: {
|
||||
// Navigation & Auth
|
||||
@@ -853,7 +857,11 @@ const dictionaries = {
|
||||
"dashboard.onboarding.submit": "Create workspace",
|
||||
"dashboard.onboarding.pending": "Creating workspace...",
|
||||
"dashboard.revenueTitle": "Booking trend",
|
||||
"dashboard.revenueSubtitle": "Bookings over the last 7 days",
|
||||
"dashboard.revenueSubtitle": "Bookings this week",
|
||||
"dashboard.customerStatus.active": "Active",
|
||||
"dashboard.customerStatus.inactive": "Inactive",
|
||||
"dashboard.bookingModal.date": "Date",
|
||||
"dashboard.bookingModal.time": "Time",
|
||||
"dashboard.noNotifications": "No new notifications",
|
||||
"dashboard.markAllRead": "Mark all as read",
|
||||
"dashboard.notifications.title": "Notifications",
|
||||
@@ -1017,7 +1025,7 @@ const dictionaries = {
|
||||
"legal.terms.service.title": "Using the service",
|
||||
"legal.terms.service.body": "The service must be used lawfully, without abusing booking forms, bypassing security, or uploading prohibited content.",
|
||||
"legal.terms.billing.title": "Subscription",
|
||||
"legal.terms.billing.body": "Paid plans are billed through Paddle. The active plan controls limits, add-ons, and support features.",
|
||||
"legal.terms.billing.body": "Paid plans are billed through Stripe. The active plan controls limits, add-ons, and support features.",
|
||||
},
|
||||
} satisfies Record<Locale, Record<string, string>>;
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -20,11 +20,11 @@ function BillingPage() {
|
||||
{ label: data.i18n.t("dashboard.billing.staff"), value: entitlements?.maxStaff ?? 1, limit: entitlements?.maxStaff ?? 1 },
|
||||
];
|
||||
if (planCode === "pro" || planCode === "business") {
|
||||
base.push({ label: cs ? "Emailove pripominky" : "Email reminders", value: "\u2713", limit: "\u2713" });
|
||||
base.push({ label: cs ? "E-mailové připomínky" : "Email reminders", value: "\u2713", limit: "\u2713" });
|
||||
base.push({ label: cs ? "Analytika" : "Analytics", value: "\u2713", limit: "\u2713" });
|
||||
}
|
||||
if (planCode === "business") {
|
||||
base.push({ label: cs ? "API pristup" : "API access", value: "\u2713", limit: "\u2713" });
|
||||
base.push({ label: cs ? "API přístup" : "API access", value: "\u2713", limit: "\u2713" });
|
||||
}
|
||||
return base;
|
||||
});
|
||||
@@ -56,14 +56,14 @@ function BillingPage() {
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-xl font-bold text-ink font-display">{planName}</h3>
|
||||
{isTrialing && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.locale() === "cs" ? `Zkusebni doba (${trialDays} dnu)` : `Trial (${trialDays} days)`}</span>}
|
||||
{isTrialing && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.locale() === "cs" ? `Zkušební doba (${trialDays} dní)` : `Trial (${trialDays} days)`}</span>}
|
||||
{isActive && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.confirmed")}</span>}
|
||||
</div>
|
||||
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.billing.period")}: {data.billingInterval() === "monthly" ? (data.i18n.locale() === "cs" ? "Mesicne" : "Monthly") : (data.i18n.locale() === "cs" ? "Rocne" : "Yearly")}</p>
|
||||
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.billing.period")}: {data.billingInterval() === "monthly" ? (data.i18n.locale() === "cs" ? "Měsíčně" : "Monthly") : (data.i18n.locale() === "cs" ? "Ročně" : "Yearly")}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button onClick={() => data.setBillingInterval("monthly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "monthly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Mesicne" : "Monthly"}</button>
|
||||
<button onClick={() => data.setBillingInterval("yearly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "yearly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Rocne" : "Yearly"}</button>
|
||||
<button onClick={() => data.setBillingInterval("monthly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "monthly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Měsíčně" : "Monthly"}</button>
|
||||
<button onClick={() => data.setBillingInterval("yearly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "yearly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Ročně" : "Yearly"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,15 +76,44 @@ function BillingPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Plan selection for checkout */}
|
||||
<div class="mt-6">
|
||||
<p class="text-xs text-ink-muted uppercase tracking-wider mb-3">{data.i18n.locale() === "cs" ? "Vybrat plán" : "Select plan"}</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{(["starter", "pro", "business"] as const).map((code) => {
|
||||
const prices = (current?.displayPrices ?? []) as any[];
|
||||
const czk = prices.find((p) => p.currency === "czk");
|
||||
const isCurrent = planCode === code;
|
||||
return (
|
||||
<div class={`p-4 rounded-xl border text-left ${isCurrent ? "border-accent bg-accent-subtle" : "border-border bg-canvas-subtle"}`}>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="font-semibold text-ink capitalize">{code}</p>
|
||||
<Show when={isCurrent}><span class="text-xs text-accent font-medium">{data.i18n.locale() === "cs" ? "Aktuální" : "Current"}</span></Show>
|
||||
</div>
|
||||
<Show when={czk} fallback={<p class="text-sm text-ink-muted">—</p>}>
|
||||
<p class="text-sm text-ink-muted">{data.billingInterval() === "yearly" ? czk.yearlyFormatted : czk.formatted}</p>
|
||||
</Show>
|
||||
<button
|
||||
onClick={() => void data.openCheckout(code)}
|
||||
disabled={data.billingAction() === "checkout"}
|
||||
class={`mt-3 w-full py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${isCurrent ? "bg-accent/15 text-accent" : "bg-accent text-canvas hover:bg-accent-hover"}`}
|
||||
>
|
||||
{isCurrent
|
||||
? (data.i18n.locale() === "cs" ? "Změnit období" : "Change interval")
|
||||
: (data.i18n.locale() === "cs" ? "Vybrat" : "Select")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 mt-6">
|
||||
<button onClick={() => void data.openCheckout()} disabled={data.billingAction() === "checkout"} class="btn-primary disabled:opacity-50">
|
||||
{data.billingAction() === "checkout" ? (data.i18n.locale() === "cs" ? "Otevirani..." : "Opening...") : (isTrialing ? data.i18n.t("dashboard.upgrade") : data.i18n.t("dashboard.checkout"))}
|
||||
</button>
|
||||
<Show when={isActive || isTrialing}>
|
||||
<button onClick={() => void data.openPortal()} class="btn-secondary">{data.i18n.locale() === "cs" ? "Spravovat predplatne" : "Manage subscription"}</button>
|
||||
<button onClick={() => void data.openPortal()} class="btn-secondary">{data.i18n.locale() === "cs" ? "Spravovat předplatné" : "Manage subscription"}</button>
|
||||
</Show>
|
||||
<button onClick={() => void data.refreshBilling()} disabled={data.billingAction() === "refresh"} class="btn-secondary disabled:opacity-50">
|
||||
{data.billingAction() === "refresh" ? (data.i18n.locale() === "cs" ? "Obnovovani..." : "Refreshing...") : data.i18n.t("dashboard.refreshBilling")}
|
||||
{data.billingAction() === "refresh" ? (data.i18n.locale() === "cs" ? "Obnovování…" : "Refreshing…") : data.i18n.t("dashboard.refreshBilling")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ function BookingsPage() {
|
||||
const handleCreateBooking = async () => {
|
||||
const bearer = data.token();
|
||||
if (!bearer || bearer.startsWith("demo.")) {
|
||||
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo rezimu nelze vytvaret rezervace." : "Cannot create bookings in demo mode.");
|
||||
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze vytvářet rezervace." : "Cannot create bookings in demo mode.");
|
||||
setShowNewBooking(false); return;
|
||||
}
|
||||
setCreatingBooking(true);
|
||||
@@ -81,7 +81,7 @@ function BookingsPage() {
|
||||
});
|
||||
setNewBookingCustomer(""); setNewBookingEmail(""); setNewBookingService(""); setNewBookingDate(""); setNewBookingTime(""); setNewBookingNotes("");
|
||||
setShowNewBooking(false);
|
||||
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Vytvoreni rezervace selhalo." : "Failed to create booking."); }
|
||||
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Vytvoření rezervace selhalo." : "Failed to create booking."); }
|
||||
finally { setCreatingBooking(false); }
|
||||
};
|
||||
|
||||
@@ -174,7 +174,7 @@ function BookingsPage() {
|
||||
<p class="font-medium text-ink">{booking.customerName}</p>
|
||||
{isPinned() && <span class="w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
</div>
|
||||
<p class="text-sm text-ink-muted">{booking.service} • {new Date(booking.startsAt).toLocaleDateString()} {new Date(booking.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</p>
|
||||
<p class="text-sm text-ink-muted">{booking.service} • {new Date(booking.startsAt).toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "numeric" })} {new Date(booking.startsAt).toLocaleTimeString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { hour: "2-digit", minute: "2-digit" })}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
@@ -224,8 +224,8 @@ function BookingsPage() {
|
||||
<Input type="email" label={data.i18n.t("booking.customer.email")} value={newBookingEmail()} onInput={(e) => setNewBookingEmail(e.currentTarget.value)} />
|
||||
<Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} />
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input type="date" label={data.i18n.t("dashboard.bookingModal.dateTime")} value={newBookingDate()} onInput={(e) => setNewBookingDate(e.currentTarget.value)} />
|
||||
<Input type="time" label={data.i18n.t("dashboard.bookingModal.duration")} value={newBookingTime()} onInput={(e) => setNewBookingTime(e.currentTarget.value)} />
|
||||
<Input type="date" label={data.i18n.t("dashboard.bookingModal.date")} value={newBookingDate()} onInput={(e) => setNewBookingDate(e.currentTarget.value)} />
|
||||
<Input type="time" label={data.i18n.t("dashboard.bookingModal.time")} value={newBookingTime()} onInput={(e) => setNewBookingTime(e.currentTarget.value)} />
|
||||
</div>
|
||||
<Textarea label={data.i18n.t("booking.customer.notes")} value={newBookingNotes()} onInput={(e) => setNewBookingNotes(e.currentTarget.value)} rows={3} resize="none" />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Show, createSignal, createMemo } from "solid-js";
|
||||
import { Show, createSignal, createMemo, createResource } from "solid-js";
|
||||
import { apiClient } from "../../lib/api-client";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { getInitials } from "../../components/dashboard/types";
|
||||
import { UsersIcon, XIcon } from "../../components/dashboard/icons";
|
||||
@@ -12,14 +13,31 @@ function CustomersPage() {
|
||||
const [pinnedCustomerIds, setPinnedCustomerIds] = createSignal<Set<string>>(new Set());
|
||||
|
||||
const demoCustomers = [
|
||||
{ id: "1", name: "Martina Novakova", email: "[email protected]", phone: "+420 123 456 789", bookingsCount: 5, lastBookingAt: new Date(Date.now() - 86400000).toISOString(), status: "active", notes: "Alergie na silne vune" },
|
||||
{ id: "1", name: "Martina Nováková", email: "[email protected]", phone: "+420 123 456 789", bookingsCount: 5, lastBookingAt: new Date(Date.now() - 86400000).toISOString(), status: "active", notes: "Alergie na silné vůně" },
|
||||
{ id: "2", name: "David Svoboda", email: "[email protected]", phone: "+420 987 654 321", bookingsCount: 3, lastBookingAt: new Date(Date.now() - 172800000).toISOString(), status: "active", notes: "" },
|
||||
{ id: "3", name: "Jana KovarovA", email: "[email protected]", phone: "+420 555 666 777", bookingsCount: 8, lastBookingAt: new Date(Date.now() - 259200000).toISOString(), status: "vip", notes: "Uprednostnuje ranni terminy" },
|
||||
{ id: "3", name: "Jana Kovářová", email: "[email protected]", phone: "+420 555 666 777", bookingsCount: 8, lastBookingAt: new Date(Date.now() - 259200000).toISOString(), status: "vip", notes: "Upřednostňuje ranní termíny" },
|
||||
{ id: "4", name: "Alice Johnson", email: "[email protected]", phone: "+420 111 222 333", bookingsCount: 1, lastBookingAt: new Date(Date.now() - 604800000).toISOString(), status: "inactive", notes: "" },
|
||||
{ id: "5", name: "Bob Smith", email: "[email protected]", phone: "+420 444 555 666", bookingsCount: 12, lastBookingAt: new Date(Date.now() - 432000000).toISOString(), status: "vip", notes: "Loyal customer" },
|
||||
];
|
||||
|
||||
const resolvedCustomers = () => demoCustomers;
|
||||
const [customers] = createResource(
|
||||
() => data.token(),
|
||||
async (bearer) => {
|
||||
if (!bearer) return [];
|
||||
if (bearer.startsWith("demo.")) return demoCustomers;
|
||||
try {
|
||||
const response = await (apiClient as any).GET("/v1/catalog/customers", {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
params: { query: { limit: 100 } },
|
||||
});
|
||||
return (response.data as any[]) ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const resolvedCustomers = () => customers() ?? [];
|
||||
const resolvedBookings = () => data.normalizedAllBookings();
|
||||
|
||||
const filteredCustomers = createMemo(() => {
|
||||
@@ -42,9 +60,9 @@ function CustomersPage() {
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "active": return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.filter.all")}</span>;
|
||||
case "active": return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.customerStatus.active")}</span>;
|
||||
case "vip": return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-ink/10 text-ink">VIP</span>;
|
||||
default: return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-canvas-subtle text-ink-subtle">{data.i18n.t("dashboard.customer.status")}</span>;
|
||||
default: return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-canvas-subtle text-ink-subtle">{data.i18n.t("dashboard.customerStatus.inactive")}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,7 +71,7 @@ function CustomersPage() {
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.customers")}</h1>
|
||||
<p class="text-ink-muted mt-1">{filteredCustomers().length} {data.i18n.locale() === "cs" ? "zakazniku" : "customers"}</p>
|
||||
<p class="text-ink-muted mt-1">{filteredCustomers().length} {data.i18n.locale() === "cs" ? "zákazníků" : "customers"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="surface-card p-4">
|
||||
@@ -80,7 +98,7 @@ function CustomersPage() {
|
||||
});
|
||||
};
|
||||
return (
|
||||
<button onClick={() => { setSelectedCustomer(customer); setShowCustomerDetail(true); }} class={`w-full text-left flex items-center gap-4 p-4 hover:bg-canvas-subtle/30 transition-colors ${isPinned() ? "bg-accent-soft/40" : ""}`}>
|
||||
<div onClick={() => { setSelectedCustomer(customer); setShowCustomerDetail(true); }} class={`w-full text-left flex items-center gap-4 p-4 hover:bg-canvas-subtle/30 transition-colors cursor-pointer ${isPinned() ? "bg-accent-soft/40" : ""}`}>
|
||||
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">{getInitials(customer.name)}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -113,7 +131,7 @@ function CustomersPage() {
|
||||
<p class="text-xs text-ink-muted">{data.i18n.t("dashboard.customer.totalBookings")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -151,7 +169,7 @@ function CustomersPage() {
|
||||
<div class="flex items-center gap-3 p-3 rounded-xl border border-border/60">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-ink">{b.service}</p>
|
||||
<p class="text-xs text-ink-muted">{new Date(b.startsAt).toLocaleDateString()}</p>
|
||||
<p class="text-xs text-ink-muted">{new Date(b.startsAt).toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US")}</p>
|
||||
</div>
|
||||
<span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${b.status === "confirmed" ? "bg-accent/10 text-accent" : "bg-canvas-muted text-ink-muted"}`}>{data.i18n.t(`dashboard.${b.status}`)}</span>
|
||||
</div>
|
||||
|
||||
@@ -8,40 +8,53 @@ export type Section = "overview" | "bookings" | "customers" | "zones" | "billing
|
||||
|
||||
function createDemoData(i18n: ReturnType<typeof useI18n>) {
|
||||
const cs = i18n.locale() === "cs";
|
||||
const now = new Date();
|
||||
const fmt = (d: Date) => d.toISOString();
|
||||
const addDays = (n: number) => { const d = new Date(now); d.setDate(d.getDate() + n); return d; };
|
||||
const addHours = (d: Date, h: number) => { const r = new Date(d); r.setHours(r.getHours() + h); return r; };
|
||||
// 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 Novakova" : "Martina Novakova", email: "[email protected]" },
|
||||
{ name: cs ? "David Svoboda" : "David Svoboda", email: "[email protected]" },
|
||||
{ name: cs ? "Jana KovarovA" : "Jana KovarovA", email: "[email protected]" },
|
||||
{ name: cs ? "Alice Johnson" : "Alice Johnson", email: "[email protected]" },
|
||||
{ name: cs ? "Bob Smith" : "Bob Smith", email: "[email protected]" },
|
||||
{ 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 = [
|
||||
{ id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(addHours(addDays(0), 10)), endsAt: fmt(addHours(addDays(0), 11)), reference: "BOK-001", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
|
||||
{ id: "b2", customerName: customers[1].name, customerEmail: customers[1].email, service: services[1], status: "pending", startsAt: fmt(addHours(addDays(1), 14)), endsAt: fmt(addHours(addDays(1), 15)), reference: "BOK-002", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" },
|
||||
{ id: "b3", customerName: customers[2].name, customerEmail: customers[2].email, service: services[2], status: "confirmed", startsAt: fmt(addHours(addDays(2), 9)), endsAt: fmt(addHours(addDays(2), 10)), reference: "BOK-003", location: cs ? "Studio B" : "Studio B", staffName: "", notes: cs ? "Bolest zad" : "Back pain" },
|
||||
{ id: "b4", customerName: customers[3].name, customerEmail: customers[3].email, service: services[3], status: "cancelled", startsAt: fmt(addHours(addDays(-1), 11)), endsAt: fmt(addHours(addDays(-1), 12)), reference: "BOK-004", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
|
||||
{ id: "b5", customerName: customers[4].name, customerEmail: customers[4].email, service: services[0], status: "completed", startsAt: fmt(addHours(addDays(-2), 13)), endsAt: fmt(addHours(addDays(-2), 14)), reference: "BOK-005", location: cs ? "Studio B" : "Studio B", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" },
|
||||
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(addHours(addDays(3), 10)), endsAt: fmt(addHours(addDays(3), 11)), reference: "BOK-006", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
|
||||
{ id: "b7", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "confirmed", startsAt: fmt(addHours(addDays(0), 16)), endsAt: fmt(addHours(addDays(0), 17)), reference: "BOK-007", location: cs ? "Studio B" : "Studio B", staffName: "", notes: "" },
|
||||
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(addHours(addDays(4), 9)), endsAt: fmt(addHours(addDays(4), 10)), reference: "BOK-008", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" },
|
||||
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(addHours(addDays(5), 11)), endsAt: fmt(addHours(addDays(5), 12)), reference: "BOK-009", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
|
||||
{ id: "b10", customerName: customers[4].name, customerEmail: customers[4].email, service: services[1], status: "completed", startsAt: fmt(addHours(addDays(-3), 10)), endsAt: fmt(addHours(addDays(-3), 11)), reference: "BOK-010", location: cs ? "Studio B" : "Studio B", staffName: "", notes: "" },
|
||||
// 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: "" },
|
||||
{ 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: "" },
|
||||
];
|
||||
|
||||
return {
|
||||
summary: {
|
||||
tenantName: cs ? "Demo Studio" : "Demo Studio",
|
||||
tenantSlug: "demo-studio",
|
||||
publicBookingUrl: "http://localhost:3000/book/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" },
|
||||
@@ -59,7 +72,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
|
||||
},
|
||||
bootstrap: {
|
||||
tenantName: cs ? "Demo Studio" : "Demo Studio",
|
||||
slug: "demo-studio",
|
||||
slug: "studio-atelier",
|
||||
brand: { primaryColor: "#c25e3a" },
|
||||
locale: cs ? "cs" : "en",
|
||||
timezone: "Europe/Prague",
|
||||
@@ -100,7 +113,7 @@ export interface DashboardData {
|
||||
setBillingAction: (v: "checkout" | "refresh" | "portal" | null) => void;
|
||||
billingInterval: () => "monthly" | "yearly";
|
||||
setBillingInterval: (v: "monthly" | "yearly") => void;
|
||||
openCheckout: () => Promise<void>;
|
||||
openCheckout: (planCode?: string) => Promise<void>;
|
||||
openPortal: () => Promise<void>;
|
||||
refreshBilling: () => Promise<void>;
|
||||
showIntegrationModal: () => boolean;
|
||||
@@ -175,9 +188,26 @@ export function DashboardDataProvider(props: { children: any }) {
|
||||
} catch { return null; }
|
||||
});
|
||||
|
||||
const resolvedSummary = () => (summary.latest as any) ?? (isDemoMode() ? demoData().summary : undefined);
|
||||
const resolvedBootstrap = () => (bootstrap.latest as any) ?? (isDemoMode() ? demoData().bootstrap : undefined);
|
||||
const resolvedBilling = () => (billing.latest as any) ?? (isDemoMode() ? demoData().billing : undefined);
|
||||
// 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 normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({
|
||||
...kpi, trend: kpi.trend ?? "neutral", change: kpi.change ?? "—",
|
||||
@@ -204,7 +234,7 @@ export function DashboardDataProvider(props: { children: any }) {
|
||||
|
||||
const isDashboardReady = () => !!token() && !bootstrap.loading && !summary.loading && !billing.loading;
|
||||
|
||||
const openCheckout = async () => {
|
||||
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");
|
||||
@@ -212,10 +242,10 @@ export function DashboardDataProvider(props: { children: any }) {
|
||||
try {
|
||||
const response = await (apiClient as any).POST("/v1/billing/checkout", {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
body: { interval: billingInterval() },
|
||||
body: { planCode: planCode ?? resolvedBilling()?.planCode ?? "pro", currency: "czk", billingInterval: billingInterval() },
|
||||
});
|
||||
if (response.data?.url) window.location.href = response.data.url;
|
||||
else throw new Error("No checkout URL");
|
||||
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); }
|
||||
};
|
||||
|
||||
@@ -31,8 +31,9 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
|
||||
const activeSection = () => {
|
||||
const p = location.pathname;
|
||||
if (p === "/dashboard" || p === "/dashboard/") return "overview";
|
||||
const match = navItems.find(item => p === item.path || p.startsWith(item.path + "/"));
|
||||
const exact = navItems.find(item => p === item.path);
|
||||
if (exact) return exact.id;
|
||||
const match = navItems.find(item => item.id !== "overview" && p.startsWith(item.path + "/"));
|
||||
return match?.id ?? "overview";
|
||||
};
|
||||
|
||||
@@ -88,41 +89,49 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
|
||||
const Sidebar = () => (
|
||||
<aside class="hidden lg:flex flex-col w-64 h-screen bg-canvas border-r border-border sticky top-0 shrink-0">
|
||||
<div class="p-6">
|
||||
<div class="px-5 pt-5 pb-4">
|
||||
<A href="/" class="flex items-center gap-2.5 text-lg font-display font-semibold tracking-tight text-ink hover:text-accent transition-colors">
|
||||
<DashboardLogo />
|
||||
</A>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 space-y-1 overflow-y-auto">
|
||||
<nav class="flex-1 px-3 space-y-0.5 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<A
|
||||
href={item.path}
|
||||
class={`
|
||||
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200
|
||||
relative w-full flex items-center gap-3 px-3.5 py-2.5 rounded-lg text-sm font-medium transition-all duration-200
|
||||
${activeSection() === item.id
|
||||
? "bg-accent text-canvas shadow-sm"
|
||||
? "bg-accent-subtle text-accent font-semibold"
|
||||
: "text-ink-muted hover:bg-canvas-subtle hover:text-ink"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Show when={activeSection() === item.id}>
|
||||
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-full bg-accent" />
|
||||
</Show>
|
||||
<item.icon /> {data.i18n.t(item.label)}
|
||||
</A>
|
||||
))}
|
||||
</nav>
|
||||
<div class="p-4 border-t border-border space-y-1">
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<div class="p-3 border-t border-border space-y-1">
|
||||
<div class="flex items-center gap-1 px-1.5 py-1">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Show when={data.auth.session()?.user}>
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg">
|
||||
<div class="w-8 h-8 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">
|
||||
{getInitials(data.auth.session()?.user?.name)}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-ink truncate">{data.auth.session()?.user?.name}</p>
|
||||
<p class="text-xs text-ink-muted truncate">{data.auth.session()?.user?.email}</p>
|
||||
</div>
|
||||
<Show when={data.resolvedBilling()?.planName}>
|
||||
<span class="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-accent bg-accent-subtle px-2 py-0.5 rounded-full">
|
||||
{data.resolvedBilling()?.planName}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<button
|
||||
@@ -154,7 +163,7 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
class={`
|
||||
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all
|
||||
${activeSection() === item.id ? "bg-accent text-canvas" : "text-ink-muted hover:bg-canvas-subtle"}
|
||||
${activeSection() === item.id ? "bg-accent-subtle text-accent font-semibold" : "text-ink-muted hover:bg-canvas-subtle hover:text-ink"}
|
||||
`}
|
||||
>
|
||||
<item.icon /> {data.i18n.t(item.label)}
|
||||
@@ -220,7 +229,7 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
<div class="p-4 bg-canvas-subtle rounded-xl">
|
||||
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.dateTime")}</p>
|
||||
<p class="font-medium text-ink">
|
||||
{new Date(data.selectedBooking()?.startsAt).toLocaleString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}
|
||||
{new Date(data.selectedBooking()?.startsAt).toLocaleString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}
|
||||
</p>
|
||||
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.bookingModal.duration")}: {getBookingDuration(data.selectedBooking()?.startsAt, data.selectedBooking()?.endsAt)}</p>
|
||||
</div>
|
||||
@@ -258,7 +267,7 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
<DashboardLogo />
|
||||
<div class="flex items-center gap-2">
|
||||
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} />
|
||||
<button onClick={() => setIsMobileMenuOpen(true)} aria-label={data.i18n.locale() === "cs" ? "Otevrit menu" : "Open menu"} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all">
|
||||
<button onClick={() => setIsMobileMenuOpen(true)} aria-label={data.i18n.locale() === "cs" ? "Otevřít menu" : "Open menu"} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all">
|
||||
<MenuIcon />
|
||||
</button>
|
||||
</div>
|
||||
@@ -286,7 +295,7 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
<div class="absolute inset-0 bg-accent/20 rounded-full blur-3xl opacity-60" />
|
||||
<BookraCharacter pose="hello" size="xl" animate={true} />
|
||||
</div>
|
||||
<h2 class="text-3xl lg:text-4xl font-bold text-ink tracking-tight font-display">{data.i18n.locale() === "cs" ? "Vas rezervacni system ceka" : "Your booking system awaits"}</h2>
|
||||
<h2 class="text-3xl lg:text-4xl font-bold text-ink tracking-tight font-display">{data.i18n.locale() === "cs" ? "Váš rezervační systém čeká" : "Your booking system awaits"}</h2>
|
||||
<p class="text-ink-muted mt-3 text-lg max-w-md mx-auto">{data.i18n.t("dashboard.welcome.body")}</p>
|
||||
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "sign-in" } }))} class="btn-primary w-full sm:w-auto">
|
||||
@@ -297,7 +306,7 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
<SparklesIcon /> {data.i18n.t("dashboard.tryDemo")}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-6 text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Jeste nemate ucet? " : "No account yet? "}
|
||||
<p class="mt-6 text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Ještě nemáte účet? " : "No account yet? "}
|
||||
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "register" } }))} class="text-accent hover:text-accent-hover font-medium underline underline-offset-2">{data.i18n.t("auth.createAccount")}</button>
|
||||
</p>
|
||||
</div>
|
||||
@@ -307,7 +316,7 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
<Show when={data.token() && !data.isDashboardReady()}>
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<BookraCharacter pose="walk" size="lg" animate={true} />
|
||||
<p class="mt-6 text-sm text-ink-muted animate-pulse font-medium">{data.i18n.locale() === "cs" ? "Nacitani dashboardu..." : "Loading your dashboard..."}</p>
|
||||
<p class="mt-6 text-sm text-ink-muted animate-pulse font-medium">{data.i18n.locale() === "cs" ? "Načítání dashboardu…" : "Loading your dashboard…"}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -43,9 +43,9 @@ function OverviewPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Setup Guide */}
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-1 surface-card p-5">
|
||||
{/* Setup Guide + Trend */}
|
||||
<div class="grid lg:grid-cols-5 gap-6">
|
||||
<div class="lg:col-span-2 surface-card p-5">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-9 h-9 rounded-lg bg-accent-subtle flex items-center justify-center text-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5"/></svg>
|
||||
@@ -106,8 +106,8 @@ function OverviewPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<RevenueChart />
|
||||
<div class="lg:col-span-3">
|
||||
<RevenueChart bookings={data.normalizedAllBookings()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,11 +161,10 @@ function OverviewPage() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Revenue Chart + Calendar + Activity */}
|
||||
{/* Calendar + Activity */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<RevenueChart bookings={data.normalizedAllBookings()} />
|
||||
<CalendarView bookings={data.normalizedUpcomingBookings()} locale={data.i18n.locale()} onBookingClick={data.openBookingDetail} />
|
||||
<div class="lg:col-span-2">
|
||||
<CalendarView bookings={data.normalizedAllBookings()} locale={data.i18n.locale()} onBookingClick={data.openBookingDetail} />
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<ActivityTimeline activities={data.normalizedRecentActivity()} />
|
||||
@@ -197,7 +196,7 @@ function OverviewPage() {
|
||||
<p class="text-sm text-ink-muted">{booking.service} • {booking.duration}</p>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-medium text-ink">{new Date(booking.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}</p>
|
||||
<p class="text-sm font-medium text-ink">{new Date(booking.startsAt).toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short", month: "short", day: "numeric" })}</p>
|
||||
<span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
booking.status === "confirmed" ? "bg-accent/10 text-accent"
|
||||
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, createSignal } from "solid-js";
|
||||
import { Show, createEffect, createResource, createSignal } from "solid-js";
|
||||
import { apiClient } from "../../lib/api-client";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
@@ -16,6 +16,26 @@ function SettingsPage() {
|
||||
const [emailBody, setEmailBody] = createSignal("");
|
||||
const [emailSaving, setEmailSaving] = createSignal(false);
|
||||
|
||||
const [emailTemplates, { refetch: refetchTemplates }] = createResource(
|
||||
() => data.token(),
|
||||
async (bearer) => {
|
||||
if (!bearer || bearer.startsWith("demo.")) return [];
|
||||
try {
|
||||
const response = await (apiClient as any).GET("/v1/tenants/email-templates", { headers: { Authorization: `Bearer ${bearer}` } });
|
||||
return (response.data?.templates as any[]) ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Prefill the editor from the stored template whenever the type changes.
|
||||
createEffect(() => {
|
||||
const tpl = (emailTemplates() ?? []).find((t: any) => t.type === activeEmailType());
|
||||
setEmailSubject(tpl?.subject ?? "");
|
||||
setEmailBody(tpl?.bodyText ?? tpl?.bodyHtml ?? "");
|
||||
});
|
||||
|
||||
const presetColors = [
|
||||
{ name: data.i18n.locale() === "cs" ? "Hnědá" : "Terracotta", color: "#c25e3a" },
|
||||
{ name: data.i18n.locale() === "cs" ? "Zelená" : "Sage", color: "#5a7c5a" },
|
||||
@@ -68,7 +88,7 @@ function SettingsPage() {
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-canvas font-bold" style={{ background: brandColor() }}>B</div>
|
||||
<div>
|
||||
<p class="font-semibold text-ink">Bookra</p>
|
||||
<p class="text-xs text-ink-muted">{data.i18n.locale() === "cs" ? "Rezervujte si termin" : "Book an appointment"}</p>
|
||||
<p class="text-xs text-ink-muted">{data.i18n.locale() === "cs" ? "Rezervujte si termín" : "Book an appointment"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-full py-2.5 rounded-lg text-canvas font-medium text-sm" style={{ background: brandColor() }}>{data.i18n.locale() === "cs" ? "Rezervovat" : "Book Now"}</button>
|
||||
@@ -109,7 +129,8 @@ function SettingsPage() {
|
||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||
setEmailSaving(true);
|
||||
try {
|
||||
await (apiClient as any).PUT("/v1/tenants/email-template", { headers: { Authorization: `Bearer ${bearer}` }, body: { type: activeEmailType(), subject: emailSubject(), body: emailBody() } });
|
||||
await (apiClient as any).PUT("/v1/tenants/email-template", { headers: { Authorization: `Bearer ${bearer}` }, body: { type: activeEmailType(), subject: emailSubject(), body: emailBody(), isEnabled: true } });
|
||||
void refetchTemplates();
|
||||
data.setDemoNotice(data.i18n.locale() === "cs" ? "Šablona uložena." : "Template saved.");
|
||||
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Chyba při ukládání." : "Save failed."); }
|
||||
finally { setEmailSaving(false); }
|
||||
|
||||
@@ -1,31 +1,146 @@
|
||||
import { Show, createSignal } from "solid-js";
|
||||
import { For, Show, createResource, createSignal } from "solid-js";
|
||||
import { apiClient } from "../../lib/api-client";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { LocationMap } from "../../components/location-map";
|
||||
import { MapPinIcon, PlusIcon, XIcon, BanIcon, ClockIcon, SparklesIcon } from "../../components/dashboard/icons";
|
||||
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon } from "../../components/dashboard/icons";
|
||||
import { DashboardLayout, useDashboardData } from "./layout";
|
||||
|
||||
function ZonesPage() {
|
||||
const data = useDashboardData();
|
||||
const cs = () => data.i18n.locale() === "cs";
|
||||
const [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours">("zones");
|
||||
const [showAddZone, setShowAddZone] = createSignal(false);
|
||||
const [newZoneName, setNewZoneName] = createSignal("");
|
||||
const [newZoneType, setNewZoneType] = createSignal("room");
|
||||
const [newZoneCapacity, setNewZoneCapacity] = createSignal(10);
|
||||
const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false);
|
||||
const [newBlockedDate, setNewBlockedDate] = createSignal("");
|
||||
const [newBlockedReason, setNewBlockedReason] = createSignal("");
|
||||
const [blockedSaving, setBlockedSaving] = createSignal(false);
|
||||
const [hoursSaving, setHoursSaving] = createSignal<number | null>(null);
|
||||
|
||||
const resolvedLocations = () => [
|
||||
{ id: "z1", name: "Main Hall", type: "hall", capacity: 20, bookingsToday: 5 },
|
||||
{ id: "z2", name: "Private Room A", type: "private", capacity: 4, bookingsToday: 2 },
|
||||
{ id: "z3", name: "Treatment Room", type: "room", capacity: 2, bookingsToday: 8 },
|
||||
];
|
||||
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
|
||||
|
||||
const [locations, { refetch: refetchLocations }] = createResource(
|
||||
() => data.token(),
|
||||
async (bearer) => {
|
||||
if (!bearer) return [];
|
||||
if (bearer.startsWith("demo.")) {
|
||||
return [
|
||||
{ id: "z1", name: cs() ? "Hlavní sál" : "Main Hall", type: "hall", capacity: 20 },
|
||||
{ id: "z2", name: cs() ? "Soukromá místnost A" : "Private Room A", type: "private", capacity: 4 },
|
||||
{ id: "z3", name: cs() ? "Ošetřovna" : "Treatment Room", type: "room", capacity: 2 },
|
||||
];
|
||||
}
|
||||
const response = await (apiClient as any).GET("/v1/catalog/locations", authHeaders());
|
||||
return (response.data as any[]) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
const [blockedDays, { refetch: refetchBlocked }] = createResource(
|
||||
() => [data.token(), activeTab()] as const,
|
||||
async ([bearer, tab]) => {
|
||||
if (!bearer || tab !== "blocked") return [];
|
||||
if (bearer.startsWith("demo.")) return [];
|
||||
const response = await (apiClient as any).GET("/v1/catalog/blocked-days", authHeaders());
|
||||
return (response.data as any[]) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
const [workingHours, { refetch: refetchHours, mutate: mutateHours }] = createResource(
|
||||
() => [data.token(), activeTab()] as const,
|
||||
async ([bearer, tab]) => {
|
||||
if (!bearer || tab !== "hours") return [];
|
||||
if (bearer.startsWith("demo.")) {
|
||||
return [0, 1, 2, 3, 4, 5, 6].map((d) => ({ dayOfWeek: d, open: d === 0 ? "" : "09:00", close: d === 0 ? "" : "18:00", isOpen: d !== 0 }));
|
||||
}
|
||||
const response = await (apiClient as any).GET("/v1/catalog/working-hours", authHeaders());
|
||||
const rows = (response.data as any[]) ?? [];
|
||||
return [0, 1, 2, 3, 4, 5, 6].map((d) => {
|
||||
const row = rows.find((r) => r.dayOfWeek === d);
|
||||
return { dayOfWeek: d, open: (row?.open ?? "").slice(0, 5), close: (row?.close ?? "").slice(0, 5), isOpen: row?.isOpen ?? false };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const resolvedLocations = () => locations() ?? [];
|
||||
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
|
||||
const canAddLocation = () => resolvedLocations().length < locationLimit();
|
||||
const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit();
|
||||
|
||||
const handleAddZone = () => {
|
||||
const handleAddZone = async () => {
|
||||
const bearer = data.token();
|
||||
if (!newZoneName().trim()) return;
|
||||
if (!canAddLocation()) { setShowUpgradePrompt(true); setShowAddZone(false); return; }
|
||||
setNewZoneName(""); setNewZoneType("room"); setNewZoneCapacity(10); setShowAddZone(false);
|
||||
if (!bearer || bearer.startsWith("demo.")) {
|
||||
data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await (apiClient as any).POST("/v1/catalog/locations", {
|
||||
...authHeaders(),
|
||||
body: { name: newZoneName().trim(), type: newZoneType(), capacity: newZoneCapacity() },
|
||||
});
|
||||
setNewZoneName(""); setNewZoneType("room"); setNewZoneCapacity(10); setShowAddZone(false);
|
||||
void refetchLocations();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Uložení zóny selhalo." : "Failed to save location.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteZone = async (id: string) => {
|
||||
const bearer = data.token();
|
||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
|
||||
try {
|
||||
await (apiClient as any).DELETE(`/v1/catalog/locations/${id}`, authHeaders());
|
||||
void refetchLocations();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Smazání selhalo." : "Delete failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddBlockedDay = async () => {
|
||||
const bearer = data.token();
|
||||
if (!newBlockedDate()) return;
|
||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||
setBlockedSaving(true);
|
||||
try {
|
||||
await (apiClient as any).POST("/v1/catalog/blocked-days", {
|
||||
...authHeaders(),
|
||||
body: { date: new Date(`${newBlockedDate()}T00:00:00`).toISOString(), type: "closure", reason: newBlockedReason().trim() || (cs() ? "Zavřeno" : "Closed") },
|
||||
});
|
||||
setNewBlockedDate(""); setNewBlockedReason("");
|
||||
void refetchBlocked();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
|
||||
} finally {
|
||||
setBlockedSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBlockedDay = async (id: string) => {
|
||||
const bearer = data.token();
|
||||
if (!bearer || bearer.startsWith("demo.")) return;
|
||||
try {
|
||||
await (apiClient as any).DELETE(`/v1/catalog/blocked-days/${id}`, authHeaders());
|
||||
void refetchBlocked();
|
||||
} catch { /* noop */ }
|
||||
};
|
||||
|
||||
const handleSaveHours = async (day: { dayOfWeek: number; open: string; close: string; isOpen: boolean }) => {
|
||||
const bearer = data.token();
|
||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||
setHoursSaving(day.dayOfWeek);
|
||||
try {
|
||||
await (apiClient as any).PUT(`/v1/catalog/working-hours/${day.dayOfWeek}`, {
|
||||
...authHeaders(),
|
||||
body: { open: day.open, close: day.close, isOpen: day.isOpen },
|
||||
});
|
||||
void refetchHours();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
|
||||
} finally {
|
||||
setHoursSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
@@ -33,12 +148,19 @@ function ZonesPage() {
|
||||
return map[type] || type;
|
||||
};
|
||||
|
||||
const dayLabel = (d: number) => {
|
||||
const labels = cs()
|
||||
? ["Neděle", "Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota"]
|
||||
: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
return labels[d];
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.zones")}</h1>
|
||||
<p class="text-ink-muted mt-1">{resolvedLocations().length} {data.i18n.locale() === "cs" ? "zon" : "zones"}</p>
|
||||
<p class="text-ink-muted mt-1">{resolvedLocations().length} {cs() ? "zón" : "locations"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,39 +177,38 @@ function ZonesPage() {
|
||||
</div>
|
||||
|
||||
{activeTab() === "zones" && (
|
||||
<div class="space-y-6">
|
||||
<LocationMap
|
||||
coordinates={{ latitude: 50.0755, longitude: 14.4378, zoom: 14, address: data.i18n.locale() === "cs" ? "Praha, Česko" : "Prague, Czechia", source: "coordinates" }}
|
||||
markerColor={data.resolvedBootstrap()?.brand?.primaryColor}
|
||||
height={320}
|
||||
class="shadow-sm"
|
||||
/>
|
||||
<div class="surface-card overflow-hidden">
|
||||
<div class="p-6 border-b border-border flex items-center justify-between">
|
||||
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zones")}</h3>
|
||||
<Show when={canAddLocation()} fallback={
|
||||
<button onClick={() => setShowUpgradePrompt(true)} class="flex items-center gap-2 px-4 py-2 bg-canvas-subtle text-ink-muted border border-border rounded-lg text-sm opacity-60 cursor-not-allowed">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
{data.i18n.t("dashboard.zone.limitReached")}
|
||||
</button>
|
||||
}>
|
||||
<button onClick={() => setShowAddZone(true)} class="btn-primary text-sm"><PlusIcon /> {data.i18n.t("dashboard.zone.add")}</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={showAddZone()}>
|
||||
<div class="p-6 border-b border-border bg-canvas-subtle/50">
|
||||
<h4 class="font-medium text-ink mb-4">{data.i18n.t("dashboard.zone.add")}</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input type="text" label={data.i18n.t("dashboard.zone.name")} value={newZoneName()} onInput={(e) => setNewZoneName(e.currentTarget.value)} placeholder={data.i18n.locale() === "cs" ? "např. Hlavní sál" : "e.g. Main Hall"} />
|
||||
<Select label={data.i18n.t("dashboard.zone.type")} value={newZoneType()} onChange={(v) => setNewZoneType(v)} options={[{ value: "room", label: data.i18n.t("dashboard.zone.rooms") }, { value: "private", label: data.i18n.t("dashboard.zone.private") }, { value: "hall", label: data.i18n.t("dashboard.zone.hall") }]} />
|
||||
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newZoneCapacity()} onInput={(e) => setNewZoneCapacity(parseInt(e.currentTarget.value) || 10)} />
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button onClick={handleAddZone} class="btn-primary text-sm">{data.i18n.t("dashboard.settings.save")}</button>
|
||||
<button onClick={() => setShowAddZone(false)} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="surface-card overflow-hidden">
|
||||
<div class="p-6 border-b border-border flex items-center justify-between">
|
||||
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zones")}</h3>
|
||||
<Show when={canAddLocation()} fallback={
|
||||
<button onClick={() => setShowUpgradePrompt(true)} class="flex items-center gap-2 px-4 py-2 bg-canvas-subtle text-ink-muted border border-border rounded-lg text-sm opacity-60 cursor-not-allowed">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
{data.i18n.t("dashboard.zone.limitReached")}
|
||||
</button>
|
||||
}>
|
||||
<button onClick={() => setShowAddZone(true)} class="btn-primary text-sm"><PlusIcon /> {data.i18n.t("dashboard.zone.add")}</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={showAddZone()}>
|
||||
<div class="p-6 border-b border-border bg-canvas-subtle/50">
|
||||
<h4 class="font-medium text-ink mb-4">{data.i18n.t("dashboard.zone.add")}</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input type="text" label={data.i18n.t("dashboard.zone.name")} value={newZoneName()} onInput={(e) => setNewZoneName(e.currentTarget.value)} placeholder={cs() ? "např. Hlavní sál" : "e.g. Main Hall"} />
|
||||
<Select label={data.i18n.t("dashboard.zone.type")} value={newZoneType()} onChange={(v) => setNewZoneType(v)} options={[{ value: "room", label: data.i18n.t("dashboard.zone.rooms") }, { value: "private", label: data.i18n.t("dashboard.zone.private") }, { value: "hall", label: data.i18n.t("dashboard.zone.hall") }]} />
|
||||
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newZoneCapacity()} onInput={(e) => setNewZoneCapacity(parseInt(e.currentTarget.value) || 10)} />
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button onClick={() => void handleAddZone()} class="btn-primary text-sm">{data.i18n.t("dashboard.settings.save")}</button>
|
||||
<button onClick={() => setShowAddZone(false)} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={resolvedLocations().length > 0} fallback={
|
||||
<div class="p-12 text-center">
|
||||
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
|
||||
<p class="text-sm text-ink-subtle mt-1">{cs() ? "Přidejte první zónu nebo místnost." : "Add your first location or room."}</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="divide-y divide-border/60">
|
||||
{resolvedLocations().map((zone: any) => (
|
||||
<div class="flex items-center justify-between p-6 hover:bg-canvas-subtle/30 transition-colors">
|
||||
@@ -98,30 +219,98 @@ function ZonesPage() {
|
||||
<p class="text-sm text-ink-muted">{typeLabel(zone.type)} • {data.i18n.t("dashboard.zone.capacity")}: {zone.capacity}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium text-ink">{zone.bookingsToday}</p>
|
||||
<p class="text-sm text-ink-muted">{zone.bookingsToday} {data.i18n.locale() === "cs" ? "rezervací dnes" : "bookings today"}</p>
|
||||
</div>
|
||||
<button onClick={() => void handleDeleteZone(zone.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}>
|
||||
<Trash2Icon />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab() === "blocked" && (
|
||||
<div class="surface-card p-12 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><BanIcon /></div>
|
||||
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
|
||||
<p class="text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Spravujte zóny, blokované dny a pracovní dobu." : "Manage locations, blocked days and working hours."}</p>
|
||||
<div class="surface-card overflow-hidden">
|
||||
<div class="p-6 border-b border-border">
|
||||
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zone.blockedDays")}</h3>
|
||||
<p class="text-sm text-ink-muted mt-1">{cs() ? "Dny, kdy nejsou dostupné rezervace." : "Days when bookings are unavailable."}</p>
|
||||
</div>
|
||||
<div class="p-6 border-b border-border bg-canvas-subtle/50">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input type="date" label={cs() ? "Datum" : "Date"} value={newBlockedDate()} onInput={(e) => setNewBlockedDate(e.currentTarget.value)} />
|
||||
<Input type="text" label={cs() ? "Důvod" : "Reason"} value={newBlockedReason()} onInput={(e) => setNewBlockedReason(e.currentTarget.value)} placeholder={cs() ? "např. Státní svátek" : "e.g. Public holiday"} />
|
||||
<div class="flex items-end">
|
||||
<button onClick={() => void handleAddBlockedDay()} disabled={blockedSaving() || !newBlockedDate()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
|
||||
{blockedSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={(blockedDays() ?? []).length > 0} fallback={
|
||||
<div class="p-12 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><BanIcon /></div>
|
||||
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
|
||||
<p class="text-sm text-ink-muted">{cs() ? "Zatím žádné blokované dny." : "No blocked days yet."}</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="divide-y divide-border/60">
|
||||
<For each={blockedDays() ?? []}>
|
||||
{(day: any) => (
|
||||
<div class="flex items-center justify-between p-4 px-6">
|
||||
<div>
|
||||
<p class="font-medium text-ink">{new Date(day.date).toLocaleDateString(cs() ? "cs-CZ" : "en-US", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}</p>
|
||||
<p class="text-sm text-ink-muted">{day.reason || "—"}</p>
|
||||
</div>
|
||||
<button onClick={() => void handleDeleteBlockedDay(day.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab() === "hours" && (
|
||||
<div class="surface-card p-12 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><ClockIcon /></div>
|
||||
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
|
||||
<p class="text-ink-muted text-sm">{data.i18n.locale() === "cs" ? "Nastavení pracovní doby bude dostupné brzy." : "Working hours settings coming soon."}</p>
|
||||
<div class="surface-card overflow-hidden">
|
||||
<div class="p-6 border-b border-border">
|
||||
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zone.workingHours")}</h3>
|
||||
<p class="text-sm text-ink-muted mt-1">{cs() ? "Nastavte dny a časy, kdy přijímáte rezervace." : "Set the days and times you accept bookings."}</p>
|
||||
</div>
|
||||
<div class="divide-y divide-border/60">
|
||||
<For each={workingHours() ?? []}>
|
||||
{(day) => {
|
||||
const isOpen = () => day.isOpen;
|
||||
const setField = (patch: Partial<typeof day>) => {
|
||||
const updated = { ...day, ...patch };
|
||||
mutateHours((workingHours() ?? []).map((d) => (d.dayOfWeek === day.dayOfWeek ? updated : d)));
|
||||
};
|
||||
return (
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3 p-4 px-6">
|
||||
<div class="w-32 shrink-0 font-medium text-ink">{dayLabel(day.dayOfWeek)}</div>
|
||||
<label class="flex items-center gap-2 text-sm text-ink-muted shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOpen()}
|
||||
onChange={(e) => setField({ isOpen: e.currentTarget.checked, open: e.currentTarget.checked ? (day.open || "09:00") : day.open, close: e.currentTarget.checked ? (day.close || "18:00") : day.close })}
|
||||
class="rounded border-border accent-[#c25e3a]"
|
||||
/>
|
||||
{cs() ? "Otevřeno" : "Open"}
|
||||
</label>
|
||||
<Show when={isOpen()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="time" value={day.open} onInput={(e) => setField({ open: e.currentTarget.value })} class="px-3 py-2 bg-canvas-subtle border border-border rounded-lg text-sm text-ink" />
|
||||
<span class="text-ink-muted">–</span>
|
||||
<input type="time" value={day.close} onInput={(e) => setField({ close: e.currentTarget.value })} class="px-3 py-2 bg-canvas-subtle border border-border rounded-lg text-sm text-ink" />
|
||||
</div>
|
||||
</Show>
|
||||
<button onClick={() => void handleSaveHours(workingHours()?.find((d) => d.dayOfWeek === day.dayOfWeek) ?? day)} disabled={hoursSaving() === day.dayOfWeek} class="sm:ml-auto px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-60">
|
||||
{hoursSaving() === day.dayOfWeek ? "…" : data.i18n.t("dashboard.settings.save")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -133,8 +322,8 @@ function ZonesPage() {
|
||||
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center shrink-0"><SparklesIcon /></div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-ink mb-1">{data.i18n.t("dashboard.locationLimitReached")}</h4>
|
||||
<p class="text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Váš plán umožňuje pouze omezený počet lokací. Pro více lokací upgradujte." : "Your plan only allows a limited number of locations. Upgrade for more."}</p>
|
||||
<div class="flex gap-3">
|
||||
<p class="text-sm text-ink-muted">{cs() ? "Váš plán umožňuje pouze omezený počet lokací. Pro více lokací upgradujte." : "Your plan only allows a limited number of locations. Upgrade for more."}</p>
|
||||
<div class="flex gap-3 mt-3">
|
||||
<button onClick={() => { setShowUpgradePrompt(false); void data.openCheckout(); }} class="px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">{data.i18n.t("dashboard.upgrade")}</button>
|
||||
<button onClick={() => setShowUpgradePrompt(false)} class="px-4 py-2 bg-canvas-subtle text-ink-muted text-sm font-medium rounded-lg hover:bg-canvas transition-colors">{data.i18n.t("dashboard.close")}</button>
|
||||
</div>
|
||||
|
||||
@@ -44,8 +44,8 @@ export function LegalRoute() {
|
||||
{
|
||||
title: isCs() ? "3. Předplatné a platby" : "3. Subscription and payments",
|
||||
body: isCs()
|
||||
? "Placené plány se účtují předem prostřednictvím platební brány Paddle nebo Stripe. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce. Při roční platbě je poskytována sleva oproti měsíčnímu zúčtování. Uživatel může předplatné kdykoliv zrušit; přístup zůstává do konce zaplaceného období. Neposkytujeme refundace za již zaplacená období."
|
||||
: "Paid plans are billed in advance through Paddle or Stripe. The active plan determines available limits, add-ons, and support features. Annual billing includes a discount compared to monthly billing. Users may cancel anytime; access continues until the end of the paid period. No refunds are provided for already-paid periods.",
|
||||
? "Placené plány se účtují předem prostřednictvím platební brány Stripe. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce. Při roční platbě je poskytována sleva oproti měsíčnímu zúčtování. Uživatel může předplatné kdykoliv zrušit; přístup zůstává do konce zaplaceného období. Neposkytujeme refundace za již zaplacená období."
|
||||
: "Paid plans are billed in advance through Stripe. The active plan determines available limits, add-ons, and support features. Annual billing includes a discount compared to monthly billing. Users may cancel anytime; access continues until the end of the paid period. No refunds are provided for already-paid periods.",
|
||||
},
|
||||
{
|
||||
title: isCs() ? "4. Odpovědnost a omezení" : "4. Liability and limitations",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.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","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.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/sentry.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/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","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||
Reference in new issue
Block a user