mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(availability): per-staff working hours and class/session management
- Working-hours repo accepts nullable staff scope (pgx + memory); staff rules live in availability_rules and drive public slot generation - Protected routes for staff working hours, class templates, and class sessions with tenant-ownership checks and input validation - Sessions derive endsAt/capacity from templates; list resolves location names and live booked counts; template delete cascades sessions, session delete preserves bookings via SET NULL (mirrored in memory) - Zones page: staff-scope selector on working hours, new Classes tab with template/session CRUD, CS+EN strings, mobile tab wrapping - Normalize availability_rules times to HH:MM in the service layer 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
d9aa29dbcb
commit
0405106587
12 files changed
+2069
-58
No files matched your search
@@ -400,6 +400,23 @@ const dictionaries = {
|
||||
"dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.",
|
||||
"dashboard.zone.staff": "Zaměstnanec",
|
||||
"dashboard.zone.allStaff": "Všichni zaměstnanci",
|
||||
"dashboard.zone.businessHours": "Celá firma",
|
||||
"dashboard.classes": "Lekce",
|
||||
"dashboard.class.templates": "Šablony lekcí",
|
||||
"dashboard.class.sessions": "Naplánované lekce",
|
||||
"dashboard.class.addTemplate": "Nová šablona",
|
||||
"dashboard.class.addSession": "Naplánovat lekci",
|
||||
"dashboard.class.title": "Název",
|
||||
"dashboard.class.titlePlaceholder": "např. Jóga pro začátečníky",
|
||||
"dashboard.class.durationMin": "Délka (min)",
|
||||
"dashboard.class.template": "Šablona",
|
||||
"dashboard.class.location": "Místo",
|
||||
"dashboard.class.noLocation": "Bez místa",
|
||||
"dashboard.class.startsAt": "Začátek",
|
||||
"dashboard.class.booked": "obsazeno",
|
||||
"dashboard.class.noTemplates": "Zatím žádné šablony lekcí.",
|
||||
"dashboard.class.noSessions": "Zatím žádné naplánované lekce.",
|
||||
"dashboard.class.sessionHint": "Vytvořte šablonu a poté z ní naplánujte konkrétní lekce.",
|
||||
"dashboard.services": "Služby",
|
||||
"dashboard.services.subtitle": "služeb v nabídce",
|
||||
"dashboard.service.add": "Přidat službu",
|
||||
@@ -952,6 +969,23 @@ const dictionaries = {
|
||||
"dashboard.zone.noZones": "No zones yet. Add your first one.",
|
||||
"dashboard.zone.staff": "Staff member",
|
||||
"dashboard.zone.allStaff": "All staff",
|
||||
"dashboard.zone.businessHours": "Whole business",
|
||||
"dashboard.classes": "Classes",
|
||||
"dashboard.class.templates": "Class templates",
|
||||
"dashboard.class.sessions": "Scheduled sessions",
|
||||
"dashboard.class.addTemplate": "New template",
|
||||
"dashboard.class.addSession": "Schedule session",
|
||||
"dashboard.class.title": "Title",
|
||||
"dashboard.class.titlePlaceholder": "e.g. Beginner yoga",
|
||||
"dashboard.class.durationMin": "Duration (min)",
|
||||
"dashboard.class.template": "Template",
|
||||
"dashboard.class.location": "Location",
|
||||
"dashboard.class.noLocation": "No location",
|
||||
"dashboard.class.startsAt": "Starts at",
|
||||
"dashboard.class.booked": "booked",
|
||||
"dashboard.class.noTemplates": "No class templates yet.",
|
||||
"dashboard.class.noSessions": "No scheduled sessions yet.",
|
||||
"dashboard.class.sessionHint": "Create a template, then schedule individual sessions from it.",
|
||||
"dashboard.services": "Services",
|
||||
"dashboard.services.subtitle": "services offered",
|
||||
"dashboard.service.add": "Add service",
|
||||
|
||||
@@ -2,13 +2,13 @@ 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 { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon } from "../../components/dashboard/icons";
|
||||
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon } 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 [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours" | "classes">("zones");
|
||||
const [showAddZone, setShowAddZone] = createSignal(false);
|
||||
const [newZoneName, setNewZoneName] = createSignal("");
|
||||
const [newZoneType, setNewZoneType] = createSignal("room");
|
||||
@@ -19,6 +19,15 @@ function ZonesPage() {
|
||||
const [newBlockedStaffId, setNewBlockedStaffId] = createSignal("");
|
||||
const [blockedSaving, setBlockedSaving] = createSignal(false);
|
||||
const [hoursSaving, setHoursSaving] = createSignal<number | null>(null);
|
||||
const [hoursScope, setHoursScope] = createSignal("");
|
||||
const [newTemplateTitle, setNewTemplateTitle] = createSignal("");
|
||||
const [newTemplateDuration, setNewTemplateDuration] = createSignal(60);
|
||||
const [newTemplateCapacity, setNewTemplateCapacity] = createSignal(8);
|
||||
const [templateSaving, setTemplateSaving] = createSignal(false);
|
||||
const [newSessionTemplateId, setNewSessionTemplateId] = createSignal("");
|
||||
const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
|
||||
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
|
||||
const [sessionSaving, setSessionSaving] = createSignal(false);
|
||||
|
||||
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
|
||||
|
||||
@@ -51,7 +60,7 @@ function ZonesPage() {
|
||||
const [staffMembers] = createResource(
|
||||
() => [data.token(), activeTab()] as const,
|
||||
async ([bearer, tab]) => {
|
||||
if (!bearer || tab !== "blocked") return [];
|
||||
if (!bearer || (tab !== "blocked" && tab !== "hours")) return [];
|
||||
if (bearer.startsWith("demo.")) {
|
||||
return [
|
||||
{ id: "st1", displayName: "Petra M.", active: true },
|
||||
@@ -71,13 +80,17 @@ function ZonesPage() {
|
||||
(staffMembers() ?? []).find((m: any) => m.id === id)?.displayName ?? "";
|
||||
|
||||
const [workingHours, { refetch: refetchHours, mutate: mutateHours }] = createResource(
|
||||
() => [data.token(), activeTab()] as const,
|
||||
async ([bearer, tab]) => {
|
||||
() => [data.token(), activeTab(), hoursScope()] as const,
|
||||
async ([bearer, tab, scope]) => {
|
||||
if (!bearer || tab !== "hours") return [];
|
||||
if (bearer.startsWith("demo.")) {
|
||||
if (scope) {
|
||||
return [0, 1, 2, 3, 4, 5, 6].map((d) => ({ dayOfWeek: d, open: d >= 1 && d <= 5 ? "08:00" : "", close: d >= 1 && d <= 5 ? "14:00" : "", isOpen: d >= 1 && d <= 5 }));
|
||||
}
|
||||
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 url = scope ? `/v1/catalog/staff/${scope}/working-hours` : "/v1/catalog/working-hours";
|
||||
const response = await (apiClient as any).GET(url, 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);
|
||||
@@ -86,6 +99,31 @@ function ZonesPage() {
|
||||
},
|
||||
);
|
||||
|
||||
const [classTemplates, { refetch: refetchTemplates }] = createResource(
|
||||
() => [data.token(), activeTab()] as const,
|
||||
async ([bearer, tab]) => {
|
||||
if (!bearer || tab !== "classes") return [];
|
||||
if (bearer.startsWith("demo.")) {
|
||||
return [{ id: "ct1", title: cs() ? "Jóga pro začátečníky" : "Beginner yoga", durationMinutes: 60, capacity: 8 }];
|
||||
}
|
||||
const response = await (apiClient as any).GET("/v1/catalog/class-templates", authHeaders());
|
||||
return (response.data as any[]) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
const [classSessions, { refetch: refetchSessions }] = createResource(
|
||||
() => [data.token(), activeTab()] as const,
|
||||
async ([bearer, tab]) => {
|
||||
if (!bearer || tab !== "classes") return [];
|
||||
if (bearer.startsWith("demo.")) {
|
||||
const start = new Date(Date.now() + 3 * 86400000);
|
||||
return [{ id: "cs1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", startsAt: start.toISOString(), endsAt: new Date(start.getTime() + 3600000).toISOString(), capacity: 8, bookedCount: 3 }];
|
||||
}
|
||||
const response = await (apiClient as any).GET("/v1/catalog/class-sessions", authHeaders());
|
||||
return (response.data as any[]) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
const resolvedLocations = () => locations() ?? [];
|
||||
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
|
||||
const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit();
|
||||
@@ -159,7 +197,9 @@ function ZonesPage() {
|
||||
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}`, {
|
||||
const scope = hoursScope();
|
||||
const url = scope ? `/v1/catalog/staff/${scope}/working-hours/${day.dayOfWeek}` : `/v1/catalog/working-hours/${day.dayOfWeek}`;
|
||||
await (apiClient as any).PUT(url, {
|
||||
...authHeaders(),
|
||||
body: { open: day.open, close: day.close, isOpen: day.isOpen },
|
||||
});
|
||||
@@ -171,6 +211,70 @@ function ZonesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTemplate = async () => {
|
||||
const bearer = data.token();
|
||||
if (!newTemplateTitle().trim()) return;
|
||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||
setTemplateSaving(true);
|
||||
try {
|
||||
await (apiClient as any).POST("/v1/catalog/class-templates", {
|
||||
...authHeaders(),
|
||||
body: { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() },
|
||||
});
|
||||
setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8);
|
||||
void refetchTemplates();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
|
||||
} finally {
|
||||
setTemplateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = 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/class-templates/${id}`, authHeaders());
|
||||
void refetchTemplates(); void refetchSessions();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Smazání selhalo." : "Delete failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSession = async () => {
|
||||
const bearer = data.token();
|
||||
if (!newSessionTemplateId() || !newSessionStartsAt()) return;
|
||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||
setSessionSaving(true);
|
||||
try {
|
||||
await (apiClient as any).POST("/v1/catalog/class-sessions", {
|
||||
...authHeaders(),
|
||||
body: {
|
||||
templateId: newSessionTemplateId(),
|
||||
startsAt: new Date(newSessionStartsAt()).toISOString(),
|
||||
...(newSessionLocationId() ? { locationId: newSessionLocationId() } : {}),
|
||||
},
|
||||
});
|
||||
setNewSessionTemplateId(""); setNewSessionLocationId(""); setNewSessionStartsAt("");
|
||||
void refetchSessions();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
|
||||
} finally {
|
||||
setSessionSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSession = 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/class-sessions/${id}`, authHeaders());
|
||||
void refetchSessions();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Smazání selhalo." : "Delete failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
const map: Record<string, string> = { room: data.i18n.t("dashboard.zone.rooms"), private: data.i18n.t("dashboard.zone.private"), hall: data.i18n.t("dashboard.zone.hall") };
|
||||
return map[type] || type;
|
||||
@@ -192,11 +296,12 @@ function ZonesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 p-1 bg-canvas-subtle rounded-xl w-fit">
|
||||
<div class="flex flex-wrap gap-2 p-1 bg-canvas-subtle rounded-xl w-fit max-w-full">
|
||||
{[
|
||||
{ id: "zones", label: data.i18n.t("dashboard.zones"), icon: MapPinIcon },
|
||||
{ id: "blocked", label: data.i18n.t("dashboard.zone.blockedDays"), icon: BanIcon },
|
||||
{ id: "hours", label: data.i18n.t("dashboard.zone.workingHours"), icon: ClockIcon },
|
||||
{ id: "classes", label: data.i18n.t("dashboard.classes"), icon: UsersIcon },
|
||||
].map((tab) => (
|
||||
<button onClick={() => setActiveTab(tab.id as any)} class={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${activeTab() === tab.id ? "bg-canvas text-accent shadow-sm" : "text-ink-muted hover:text-ink"}`}>
|
||||
<tab.icon /> {tab.label}
|
||||
@@ -314,9 +419,22 @@ function ZonesPage() {
|
||||
|
||||
{activeTab() === "hours" && (
|
||||
<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 class="p-6 border-b border-border flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<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="w-full sm:w-56 shrink-0">
|
||||
<Select
|
||||
label={data.i18n.t("dashboard.zone.staff")}
|
||||
value={hoursScope()}
|
||||
onChange={(v) => setHoursScope(v)}
|
||||
options={[
|
||||
{ value: "", label: data.i18n.t("dashboard.zone.businessHours") },
|
||||
...(staffMembers() ?? []).map((m: any) => ({ value: m.id, label: m.displayName })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-border/60">
|
||||
<For each={workingHours() ?? []}>
|
||||
@@ -356,6 +474,108 @@ function ZonesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab() === "classes" && (
|
||||
<div class="space-y-6">
|
||||
<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.class.templates")}</h3>
|
||||
<p class="text-sm text-ink-muted mt-1">{data.i18n.t("dashboard.class.sessionHint")}</p>
|
||||
</div>
|
||||
<div class="p-6 border-b border-border bg-canvas-subtle/50">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Input type="text" label={data.i18n.t("dashboard.class.title")} value={newTemplateTitle()} onInput={(e) => setNewTemplateTitle(e.currentTarget.value)} placeholder={data.i18n.t("dashboard.class.titlePlaceholder")} />
|
||||
<Input type="number" label={data.i18n.t("dashboard.class.durationMin")} value={newTemplateDuration()} onInput={(e) => setNewTemplateDuration(parseInt(e.currentTarget.value) || 60)} />
|
||||
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newTemplateCapacity()} onInput={(e) => setNewTemplateCapacity(parseInt(e.currentTarget.value) || 8)} />
|
||||
<div class="flex items-end">
|
||||
<button onClick={() => void handleAddTemplate()} disabled={templateSaving() || !newTemplateTitle().trim()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
|
||||
{templateSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addTemplate")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={(classTemplates() ?? []).length > 0} fallback={
|
||||
<div class="p-10 text-center">
|
||||
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.class.noTemplates")}</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="divide-y divide-border/60">
|
||||
<For each={classTemplates() ?? []}>
|
||||
{(tpl: any) => (
|
||||
<div class="flex items-center justify-between p-4 px-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><UsersIcon /></div>
|
||||
<div>
|
||||
<p class="font-medium text-ink">{tpl.title}</p>
|
||||
<p class="text-sm text-ink-muted">{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} • {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => void handleDeleteTemplate(tpl.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>
|
||||
|
||||
<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.class.sessions")}</h3>
|
||||
</div>
|
||||
<div class="p-6 border-b border-border bg-canvas-subtle/50">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Select
|
||||
label={data.i18n.t("dashboard.class.template")}
|
||||
value={newSessionTemplateId()}
|
||||
onChange={(v) => setNewSessionTemplateId(v)}
|
||||
options={[
|
||||
{ value: "", label: "—" },
|
||||
...(classTemplates() ?? []).map((t: any) => ({ value: t.id, label: t.title })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label={data.i18n.t("dashboard.class.location")}
|
||||
value={newSessionLocationId()}
|
||||
onChange={(v) => setNewSessionLocationId(v)}
|
||||
options={[
|
||||
{ value: "", label: data.i18n.t("dashboard.class.noLocation") },
|
||||
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
|
||||
]}
|
||||
/>
|
||||
<Input type="datetime-local" label={data.i18n.t("dashboard.class.startsAt")} value={newSessionStartsAt()} onInput={(e) => setNewSessionStartsAt(e.currentTarget.value)} />
|
||||
<div class="flex items-end">
|
||||
<button onClick={() => void handleAddSession()} disabled={sessionSaving() || !newSessionTemplateId() || !newSessionStartsAt()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
|
||||
{sessionSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addSession")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={(classSessions() ?? []).length > 0} fallback={
|
||||
<div class="p-10 text-center">
|
||||
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.class.noSessions")}</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="divide-y divide-border/60">
|
||||
<For each={classSessions() ?? []}>
|
||||
{(session: any) => (
|
||||
<div class="flex items-center justify-between p-4 px-6">
|
||||
<div>
|
||||
<p class="font-medium text-ink">{session.templateTitle}</p>
|
||||
<p class="text-sm text-ink-muted">
|
||||
{new Date(session.startsAt).toLocaleString(cs() ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
|
||||
{session.locationName ? ` • ${session.locationName}` : ""}
|
||||
{` • ${session.bookedCount}/${session.capacity} ${data.i18n.t("dashboard.class.booked")}`}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => void handleDeleteSession(session.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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Show when={showUpgradePrompt()}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowUpgradePrompt(false)} />
|
||||
|
||||
Reference in new issue
Block a user