- New master classes section on landing page with upcoming events grid - Admin CRUD for master classes (image, slots, trainer, style, cost, location) - User signup modal (name + Instagram required, Telegram optional) - Admin registration management: view, add, edit, delete with quick-contact links - Customizable success message for signup confirmation - Auto-filter past events, Russian date formatting, duration auto-calculation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
import Database from "better-sqlite3";
|
|
import path from "path";
|
|
import type { SiteContent, TeamMember, RichListItem, VictoryItem } from "@/types/content";
|
|
|
|
const DB_PATH =
|
|
process.env.DATABASE_PATH ||
|
|
path.join(process.cwd(), "db", "blackheart.db");
|
|
|
|
let _db: Database.Database | null = null;
|
|
|
|
function getDb(): Database.Database {
|
|
if (!_db) {
|
|
_db = new Database(DB_PATH);
|
|
_db.pragma("journal_mode = WAL");
|
|
_db.pragma("foreign_keys = ON");
|
|
initTables(_db);
|
|
}
|
|
return _db;
|
|
}
|
|
|
|
function initTables(db: Database.Database) {
|
|
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 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'))
|
|
);
|
|
|
|
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,
|
|
experience TEXT,
|
|
victories TEXT,
|
|
education TEXT,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
`);
|
|
|
|
// Migrate: add bio columns if missing
|
|
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");
|
|
}
|
|
|
|
// --- 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;
|
|
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 parseVictories(val: string | null): VictoryItem[] | undefined {
|
|
if (!val) return undefined;
|
|
try {
|
|
const arr = JSON.parse(val);
|
|
if (!Array.isArray(arr) || arr.length === 0) return undefined;
|
|
// Handle old string[], old RichListItem[], and new VictoryItem[] formats
|
|
return arr.map((item: string | Record<string, unknown>) => {
|
|
if (typeof item === "string") return { place: "", category: "", competition: item };
|
|
if ("text" in item && !("competition" in item)) return { place: "", category: "", competition: item.text as string, image: item.image as string | undefined, link: item.link as string | undefined };
|
|
return item as unknown as VictoryItem;
|
|
});
|
|
} 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,
|
|
description: r.description ?? undefined,
|
|
experience: parseJsonArray(r.experience),
|
|
victories: parseVictories(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,
|
|
description: r.description ?? undefined,
|
|
experience: parseJsonArray(r.experience),
|
|
victories: parseVictories(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, description, experience, victories, education, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
data.name,
|
|
data.role,
|
|
data.image,
|
|
data.instagram ?? null,
|
|
data.description ?? null,
|
|
data.experience?.length ? JSON.stringify(data.experience) : 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.description !== undefined) { fields.push("description = ?"); values.push(data.description || null); }
|
|
if (data.experience !== undefined) { fields.push("experience = ?"); values.push(data.experience?.length ? JSON.stringify(data.experience) : 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",
|
|
"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,
|
|
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;
|
|
telegram: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface McRegistration {
|
|
id: number;
|
|
masterClassTitle: string;
|
|
name: string;
|
|
instagram: string;
|
|
telegram?: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export function addMcRegistration(
|
|
masterClassTitle: string,
|
|
name: string,
|
|
instagram: string,
|
|
telegram?: string
|
|
): number {
|
|
const db = getDb();
|
|
const result = db
|
|
.prepare(
|
|
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram)
|
|
VALUES (?, ?, ?, ?)`
|
|
)
|
|
.run(masterClassTitle, name, instagram, telegram || 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((r) => ({
|
|
id: r.id,
|
|
masterClassTitle: r.master_class_title,
|
|
name: r.name,
|
|
instagram: r.instagram,
|
|
telegram: r.telegram ?? undefined,
|
|
createdAt: r.created_at,
|
|
}));
|
|
}
|
|
|
|
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 deleteMcRegistration(id: number): void {
|
|
const db = getDb();
|
|
db.prepare("DELETE FROM mc_registrations WHERE id = ?").run(id);
|
|
}
|
|
|
|
export { SECTION_KEYS };
|