Files
blackheart-website/src/lib/db.ts
diana.dolgolyova 84b0bc4d60 feat: add master classes section with registration system
- 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>
2026-03-15 18:29:06 +03:00

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 };