- Replace VictoryItem (type/place/category/competition/city/date) with RichListItem (text + optional link/image) - Remove VictoryItemListField, DateRangeField, CityField and related helpers - Remove experience field from admin form and user profile (can be in bio text) - Simplify TeamProfile: remove victory tabs, show achievements as RichCards - Fix auto-save: snapshot comparison prevents false saves on focus/blur - Add save on tab leave (visibilitychange) and page close (sendBeacon) - Add save after image uploads (main photo, achievements, education) - Auto-migrate old VictoryItem data to RichListItem format in DB parser
1393 lines
45 KiB
TypeScript
1393 lines
45 KiB
TypeScript
import Database from "better-sqlite3";
|
|
import path from "path";
|
|
import type { SiteContent, TeamMember, RichListItem } from "@/types/content";
|
|
import { MS_PER_DAY } from "@/lib/constants";
|
|
|
|
const DB_PATH =
|
|
process.env.DATABASE_PATH ||
|
|
path.join(process.cwd(), "db", "blackheart.db");
|
|
|
|
let _db: Database.Database | null = null;
|
|
|
|
export function getDb(): Database.Database {
|
|
if (!_db) {
|
|
_db = new Database(DB_PATH);
|
|
_db.pragma("journal_mode = WAL");
|
|
_db.pragma("foreign_keys = ON");
|
|
runMigrations(_db);
|
|
}
|
|
return _db;
|
|
}
|
|
|
|
// --- Migrations ---
|
|
// Each migration has a unique version number and runs exactly once.
|
|
// Add new migrations to the end of the array. Never modify existing ones.
|
|
|
|
interface Migration {
|
|
version: number;
|
|
name: string;
|
|
up: (db: Database.Database) => void;
|
|
}
|
|
|
|
const migrations: Migration[] = [
|
|
{
|
|
version: 1,
|
|
name: "create_sections_and_team_members",
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS sections (
|
|
key TEXT PRIMARY KEY,
|
|
data TEXT NOT NULL,
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS team_members (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
image TEXT NOT NULL,
|
|
instagram TEXT,
|
|
description TEXT,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
`);
|
|
},
|
|
},
|
|
{
|
|
version: 2,
|
|
name: "add_team_bio_columns",
|
|
up: (db) => {
|
|
const cols = db.prepare("PRAGMA table_info(team_members)").all() as { name: string }[];
|
|
const colNames = new Set(cols.map((c) => c.name));
|
|
if (!colNames.has("experience")) db.exec("ALTER TABLE team_members ADD COLUMN experience TEXT");
|
|
if (!colNames.has("victories")) db.exec("ALTER TABLE team_members ADD COLUMN victories TEXT");
|
|
if (!colNames.has("education")) db.exec("ALTER TABLE team_members ADD COLUMN education TEXT");
|
|
},
|
|
},
|
|
{
|
|
version: 3,
|
|
name: "create_mc_registrations",
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS mc_registrations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
master_class_title TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
instagram TEXT NOT NULL,
|
|
telegram TEXT,
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
`);
|
|
},
|
|
},
|
|
{
|
|
version: 4,
|
|
name: "add_mc_notification_tracking",
|
|
up: (db) => {
|
|
const cols = db.prepare("PRAGMA table_info(mc_registrations)").all() as { name: string }[];
|
|
const colNames = new Set(cols.map((c) => c.name));
|
|
if (!colNames.has("notified_confirm"))
|
|
db.exec("ALTER TABLE mc_registrations ADD COLUMN notified_confirm INTEGER NOT NULL DEFAULT 0");
|
|
if (!colNames.has("notified_reminder"))
|
|
db.exec("ALTER TABLE mc_registrations ADD COLUMN notified_reminder INTEGER NOT NULL DEFAULT 0");
|
|
},
|
|
},
|
|
{
|
|
version: 5,
|
|
name: "create_group_bookings",
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS group_bookings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
phone TEXT NOT NULL,
|
|
group_info TEXT,
|
|
notified_confirm INTEGER NOT NULL DEFAULT 0,
|
|
notified_reminder INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
`);
|
|
},
|
|
},
|
|
{
|
|
version: 6,
|
|
name: "create_open_day_tables",
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS open_day_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
date TEXT NOT NULL,
|
|
title TEXT NOT NULL DEFAULT 'День открытых дверей',
|
|
description TEXT,
|
|
price_per_class INTEGER NOT NULL DEFAULT 30,
|
|
discount_price INTEGER NOT NULL DEFAULT 20,
|
|
discount_threshold INTEGER NOT NULL DEFAULT 3,
|
|
min_bookings INTEGER NOT NULL DEFAULT 4,
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS open_day_classes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id INTEGER NOT NULL REFERENCES open_day_events(id) ON DELETE CASCADE,
|
|
hall TEXT NOT NULL,
|
|
start_time TEXT NOT NULL,
|
|
end_time TEXT NOT NULL,
|
|
trainer TEXT NOT NULL,
|
|
style TEXT NOT NULL,
|
|
cancelled INTEGER NOT NULL DEFAULT 0,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
UNIQUE(event_id, hall, start_time)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS open_day_bookings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
class_id INTEGER NOT NULL REFERENCES open_day_classes(id) ON DELETE CASCADE,
|
|
event_id INTEGER NOT NULL REFERENCES open_day_events(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
phone TEXT NOT NULL,
|
|
instagram TEXT,
|
|
telegram TEXT,
|
|
notified_confirm INTEGER NOT NULL DEFAULT 0,
|
|
notified_reminder INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
UNIQUE(class_id, phone)
|
|
);
|
|
`);
|
|
},
|
|
},
|
|
{
|
|
version: 7,
|
|
name: "unify_booking_fields",
|
|
up: (db) => {
|
|
// Add phone to mc_registrations
|
|
const mcCols = db.prepare("PRAGMA table_info(mc_registrations)").all() as { name: string }[];
|
|
const mcColNames = new Set(mcCols.map((c) => c.name));
|
|
if (!mcColNames.has("phone")) db.exec("ALTER TABLE mc_registrations ADD COLUMN phone TEXT");
|
|
|
|
// Add instagram/telegram to group_bookings
|
|
const gbCols = db.prepare("PRAGMA table_info(group_bookings)").all() as { name: string }[];
|
|
const gbColNames = new Set(gbCols.map((c) => c.name));
|
|
if (!gbColNames.has("instagram")) db.exec("ALTER TABLE group_bookings ADD COLUMN instagram TEXT");
|
|
if (!gbColNames.has("telegram")) db.exec("ALTER TABLE group_bookings ADD COLUMN telegram TEXT");
|
|
},
|
|
},
|
|
{
|
|
version: 8,
|
|
name: "add_reminder_status",
|
|
up: (db) => {
|
|
// reminder_status: null = not reminded, 'pending' = reminded but no answer, 'coming' = confirmed, 'cancelled' = not coming
|
|
for (const table of ["mc_registrations", "group_bookings", "open_day_bookings"]) {
|
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === "reminder_status")) {
|
|
db.exec(`ALTER TABLE ${table} ADD COLUMN reminder_status TEXT`);
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: 9,
|
|
name: "add_performance_indexes",
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_mc_registrations_title ON mc_registrations(master_class_title);
|
|
CREATE INDEX IF NOT EXISTS idx_mc_registrations_created ON mc_registrations(created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_group_bookings_created ON group_bookings(created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_open_day_bookings_event ON open_day_bookings(event_id);
|
|
CREATE INDEX IF NOT EXISTS idx_open_day_bookings_class ON open_day_bookings(class_id);
|
|
CREATE INDEX IF NOT EXISTS idx_open_day_bookings_phone ON open_day_bookings(event_id, phone);
|
|
CREATE INDEX IF NOT EXISTS idx_open_day_bookings_class_phone ON open_day_bookings(class_id, phone);
|
|
CREATE INDEX IF NOT EXISTS idx_open_day_classes_event ON open_day_classes(event_id);
|
|
`);
|
|
},
|
|
},
|
|
{
|
|
version: 10,
|
|
name: "add_group_booking_status",
|
|
up: (db) => {
|
|
const cols = db.prepare("PRAGMA table_info(group_bookings)").all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === "status")) {
|
|
db.exec("ALTER TABLE group_bookings ADD COLUMN status TEXT NOT NULL DEFAULT 'new'");
|
|
}
|
|
if (!cols.some((c) => c.name === "confirmed_date")) {
|
|
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_date TEXT");
|
|
}
|
|
if (!cols.some((c) => c.name === "confirmed_group")) {
|
|
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_group TEXT");
|
|
}
|
|
if (!cols.some((c) => c.name === "confirmed_comment")) {
|
|
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_comment TEXT");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: 11,
|
|
name: "add_team_short_description",
|
|
up: (db) => {
|
|
const cols = db.prepare("PRAGMA table_info(team_members)").all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === "short_description")) {
|
|
db.exec("ALTER TABLE team_members ADD COLUMN short_description TEXT");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: 12,
|
|
name: "add_status_to_mc_and_openday",
|
|
up: (db) => {
|
|
for (const table of ["mc_registrations", "open_day_bookings"]) {
|
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
|
|
const colNames = new Set(cols.map((c) => c.name));
|
|
if (!colNames.has("status")) {
|
|
db.exec(`ALTER TABLE ${table} ADD COLUMN status TEXT NOT NULL DEFAULT 'new'`);
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: 13,
|
|
name: "add_booking_notes",
|
|
up: (db) => {
|
|
for (const table of ["mc_registrations", "group_bookings", "open_day_bookings"]) {
|
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === "notes")) {
|
|
db.exec(`ALTER TABLE ${table} ADD COLUMN notes TEXT`);
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: 14,
|
|
name: "add_max_participants_to_open_day_classes",
|
|
up: (db) => {
|
|
const cols = db.prepare("PRAGMA table_info(open_day_classes)").all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === "max_participants")) {
|
|
db.exec("ALTER TABLE open_day_classes ADD COLUMN max_participants INTEGER NOT NULL DEFAULT 0");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: 15,
|
|
name: "add_max_participants_to_open_day_events",
|
|
up: (db) => {
|
|
const cols = db.prepare("PRAGMA table_info(open_day_events)").all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === "max_participants")) {
|
|
db.exec("ALTER TABLE open_day_events ADD COLUMN max_participants INTEGER NOT NULL DEFAULT 0");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: 16,
|
|
name: "add_messages_to_open_day_events",
|
|
up: (db) => {
|
|
const cols = db.prepare("PRAGMA table_info(open_day_events)").all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === "success_message")) {
|
|
db.exec("ALTER TABLE open_day_events ADD COLUMN success_message TEXT");
|
|
}
|
|
if (!cols.some((c) => c.name === "waiting_list_text")) {
|
|
db.exec("ALTER TABLE open_day_events ADD COLUMN waiting_list_text TEXT");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
version: 17,
|
|
name: "add_confirmed_hall_to_group_bookings",
|
|
up: (db) => {
|
|
const cols = db.prepare("PRAGMA table_info(group_bookings)").all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === "confirmed_hall")) {
|
|
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_hall TEXT");
|
|
}
|
|
},
|
|
},
|
|
];
|
|
|
|
function runMigrations(db: Database.Database) {
|
|
// Create migrations tracking table
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS _migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
applied_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
`);
|
|
|
|
const applied = new Set(
|
|
(db.prepare("SELECT version FROM _migrations").all() as { version: number }[])
|
|
.map((r) => r.version)
|
|
);
|
|
|
|
const pending = migrations.filter((m) => !applied.has(m.version));
|
|
if (pending.length === 0) return;
|
|
|
|
const insertMigration = db.prepare(
|
|
"INSERT INTO _migrations (version, name) VALUES (?, ?)"
|
|
);
|
|
|
|
const tx = db.transaction(() => {
|
|
for (const m of pending) {
|
|
m.up(db);
|
|
insertMigration.run(m.version, m.name);
|
|
}
|
|
});
|
|
tx();
|
|
}
|
|
|
|
// --- Sections ---
|
|
|
|
export function getSection<T = unknown>(key: string): T | null {
|
|
const db = getDb();
|
|
const row = db.prepare("SELECT data FROM sections WHERE key = ?").get(key) as
|
|
| { data: string }
|
|
| undefined;
|
|
return row ? (JSON.parse(row.data) as T) : null;
|
|
}
|
|
|
|
export function setSection(key: string, data: unknown): void {
|
|
const db = getDb();
|
|
db.prepare(
|
|
`INSERT INTO sections (key, data, updated_at) VALUES (?, ?, datetime('now'))
|
|
ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
|
|
).run(key, JSON.stringify(data));
|
|
}
|
|
|
|
// --- Team Members ---
|
|
|
|
interface TeamMemberRow {
|
|
id: number;
|
|
name: string;
|
|
role: string;
|
|
image: string;
|
|
instagram: string | null;
|
|
short_description: string | null;
|
|
description: string | null;
|
|
experience: string | null;
|
|
victories: string | null;
|
|
education: string | null;
|
|
sort_order: number;
|
|
}
|
|
|
|
function parseJsonArray(val: string | null): string[] | undefined {
|
|
if (!val) return undefined;
|
|
try { const arr = JSON.parse(val); return Array.isArray(arr) && arr.length > 0 ? arr : undefined; } catch { return undefined; }
|
|
}
|
|
|
|
function parseRichList(val: string | null): RichListItem[] | undefined {
|
|
if (!val) return undefined;
|
|
try {
|
|
const arr = JSON.parse(val);
|
|
if (!Array.isArray(arr) || arr.length === 0) return undefined;
|
|
// Handle both old string[] and new RichListItem[] formats
|
|
return arr.map((item: string | RichListItem) =>
|
|
typeof item === "string" ? { text: item } : item
|
|
);
|
|
} catch { return undefined; }
|
|
}
|
|
|
|
function parseVictoriesAsRichList(val: string | null): RichListItem[] | undefined {
|
|
if (!val) return undefined;
|
|
try {
|
|
const arr = JSON.parse(val);
|
|
if (!Array.isArray(arr) || arr.length === 0) return undefined;
|
|
// Migrate old VictoryItem[] → RichListItem[]
|
|
return arr.map((item: string | Record<string, unknown>) => {
|
|
if (typeof item === "string") return { text: item };
|
|
if ("text" in item) return { text: item.text as string, image: item.image as string | undefined, link: item.link as string | undefined };
|
|
// Old VictoryItem format: combine place + category + competition into text
|
|
const parts = [item.place, item.category, item.competition].filter(Boolean);
|
|
return { text: parts.join(" · "), image: item.image as string | undefined, link: item.link as string | undefined };
|
|
});
|
|
} catch { return undefined; }
|
|
}
|
|
|
|
export function getTeamMembers(): (TeamMember & { id: number })[] {
|
|
const db = getDb();
|
|
const rows = db
|
|
.prepare("SELECT * FROM team_members ORDER BY sort_order ASC, id ASC")
|
|
.all() as TeamMemberRow[];
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
role: r.role,
|
|
image: r.image,
|
|
instagram: r.instagram ?? undefined,
|
|
shortDescription: r.short_description ?? undefined,
|
|
description: r.description ?? undefined,
|
|
victories: parseVictoriesAsRichList(r.victories),
|
|
education: parseRichList(r.education),
|
|
}));
|
|
}
|
|
|
|
export function getTeamMember(
|
|
id: number
|
|
): (TeamMember & { id: number }) | null {
|
|
const db = getDb();
|
|
const r = db
|
|
.prepare("SELECT * FROM team_members WHERE id = ?")
|
|
.get(id) as TeamMemberRow | undefined;
|
|
if (!r) return null;
|
|
return {
|
|
id: r.id,
|
|
name: r.name,
|
|
role: r.role,
|
|
image: r.image,
|
|
instagram: r.instagram ?? undefined,
|
|
shortDescription: r.short_description ?? undefined,
|
|
description: r.description ?? undefined,
|
|
victories: parseVictoriesAsRichList(r.victories),
|
|
education: parseRichList(r.education),
|
|
};
|
|
}
|
|
|
|
export function createTeamMember(
|
|
data: Omit<TeamMember, "id">
|
|
): number {
|
|
const db = getDb();
|
|
const maxOrder = db
|
|
.prepare("SELECT COALESCE(MAX(sort_order), -1) as max FROM team_members")
|
|
.get() as { max: number };
|
|
const result = db
|
|
.prepare(
|
|
`INSERT INTO team_members (name, role, image, instagram, short_description, description, experience, victories, education, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
data.name,
|
|
data.role,
|
|
data.image,
|
|
data.instagram ?? null,
|
|
data.shortDescription ?? null,
|
|
data.description ?? null,
|
|
null,
|
|
data.victories?.length ? JSON.stringify(data.victories) : null,
|
|
data.education?.length ? JSON.stringify(data.education) : null,
|
|
maxOrder.max + 1
|
|
);
|
|
return result.lastInsertRowid as number;
|
|
}
|
|
|
|
export function updateTeamMember(
|
|
id: number,
|
|
data: Partial<Omit<TeamMember, "id">>
|
|
): void {
|
|
const db = getDb();
|
|
const fields: string[] = [];
|
|
const values: unknown[] = [];
|
|
|
|
if (data.name !== undefined) { fields.push("name = ?"); values.push(data.name); }
|
|
if (data.role !== undefined) { fields.push("role = ?"); values.push(data.role); }
|
|
if (data.image !== undefined) { fields.push("image = ?"); values.push(data.image); }
|
|
if (data.instagram !== undefined) { fields.push("instagram = ?"); values.push(data.instagram || null); }
|
|
if (data.shortDescription !== undefined) { fields.push("short_description = ?"); values.push(data.shortDescription || null); }
|
|
if (data.description !== undefined) { fields.push("description = ?"); values.push(data.description || null); }
|
|
if (data.victories !== undefined) { fields.push("victories = ?"); values.push(data.victories?.length ? JSON.stringify(data.victories) : null); }
|
|
if (data.education !== undefined) { fields.push("education = ?"); values.push(data.education?.length ? JSON.stringify(data.education) : null); }
|
|
|
|
if (fields.length === 0) return;
|
|
fields.push("updated_at = datetime('now')");
|
|
values.push(id);
|
|
|
|
db.prepare(`UPDATE team_members SET ${fields.join(", ")} WHERE id = ?`).run(
|
|
...values
|
|
);
|
|
}
|
|
|
|
export function deleteTeamMember(id: number): void {
|
|
const db = getDb();
|
|
db.prepare("DELETE FROM team_members WHERE id = ?").run(id);
|
|
}
|
|
|
|
export function reorderTeamMembers(ids: number[]): void {
|
|
const db = getDb();
|
|
const stmt = db.prepare(
|
|
"UPDATE team_members SET sort_order = ? WHERE id = ?"
|
|
);
|
|
const tx = db.transaction(() => {
|
|
ids.forEach((id, index) => stmt.run(index, id));
|
|
});
|
|
tx();
|
|
}
|
|
|
|
// --- Full site content ---
|
|
|
|
const SECTION_KEYS = [
|
|
"meta",
|
|
"hero",
|
|
"about",
|
|
"classes",
|
|
"masterClasses",
|
|
"faq",
|
|
"pricing",
|
|
"schedule",
|
|
"news",
|
|
"contact",
|
|
] as const;
|
|
|
|
export function getSiteContent(): SiteContent | null {
|
|
const db = getDb();
|
|
const rows = db.prepare("SELECT key, data FROM sections").all() as {
|
|
key: string;
|
|
data: string;
|
|
}[];
|
|
|
|
if (rows.length === 0) return null;
|
|
|
|
const sections: Record<string, unknown> = {};
|
|
for (const row of rows) {
|
|
sections[row.key] = JSON.parse(row.data);
|
|
}
|
|
|
|
// Merge team members from dedicated table
|
|
const members = getTeamMembers();
|
|
const teamSection = (sections.team as { title?: string }) || {};
|
|
|
|
return {
|
|
meta: sections.meta,
|
|
hero: sections.hero,
|
|
about: sections.about,
|
|
classes: sections.classes,
|
|
masterClasses: sections.masterClasses ?? { title: "Мастер-классы", items: [] },
|
|
faq: sections.faq,
|
|
pricing: sections.pricing,
|
|
schedule: sections.schedule,
|
|
news: sections.news ?? { title: "Новости", items: [] },
|
|
contact: sections.contact,
|
|
team: {
|
|
title: teamSection.title || "",
|
|
members: members.map(({ id, ...rest }) => rest),
|
|
},
|
|
} as SiteContent;
|
|
}
|
|
|
|
export function isDatabaseSeeded(): boolean {
|
|
const db = getDb();
|
|
const row = db
|
|
.prepare("SELECT COUNT(*) as count FROM sections")
|
|
.get() as { count: number };
|
|
return row.count > 0;
|
|
}
|
|
|
|
// --- MC Registrations ---
|
|
|
|
interface McRegistrationRow {
|
|
id: number;
|
|
master_class_title: string;
|
|
name: string;
|
|
instagram: string;
|
|
phone: string | null;
|
|
telegram: string | null;
|
|
created_at: string;
|
|
notified_confirm: number;
|
|
notified_reminder: number;
|
|
reminder_status: string | null;
|
|
status: string;
|
|
notes: string | null;
|
|
}
|
|
|
|
export interface McRegistration {
|
|
id: number;
|
|
masterClassTitle: string;
|
|
name: string;
|
|
instagram: string;
|
|
phone?: string;
|
|
telegram?: string;
|
|
createdAt: string;
|
|
notifiedConfirm: boolean;
|
|
notifiedReminder: boolean;
|
|
reminderStatus?: string;
|
|
status: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export function addMcRegistration(
|
|
masterClassTitle: string,
|
|
name: string,
|
|
instagram: string,
|
|
telegram?: string,
|
|
phone?: string,
|
|
notes?: string
|
|
): number {
|
|
const db = getDb();
|
|
const result = db
|
|
.prepare(
|
|
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(masterClassTitle, name, instagram, telegram || null, phone || null, notes || null);
|
|
return result.lastInsertRowid as number;
|
|
}
|
|
|
|
export function getMcRegistrations(masterClassTitle: string): McRegistration[] {
|
|
const db = getDb();
|
|
const rows = db
|
|
.prepare(
|
|
"SELECT * FROM mc_registrations WHERE master_class_title = ? ORDER BY created_at DESC"
|
|
)
|
|
.all(masterClassTitle) as McRegistrationRow[];
|
|
return rows.map(mapMcRow);
|
|
}
|
|
|
|
export function getAllMcRegistrations(): McRegistration[] {
|
|
const db = getDb();
|
|
const rows = db
|
|
.prepare("SELECT * FROM mc_registrations ORDER BY created_at DESC")
|
|
.all() as McRegistrationRow[];
|
|
return rows.map(mapMcRow);
|
|
}
|
|
|
|
function mapMcRow(r: McRegistrationRow): McRegistration {
|
|
return {
|
|
id: r.id,
|
|
masterClassTitle: r.master_class_title,
|
|
name: r.name,
|
|
instagram: r.instagram,
|
|
phone: r.phone ?? undefined,
|
|
telegram: r.telegram ?? undefined,
|
|
createdAt: r.created_at,
|
|
notifiedConfirm: !!r.notified_confirm,
|
|
notifiedReminder: !!r.notified_reminder,
|
|
reminderStatus: r.reminder_status ?? undefined,
|
|
status: r.status || "new",
|
|
notes: r.notes ?? undefined,
|
|
};
|
|
}
|
|
|
|
export function setMcRegistrationStatus(id: number, status: string): void {
|
|
const db = getDb();
|
|
db.prepare("UPDATE mc_registrations SET status = ?, notified_confirm = 1 WHERE id = ?").run(status, id);
|
|
}
|
|
|
|
export function updateMcRegistration(
|
|
id: number,
|
|
name: string,
|
|
instagram: string,
|
|
telegram?: string
|
|
): void {
|
|
const db = getDb();
|
|
db.prepare(
|
|
"UPDATE mc_registrations SET name = ?, instagram = ?, telegram = ? WHERE id = ?"
|
|
).run(name, instagram, telegram || null, id);
|
|
}
|
|
|
|
export function toggleMcNotification(
|
|
id: number,
|
|
field: "notified_confirm" | "notified_reminder",
|
|
value: boolean
|
|
): void {
|
|
const db = getDb();
|
|
db.prepare(
|
|
`UPDATE mc_registrations SET ${field} = ? WHERE id = ?`
|
|
).run(value ? 1 : 0, id);
|
|
}
|
|
|
|
export function deleteMcRegistration(id: number): void {
|
|
const db = getDb();
|
|
db.prepare("DELETE FROM mc_registrations WHERE id = ?").run(id);
|
|
}
|
|
|
|
// --- Group Bookings ---
|
|
|
|
interface GroupBookingRow {
|
|
id: number;
|
|
name: string;
|
|
phone: string;
|
|
group_info: string | null;
|
|
instagram: string | null;
|
|
telegram: string | null;
|
|
notified_confirm: number;
|
|
notified_reminder: number;
|
|
reminder_status: string | null;
|
|
status: string;
|
|
confirmed_date: string | null;
|
|
confirmed_group: string | null;
|
|
confirmed_hall: string | null;
|
|
confirmed_comment: string | null;
|
|
notes: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
|
|
|
export interface GroupBooking {
|
|
id: number;
|
|
name: string;
|
|
phone: string;
|
|
groupInfo?: string;
|
|
instagram?: string;
|
|
telegram?: string;
|
|
notifiedConfirm: boolean;
|
|
notifiedReminder: boolean;
|
|
reminderStatus?: string;
|
|
status: BookingStatus;
|
|
confirmedDate?: string;
|
|
confirmedGroup?: string;
|
|
confirmedHall?: string;
|
|
confirmedComment?: string;
|
|
notes?: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export function addGroupBooking(
|
|
name: string,
|
|
phone: string,
|
|
groupInfo?: string,
|
|
instagram?: string,
|
|
telegram?: string
|
|
): number {
|
|
const db = getDb();
|
|
const result = db
|
|
.prepare(
|
|
"INSERT INTO group_bookings (name, phone, group_info, instagram, telegram) VALUES (?, ?, ?, ?, ?)"
|
|
)
|
|
.run(name, phone, groupInfo || null, instagram || null, telegram || null);
|
|
return result.lastInsertRowid as number;
|
|
}
|
|
|
|
export function getGroupBookings(): GroupBooking[] {
|
|
const db = getDb();
|
|
const rows = db
|
|
.prepare("SELECT * FROM group_bookings ORDER BY created_at DESC")
|
|
.all() as GroupBookingRow[];
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
phone: r.phone,
|
|
groupInfo: r.group_info ?? undefined,
|
|
instagram: r.instagram ?? undefined,
|
|
telegram: r.telegram ?? undefined,
|
|
notifiedConfirm: !!r.notified_confirm,
|
|
notifiedReminder: !!r.notified_reminder,
|
|
reminderStatus: r.reminder_status ?? undefined,
|
|
status: (r.status || "new") as BookingStatus,
|
|
confirmedDate: r.confirmed_date ?? undefined,
|
|
confirmedGroup: r.confirmed_group ?? undefined,
|
|
confirmedHall: r.confirmed_hall ?? undefined,
|
|
confirmedComment: r.confirmed_comment ?? undefined,
|
|
notes: r.notes ?? undefined,
|
|
createdAt: r.created_at,
|
|
}));
|
|
}
|
|
|
|
export function setGroupBookingStatus(
|
|
id: number,
|
|
status: BookingStatus,
|
|
confirmation?: { date: string; group: string; hall?: string; comment?: string }
|
|
): void {
|
|
const db = getDb();
|
|
if (status === "confirmed" && confirmation) {
|
|
// Auto-set reminder to 'coming' only if confirmed for today/tomorrow
|
|
const today = new Date().toISOString().split("T")[0];
|
|
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
|
|
const reminderStatus = (confirmation.date === today || confirmation.date === tomorrow) ? "coming" : null;
|
|
db.prepare(
|
|
"UPDATE group_bookings SET status = ?, confirmed_date = ?, confirmed_group = ?, confirmed_hall = ?, confirmed_comment = ?, notified_confirm = 1, reminder_status = ? WHERE id = ?"
|
|
).run(status, confirmation.date, confirmation.group, confirmation.hall || null, confirmation.comment || null, reminderStatus, id);
|
|
} else {
|
|
db.prepare(
|
|
"UPDATE group_bookings SET status = ?, confirmed_date = NULL, confirmed_group = NULL, confirmed_comment = NULL, notified_confirm = 1 WHERE id = ?"
|
|
).run(status, id);
|
|
}
|
|
}
|
|
|
|
export function updateGroupBooking(
|
|
id: number,
|
|
name: string,
|
|
phone: string,
|
|
groupInfo?: string
|
|
): void {
|
|
const db = getDb();
|
|
db.prepare(
|
|
"UPDATE group_bookings SET name = ?, phone = ?, group_info = ? WHERE id = ?"
|
|
).run(name, phone, groupInfo || null, id);
|
|
}
|
|
|
|
export function toggleGroupBookingNotification(
|
|
id: number,
|
|
field: "notified_confirm" | "notified_reminder",
|
|
value: boolean
|
|
): void {
|
|
const db = getDb();
|
|
db.prepare(`UPDATE group_bookings SET ${field} = ? WHERE id = ?`).run(
|
|
value ? 1 : 0,
|
|
id
|
|
);
|
|
}
|
|
|
|
export function deleteGroupBooking(id: number): void {
|
|
const db = getDb();
|
|
db.prepare("DELETE FROM group_bookings WHERE id = ?").run(id);
|
|
}
|
|
|
|
// --- Unread booking counts (lightweight) ---
|
|
|
|
export interface UnreadCounts {
|
|
groupBookings: number;
|
|
mcRegistrations: number;
|
|
openDayBookings: number;
|
|
total: number;
|
|
}
|
|
|
|
// --- Reminder status ---
|
|
|
|
export type ReminderStatus = "pending" | "coming" | "cancelled";
|
|
|
|
export function setReminderStatus(
|
|
table: "mc_registrations" | "group_bookings" | "open_day_bookings",
|
|
id: number,
|
|
status: ReminderStatus | null
|
|
): void {
|
|
const db = getDb();
|
|
db.prepare(`UPDATE ${table} SET reminder_status = ? WHERE id = ?`).run(status, id);
|
|
}
|
|
|
|
export function updateBookingNotes(
|
|
table: "mc_registrations" | "group_bookings" | "open_day_bookings",
|
|
id: number,
|
|
notes: string
|
|
): void {
|
|
const db = getDb();
|
|
db.prepare(`UPDATE ${table} SET notes = ? WHERE id = ?`).run(notes || null, id);
|
|
}
|
|
|
|
export interface ReminderItem {
|
|
id: number;
|
|
type: "class" | "master-class" | "open-day";
|
|
table: "mc_registrations" | "group_bookings" | "open_day_bookings";
|
|
name: string;
|
|
phone?: string;
|
|
instagram?: string;
|
|
telegram?: string;
|
|
reminderStatus?: string;
|
|
eventLabel: string;
|
|
eventDate: string;
|
|
}
|
|
|
|
export function getUpcomingReminders(): ReminderItem[] {
|
|
const db = getDb();
|
|
const items: ReminderItem[] = [];
|
|
|
|
// Tomorrow and today dates
|
|
const now = new Date();
|
|
const today = now.toISOString().split("T")[0];
|
|
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
|
|
|
// MC registrations — match slots from sections JSON
|
|
try {
|
|
const mcSection = db.prepare("SELECT data FROM sections WHERE key = 'masterClasses'").get() as { data: string } | undefined;
|
|
if (mcSection) {
|
|
const mcData = JSON.parse(mcSection.data) as { items: { title: string; slots: { date: string; startTime?: string }[] }[] };
|
|
// Find MC titles with slots today or tomorrow
|
|
const upcomingTitles: { title: string; date: string; time?: string }[] = [];
|
|
for (const mc of mcData.items || []) {
|
|
for (const slot of mc.slots || []) {
|
|
if (slot.date === today || slot.date === tomorrow) {
|
|
upcomingTitles.push({ title: mc.title, date: slot.date, time: slot.startTime });
|
|
}
|
|
}
|
|
}
|
|
if (upcomingTitles.length > 0) {
|
|
const uniqueTitles = [...new Set(upcomingTitles.map((t) => t.title))];
|
|
const placeholders = uniqueTitles.map(() => "?").join(", ");
|
|
const rows = db.prepare(
|
|
`SELECT * FROM mc_registrations WHERE master_class_title IN (${placeholders})`
|
|
).all(...uniqueTitles) as McRegistrationRow[];
|
|
|
|
// Build a lookup: title → { date, time }
|
|
const titleInfo = new Map<string, { date: string; time?: string }>();
|
|
for (const t of upcomingTitles) {
|
|
titleInfo.set(t.title, { date: t.date, time: t.time });
|
|
}
|
|
|
|
for (const r of rows) {
|
|
const info = titleInfo.get(r.master_class_title);
|
|
if (!info) continue;
|
|
items.push({
|
|
id: r.id,
|
|
type: "master-class",
|
|
table: "mc_registrations",
|
|
name: r.name,
|
|
phone: r.phone ?? undefined,
|
|
instagram: r.instagram ?? undefined,
|
|
telegram: r.telegram ?? undefined,
|
|
reminderStatus: r.reminder_status ?? undefined,
|
|
eventLabel: `${r.master_class_title}${info.time ? ` · ${info.time}` : ""}`,
|
|
eventDate: info.date,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
// Group bookings — confirmed with date today/tomorrow
|
|
try {
|
|
const gbRows = db.prepare(
|
|
"SELECT * FROM group_bookings WHERE status IN ('confirmed', 'contacted') AND confirmed_date IN (?, ?)"
|
|
).all(today, tomorrow) as GroupBookingRow[];
|
|
for (const r of gbRows) {
|
|
items.push({
|
|
id: r.id,
|
|
type: "class",
|
|
table: "group_bookings",
|
|
name: r.name,
|
|
phone: r.phone ?? undefined,
|
|
instagram: r.instagram ?? undefined,
|
|
telegram: r.telegram ?? undefined,
|
|
reminderStatus: r.reminder_status ?? undefined,
|
|
eventLabel: r.confirmed_group || "Занятие",
|
|
eventHall: r.confirmed_hall ?? undefined,
|
|
eventDate: r.confirmed_date!,
|
|
});
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
// Open Day bookings — check event date
|
|
try {
|
|
const events = db.prepare(
|
|
"SELECT id, date, title FROM open_day_events WHERE date IN (?, ?) AND active = 1"
|
|
).all(today, tomorrow) as { id: number; date: string; title: string }[];
|
|
for (const ev of events) {
|
|
const rows = db.prepare(
|
|
`SELECT b.*, c.style as class_style, c.trainer as class_trainer, c.start_time as class_time, c.hall as class_hall
|
|
FROM open_day_bookings b
|
|
JOIN open_day_classes c ON c.id = b.class_id
|
|
WHERE b.event_id = ? AND c.cancelled = 0`
|
|
).all(ev.id) as OpenDayBookingRow[];
|
|
for (const r of rows) {
|
|
items.push({
|
|
id: r.id,
|
|
type: "open-day",
|
|
table: "open_day_bookings",
|
|
name: r.name,
|
|
phone: r.phone ?? undefined,
|
|
instagram: r.instagram ?? undefined,
|
|
telegram: r.telegram ?? undefined,
|
|
reminderStatus: r.reminder_status ?? undefined,
|
|
eventLabel: `${r.class_style} · ${r.class_trainer} · ${r.class_time}`,
|
|
eventHall: r.class_hall ?? undefined,
|
|
eventDate: ev.date,
|
|
});
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
return items;
|
|
}
|
|
|
|
export function getUnreadBookingCounts(): UnreadCounts {
|
|
const db = getDb();
|
|
const gb = (db.prepare("SELECT COUNT(*) as c FROM group_bookings WHERE notified_confirm = 0").get() as { c: number }).c;
|
|
const mc = (db.prepare("SELECT COUNT(*) as c FROM mc_registrations WHERE notified_confirm = 0").get() as { c: number }).c;
|
|
|
|
let od = 0;
|
|
// Check if open_day_bookings table exists (might not if no migration yet)
|
|
try {
|
|
od = (db.prepare("SELECT COUNT(*) as c FROM open_day_bookings WHERE notified_confirm = 0").get() as { c: number }).c;
|
|
} catch { /* table doesn't exist yet */ }
|
|
|
|
return { groupBookings: gb, mcRegistrations: mc, openDayBookings: od, total: gb + mc + od };
|
|
}
|
|
|
|
// --- Open Day Events ---
|
|
|
|
interface OpenDayEventRow {
|
|
id: number;
|
|
date: string;
|
|
title: string;
|
|
description: string | null;
|
|
price_per_class: number;
|
|
discount_price: number;
|
|
discount_threshold: number;
|
|
min_bookings: number;
|
|
max_participants: number;
|
|
success_message: string | null;
|
|
waiting_list_text: string | null;
|
|
active: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface OpenDayEvent {
|
|
id: number;
|
|
date: string;
|
|
title: string;
|
|
description?: string;
|
|
pricePerClass: number;
|
|
discountPrice: number;
|
|
discountThreshold: number;
|
|
minBookings: number;
|
|
maxParticipants: number;
|
|
successMessage?: string;
|
|
waitingListText?: string;
|
|
active: boolean;
|
|
}
|
|
|
|
interface OpenDayClassRow {
|
|
id: number;
|
|
event_id: number;
|
|
hall: string;
|
|
start_time: string;
|
|
end_time: string;
|
|
trainer: string;
|
|
style: string;
|
|
cancelled: number;
|
|
sort_order: number;
|
|
max_participants: number;
|
|
booking_count?: number;
|
|
}
|
|
|
|
export interface OpenDayClass {
|
|
id: number;
|
|
eventId: number;
|
|
hall: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
trainer: string;
|
|
style: string;
|
|
cancelled: boolean;
|
|
sortOrder: number;
|
|
maxParticipants: number;
|
|
bookingCount: number;
|
|
}
|
|
|
|
interface OpenDayBookingRow {
|
|
id: number;
|
|
class_id: number;
|
|
event_id: number;
|
|
name: string;
|
|
phone: string;
|
|
instagram: string | null;
|
|
telegram: string | null;
|
|
notified_confirm: number;
|
|
notified_reminder: number;
|
|
reminder_status: string | null;
|
|
status: string;
|
|
notes: string | null;
|
|
created_at: string;
|
|
class_style?: string;
|
|
class_trainer?: string;
|
|
class_time?: string;
|
|
class_hall?: string;
|
|
}
|
|
|
|
export interface OpenDayBooking {
|
|
id: number;
|
|
classId: number;
|
|
eventId: number;
|
|
name: string;
|
|
phone: string;
|
|
instagram?: string;
|
|
telegram?: string;
|
|
notifiedConfirm: boolean;
|
|
notifiedReminder: boolean;
|
|
reminderStatus?: string;
|
|
status: string;
|
|
notes?: string;
|
|
createdAt: string;
|
|
classStyle?: string;
|
|
classTrainer?: string;
|
|
classTime?: string;
|
|
classHall?: string;
|
|
}
|
|
|
|
function mapEventRow(r: OpenDayEventRow): OpenDayEvent {
|
|
return {
|
|
id: r.id,
|
|
date: r.date,
|
|
title: r.title,
|
|
description: r.description ?? undefined,
|
|
pricePerClass: r.price_per_class,
|
|
discountPrice: r.discount_price,
|
|
discountThreshold: r.discount_threshold,
|
|
minBookings: r.min_bookings,
|
|
maxParticipants: r.max_participants ?? 0,
|
|
successMessage: r.success_message ?? undefined,
|
|
waitingListText: r.waiting_list_text ?? undefined,
|
|
active: !!r.active,
|
|
};
|
|
}
|
|
|
|
function mapClassRow(r: OpenDayClassRow): OpenDayClass {
|
|
return {
|
|
id: r.id,
|
|
eventId: r.event_id,
|
|
hall: r.hall,
|
|
startTime: r.start_time,
|
|
endTime: r.end_time,
|
|
trainer: r.trainer,
|
|
style: r.style,
|
|
cancelled: !!r.cancelled,
|
|
sortOrder: r.sort_order,
|
|
maxParticipants: r.max_participants ?? 0,
|
|
bookingCount: r.booking_count ?? 0,
|
|
};
|
|
}
|
|
|
|
export function getConfirmedOpenDayBookingCount(classId: number): number {
|
|
const db = getDb();
|
|
const row = db.prepare(
|
|
"SELECT COUNT(*) as cnt FROM open_day_bookings WHERE class_id = ? AND status = 'confirmed'"
|
|
).get(classId) as { cnt: number };
|
|
return row.cnt;
|
|
}
|
|
|
|
export function setOpenDayBookingStatus(id: number, status: string): void {
|
|
const db = getDb();
|
|
db.prepare("UPDATE open_day_bookings SET status = ?, notified_confirm = 1 WHERE id = ?").run(status, id);
|
|
}
|
|
|
|
function mapBookingRow(r: OpenDayBookingRow): OpenDayBooking {
|
|
return {
|
|
id: r.id,
|
|
classId: r.class_id,
|
|
eventId: r.event_id,
|
|
name: r.name,
|
|
phone: r.phone,
|
|
instagram: r.instagram ?? undefined,
|
|
telegram: r.telegram ?? undefined,
|
|
notifiedConfirm: !!r.notified_confirm,
|
|
notifiedReminder: !!r.notified_reminder,
|
|
reminderStatus: r.reminder_status ?? undefined,
|
|
status: r.status || "new",
|
|
notes: r.notes ?? undefined,
|
|
createdAt: r.created_at,
|
|
classStyle: r.class_style ?? undefined,
|
|
classTrainer: r.class_trainer ?? undefined,
|
|
classTime: r.class_time ?? undefined,
|
|
classHall: r.class_hall ?? undefined,
|
|
};
|
|
}
|
|
|
|
export function createOpenDayEvent(data: {
|
|
date: string;
|
|
title?: string;
|
|
description?: string;
|
|
pricePerClass?: number;
|
|
discountPrice?: number;
|
|
discountThreshold?: number;
|
|
minBookings?: number;
|
|
}): number {
|
|
const db = getDb();
|
|
const result = db
|
|
.prepare(
|
|
`INSERT INTO open_day_events (date, title, description, price_per_class, discount_price, discount_threshold, min_bookings)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
data.date,
|
|
data.title || "День открытых дверей",
|
|
data.description || null,
|
|
data.pricePerClass ?? 30,
|
|
data.discountPrice ?? 20,
|
|
data.discountThreshold ?? 3,
|
|
data.minBookings ?? 4
|
|
);
|
|
return result.lastInsertRowid as number;
|
|
}
|
|
|
|
export function getOpenDayEvents(): OpenDayEvent[] {
|
|
const db = getDb();
|
|
const rows = db
|
|
.prepare("SELECT * FROM open_day_events ORDER BY date DESC")
|
|
.all() as OpenDayEventRow[];
|
|
return rows.map(mapEventRow);
|
|
}
|
|
|
|
export function getOpenDayEvent(id: number): OpenDayEvent | null {
|
|
const db = getDb();
|
|
const row = db
|
|
.prepare("SELECT * FROM open_day_events WHERE id = ?")
|
|
.get(id) as OpenDayEventRow | undefined;
|
|
return row ? mapEventRow(row) : null;
|
|
}
|
|
|
|
export function getActiveOpenDayEvent(): OpenDayEvent | null {
|
|
const db = getDb();
|
|
const row = db
|
|
.prepare(
|
|
"SELECT * FROM open_day_events WHERE active = 1 AND date >= date('now') ORDER BY date ASC LIMIT 1"
|
|
)
|
|
.get() as OpenDayEventRow | undefined;
|
|
return row ? mapEventRow(row) : null;
|
|
}
|
|
|
|
export function updateOpenDayEvent(
|
|
id: number,
|
|
data: Partial<{
|
|
date: string;
|
|
title: string;
|
|
description: string;
|
|
pricePerClass: number;
|
|
discountPrice: number;
|
|
discountThreshold: number;
|
|
minBookings: number;
|
|
maxParticipants: number;
|
|
successMessage: string;
|
|
waitingListText: string;
|
|
active: boolean;
|
|
}>
|
|
): void {
|
|
const db = getDb();
|
|
const sets: string[] = [];
|
|
const vals: unknown[] = [];
|
|
if (data.date !== undefined) { sets.push("date = ?"); vals.push(data.date); }
|
|
if (data.title !== undefined) { sets.push("title = ?"); vals.push(data.title); }
|
|
if (data.description !== undefined) { sets.push("description = ?"); vals.push(data.description || null); }
|
|
if (data.pricePerClass !== undefined) { sets.push("price_per_class = ?"); vals.push(data.pricePerClass); }
|
|
if (data.discountPrice !== undefined) { sets.push("discount_price = ?"); vals.push(data.discountPrice); }
|
|
if (data.discountThreshold !== undefined) { sets.push("discount_threshold = ?"); vals.push(data.discountThreshold); }
|
|
if (data.minBookings !== undefined) { sets.push("min_bookings = ?"); vals.push(data.minBookings); }
|
|
if (data.maxParticipants !== undefined) { sets.push("max_participants = ?"); vals.push(data.maxParticipants); }
|
|
if (data.successMessage !== undefined) { sets.push("success_message = ?"); vals.push(data.successMessage || null); }
|
|
if (data.waitingListText !== undefined) { sets.push("waiting_list_text = ?"); vals.push(data.waitingListText || null); }
|
|
if (data.active !== undefined) { sets.push("active = ?"); vals.push(data.active ? 1 : 0); }
|
|
if (sets.length === 0) return;
|
|
sets.push("updated_at = datetime('now')");
|
|
vals.push(id);
|
|
db.prepare(`UPDATE open_day_events SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
|
}
|
|
|
|
export function deleteOpenDayEvent(id: number): void {
|
|
const db = getDb();
|
|
db.prepare("DELETE FROM open_day_events WHERE id = ?").run(id);
|
|
}
|
|
|
|
// --- Open Day Classes ---
|
|
|
|
export function addOpenDayClass(
|
|
eventId: number,
|
|
data: { hall: string; startTime: string; endTime: string; trainer: string; style: string }
|
|
): number {
|
|
const db = getDb();
|
|
const maxOrder = (
|
|
db.prepare("SELECT MAX(sort_order) as m FROM open_day_classes WHERE event_id = ?").get(eventId) as { m: number | null }
|
|
).m ?? -1;
|
|
const result = db
|
|
.prepare(
|
|
`INSERT INTO open_day_classes (event_id, hall, start_time, end_time, trainer, style, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(eventId, data.hall, data.startTime, data.endTime, data.trainer, data.style, maxOrder + 1);
|
|
return result.lastInsertRowid as number;
|
|
}
|
|
|
|
export function getOpenDayClassById(classId: number): OpenDayClass | null {
|
|
const db = getDb();
|
|
const row = db.prepare(
|
|
`SELECT c.*, COALESCE(b.cnt, 0) as booking_count
|
|
FROM open_day_classes c
|
|
LEFT JOIN (SELECT class_id, COUNT(*) as cnt FROM open_day_bookings GROUP BY class_id) b ON b.class_id = c.id
|
|
WHERE c.id = ?`
|
|
).get(classId) as OpenDayClassRow | undefined;
|
|
return row ? mapClassRow(row) : null;
|
|
}
|
|
|
|
export function getOpenDayClasses(eventId: number): OpenDayClass[] {
|
|
const db = getDb();
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT c.*, COALESCE(b.cnt, 0) as booking_count
|
|
FROM open_day_classes c
|
|
LEFT JOIN (SELECT class_id, COUNT(*) as cnt FROM open_day_bookings GROUP BY class_id) b ON b.class_id = c.id
|
|
WHERE c.event_id = ?
|
|
ORDER BY c.hall, c.start_time`
|
|
)
|
|
.all(eventId) as OpenDayClassRow[];
|
|
return rows.map(mapClassRow);
|
|
}
|
|
|
|
export function updateOpenDayClass(
|
|
id: number,
|
|
data: Partial<{ hall: string; startTime: string; endTime: string; trainer: string; style: string; cancelled: boolean; sortOrder: number; maxParticipants: number }>
|
|
): void {
|
|
const db = getDb();
|
|
const sets: string[] = [];
|
|
const vals: unknown[] = [];
|
|
if (data.hall !== undefined) { sets.push("hall = ?"); vals.push(data.hall); }
|
|
if (data.startTime !== undefined) { sets.push("start_time = ?"); vals.push(data.startTime); }
|
|
if (data.endTime !== undefined) { sets.push("end_time = ?"); vals.push(data.endTime); }
|
|
if (data.trainer !== undefined) { sets.push("trainer = ?"); vals.push(data.trainer); }
|
|
if (data.style !== undefined) { sets.push("style = ?"); vals.push(data.style); }
|
|
if (data.cancelled !== undefined) { sets.push("cancelled = ?"); vals.push(data.cancelled ? 1 : 0); }
|
|
if (data.sortOrder !== undefined) { sets.push("sort_order = ?"); vals.push(data.sortOrder); }
|
|
if (data.maxParticipants !== undefined) { sets.push("max_participants = ?"); vals.push(data.maxParticipants); }
|
|
if (sets.length === 0) return;
|
|
vals.push(id);
|
|
db.prepare(`UPDATE open_day_classes SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
|
}
|
|
|
|
export function deleteOpenDayClass(id: number): void {
|
|
const db = getDb();
|
|
db.prepare("DELETE FROM open_day_classes WHERE id = ?").run(id);
|
|
}
|
|
|
|
// --- Open Day Bookings ---
|
|
|
|
export function addOpenDayBooking(
|
|
classId: number,
|
|
eventId: number,
|
|
data: { name: string; phone: string; instagram?: string; telegram?: string; notes?: string }
|
|
): number {
|
|
const db = getDb();
|
|
const result = db
|
|
.prepare(
|
|
`INSERT INTO open_day_bookings (class_id, event_id, name, phone, instagram, telegram, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(classId, eventId, data.name, data.phone, data.instagram || null, data.telegram || null, data.notes || null);
|
|
return result.lastInsertRowid as number;
|
|
}
|
|
|
|
export function getOpenDayBookings(eventId: number): OpenDayBooking[] {
|
|
const db = getDb();
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT b.*, c.style as class_style, c.trainer as class_trainer, c.start_time as class_time, c.hall as class_hall
|
|
FROM open_day_bookings b
|
|
JOIN open_day_classes c ON c.id = b.class_id
|
|
WHERE b.event_id = ?
|
|
ORDER BY b.created_at DESC`
|
|
)
|
|
.all(eventId) as OpenDayBookingRow[];
|
|
return rows.map(mapBookingRow);
|
|
}
|
|
|
|
export function getOpenDayBookingCountsByClass(eventId: number): Record<number, number> {
|
|
const db = getDb();
|
|
const rows = db
|
|
.prepare("SELECT class_id, COUNT(*) as cnt FROM open_day_bookings WHERE event_id = ? GROUP BY class_id")
|
|
.all(eventId) as { class_id: number; cnt: number }[];
|
|
const result: Record<number, number> = {};
|
|
for (const r of rows) result[r.class_id] = r.cnt;
|
|
return result;
|
|
}
|
|
|
|
export function getPersonOpenDayBookings(eventId: number, phone: string): number {
|
|
const db = getDb();
|
|
const row = db
|
|
.prepare("SELECT COUNT(*) as cnt FROM open_day_bookings WHERE event_id = ? AND phone = ?")
|
|
.get(eventId, phone) as { cnt: number };
|
|
return row.cnt;
|
|
}
|
|
|
|
export function toggleOpenDayNotification(
|
|
id: number,
|
|
field: "notified_confirm" | "notified_reminder",
|
|
value: boolean
|
|
): void {
|
|
const db = getDb();
|
|
db.prepare(`UPDATE open_day_bookings SET ${field} = ? WHERE id = ?`).run(value ? 1 : 0, id);
|
|
}
|
|
|
|
export function deleteOpenDayBooking(id: number): void {
|
|
const db = getDb();
|
|
db.prepare("DELETE FROM open_day_bookings WHERE id = ?").run(id);
|
|
}
|
|
|
|
export function isOpenDayClassBookedByPhone(classId: number, phone: string): boolean {
|
|
const db = getDb();
|
|
const row = db
|
|
.prepare("SELECT id FROM open_day_bookings WHERE class_id = ? AND phone = ? LIMIT 1")
|
|
.get(classId, phone) as { id: number } | undefined;
|
|
return !!row;
|
|
}
|
|
|
|
export { SECTION_KEYS };
|