Compare commits
2 Commits
26cb9a9772
...
6cbdba2197
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cbdba2197 | |||
| 3ac6a4d840 |
109
CLAUDE.md
109
CLAUDE.md
@@ -7,8 +7,9 @@ Content language: Russian
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
- **Next.js 16** (App Router, TypeScript, Turbopack)
|
- **Next.js 16** (App Router, TypeScript, Turbopack)
|
||||||
- **Tailwind CSS v4** (light + dark mode, class-based toggle)
|
- **Tailwind CSS v4** (dark mode only, gold/black theme)
|
||||||
- **lucide-react** for icons
|
- **lucide-react** for icons
|
||||||
|
- **better-sqlite3** for SQLite database
|
||||||
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
|
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
|
||||||
- **Hosting**: Vercel (planned)
|
- **Hosting**: Vercel (planned)
|
||||||
|
|
||||||
@@ -16,66 +17,120 @@ Content language: Russian
|
|||||||
- Function declarations for components (not arrow functions)
|
- Function declarations for components (not arrow functions)
|
||||||
- PascalCase for component files, camelCase for utils
|
- PascalCase for component files, camelCase for utils
|
||||||
- `@/` path alias for imports
|
- `@/` path alias for imports
|
||||||
- Semantic CSS classes via `@apply`: `surface-base`, `surface-muted`, `heading-text`, `body-text`, `nav-link`, `card`, `contact-item`, `contact-icon`, `theme-border`
|
|
||||||
- Only Header + ThemeToggle are client components (minimal JS shipped)
|
|
||||||
- `next/image` with `unoptimized` for PNGs that need transparency preserved
|
- `next/image` with `unoptimized` for PNGs that need transparency preserved
|
||||||
|
- Header nav uses `lg:` breakpoint (1024px) for desktop/mobile switch (9 nav links + CTA need the space)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── layout.tsx # Root layout, fonts, metadata
|
│ ├── layout.tsx # Root layout, fonts, metadata
|
||||||
│ ├── page.tsx # Landing: Hero → Team → About → Classes → Contact
|
│ ├── page.tsx # Landing: Hero → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
|
||||||
│ ├── globals.css # Tailwind imports
|
│ ├── globals.css # Tailwind imports
|
||||||
│ ├── styles/
|
│ ├── styles/
|
||||||
│ │ ├── theme.css # Theme variables, semantic classes
|
│ │ ├── theme.css # Theme variables, semantic classes
|
||||||
│ │ └── animations.css # Keyframes, scroll reveal, modal animations
|
│ │ └── animations.css # Keyframes, scroll reveal, modal animations
|
||||||
│ ├── icon.png # Favicon
|
│ ├── admin/
|
||||||
│ └── apple-icon.png
|
│ │ ├── page.tsx # Dashboard with 11 section cards
|
||||||
|
│ │ ├── login/ # Password auth
|
||||||
|
│ │ ├── layout.tsx # Sidebar nav shell
|
||||||
|
│ │ ├── _components/ # SectionEditor, FormField, ArrayEditor
|
||||||
|
│ │ ├── meta/ # SEO editor
|
||||||
|
│ │ ├── hero/ # Hero editor
|
||||||
|
│ │ ├── about/ # About editor
|
||||||
|
│ │ ├── team/ # Team list + [id] editor
|
||||||
|
│ │ ├── classes/ # Classes editor with icon picker
|
||||||
|
│ │ ├── master-classes/ # MC editor with registrations
|
||||||
|
│ │ ├── schedule/ # Schedule editor
|
||||||
|
│ │ ├── pricing/ # Pricing editor
|
||||||
|
│ │ ├── faq/ # FAQ editor
|
||||||
|
│ │ ├── news/ # News editor
|
||||||
|
│ │ └── contact/ # Contact editor
|
||||||
|
│ └── api/
|
||||||
|
│ ├── auth/login/ # POST login
|
||||||
|
│ ├── logout/ # POST logout
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ ├── sections/[key]/ # GET/PUT section data
|
||||||
|
│ │ ├── team/ # CRUD team members
|
||||||
|
│ │ ├── team/[id]/ # GET/PUT/DELETE single member
|
||||||
|
│ │ ├── team/reorder/ # PUT reorder
|
||||||
|
│ │ ├── upload/ # POST file upload (whitelisted folders)
|
||||||
|
│ │ ├── mc-registrations/ # CRUD registrations
|
||||||
|
│ │ └── validate-instagram/ # GET check username
|
||||||
|
│ └── master-class-register/ # POST public signup
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── layout/
|
│ ├── layout/
|
||||||
│ │ ├── Header.tsx # Sticky nav, mobile menu, theme toggle ("use client")
|
│ │ ├── Header.tsx # Sticky nav, mobile menu, booking modal ("use client")
|
||||||
│ │ └── Footer.tsx
|
│ │ └── Footer.tsx
|
||||||
│ ├── sections/
|
│ ├── sections/
|
||||||
│ │ ├── Hero.tsx
|
│ │ ├── Hero.tsx # Hero with animated logo, floating hearts
|
||||||
│ │ ├── Team.tsx # "use client" — clickable cards + modal
|
│ │ ├── About.tsx # About with stats (trainers, classes, locations)
|
||||||
│ │ ├── About.tsx
|
│ │ ├── Team.tsx # Carousel + profile view
|
||||||
│ │ ├── Classes.tsx
|
│ │ ├── Classes.tsx # Showcase layout with icon selector
|
||||||
│ │ └── Contact.tsx
|
│ │ ├── MasterClasses.tsx # Cards with signup modal
|
||||||
|
│ │ ├── Schedule.tsx # Day/group views with filters
|
||||||
|
│ │ ├── Pricing.tsx # Tabs: prices, rental, rules
|
||||||
|
│ │ ├── News.tsx # Featured + compact articles
|
||||||
|
│ │ ├── FAQ.tsx # Accordion with show more
|
||||||
|
│ │ └── Contact.tsx # Info + Yandex Maps iframe
|
||||||
│ └── ui/
|
│ └── ui/
|
||||||
│ ├── Button.tsx
|
│ ├── Button.tsx
|
||||||
│ ├── SectionHeading.tsx
|
│ ├── SectionHeading.tsx
|
||||||
│ ├── SocialLinks.tsx
|
│ ├── BookingModal.tsx # Booking form → Instagram DM
|
||||||
│ ├── ThemeToggle.tsx
|
│ ├── MasterClassSignupModal.tsx # MC registration form → API
|
||||||
│ ├── Reveal.tsx # Intersection Observer scroll reveal
|
│ ├── NewsModal.tsx # News detail popup
|
||||||
│ └── TeamMemberModal.tsx # "use client" — member popup
|
│ ├── Reveal.tsx # Intersection Observer scroll reveal
|
||||||
|
│ ├── BackToTop.tsx
|
||||||
|
│ └── ...
|
||||||
├── data/
|
├── data/
|
||||||
│ └── content.ts # ALL Russian text, structured for future CMS
|
│ └── content.ts # Fallback Russian text (DB takes priority)
|
||||||
├── lib/
|
├── lib/
|
||||||
│ └── constants.ts # BRAND constants, NAV_LINKS
|
│ ├── constants.ts # BRAND constants, NAV_LINKS
|
||||||
|
│ ├── config.ts # UI_CONFIG (thresholds, counts)
|
||||||
|
│ ├── db.ts # SQLite DB, migrations, CRUD
|
||||||
|
│ ├── auth.ts # Token signing (Node.js)
|
||||||
|
│ ├── auth-edge.ts # Token verification (Edge/Web Crypto)
|
||||||
|
│ └── content.ts # getContent() — DB with fallback
|
||||||
|
├── proxy.ts # Middleware: auth guard for /admin/*
|
||||||
└── types/
|
└── types/
|
||||||
├── index.ts
|
├── index.ts
|
||||||
├── content.ts # SiteContent, TeamMember, ClassItem, ContactInfo
|
├── content.ts # SiteContent, TeamMember, ClassItem, MasterClassItem, etc.
|
||||||
└── navigation.ts
|
└── navigation.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Brand / Styling
|
## Brand / Styling
|
||||||
- **Accent**: rose/red (`#e11d48`)
|
- **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
|
||||||
- **Dark mode**: bg `#0a0a0a`, surface `#171717`
|
- **Background**: `#050505` – `#0a0a0a` (dark only)
|
||||||
- **Light mode**: bg `#fafafa`, surface `#ffffff`
|
- **Surface**: `#171717` dark cards
|
||||||
- Logo: transparent PNG, uses `dark:invert` + `unoptimized`
|
- Logo: transparent PNG heart with gold glow, uses `unoptimized`
|
||||||
|
|
||||||
## Content Data
|
## Content Data
|
||||||
- All text lives in `src/data/content.ts` (type-safe, one file to edit)
|
- Primary source: SQLite database (`db/blackheart.db`)
|
||||||
- 13 team members with photos, Instagram links, and personal descriptions
|
- Fallback: `src/data/content.ts` (auto-seeds DB on first access)
|
||||||
|
- Admin panel edits go to DB, site reads from DB via `getContent()`
|
||||||
|
- 12 team members with photos, Instagram links, bios, victories, education
|
||||||
- 6 class types (Exotic Pole Dance, Pole Dance, Body Plastic, etc.)
|
- 6 class types (Exotic Pole Dance, Pole Dance, Body Plastic, etc.)
|
||||||
|
- Master classes with date/time slots and public registration
|
||||||
- 2 addresses in Minsk, Yandex Maps embed with markers
|
- 2 addresses in Minsk, Yandex Maps embed with markers
|
||||||
- Contact: phone, Instagram
|
- Contact: phone, Instagram (no email)
|
||||||
|
|
||||||
|
## Admin Panel
|
||||||
|
- Password-based auth with HMAC-SHA256 signed JWT (24h TTL)
|
||||||
|
- Cookie: `bh-admin-token` (httpOnly, secure in prod)
|
||||||
|
- Auto-save with 800ms debounce on all section editors
|
||||||
|
- Team members: drag-reorder, photo upload, rich bio (experience, victories, education)
|
||||||
|
- Master classes: slots, registration viewer, trainer/style autocomplete from existing data
|
||||||
|
- File upload: whitelisted folders (`team`, `master-classes`, `news`, `classes`), max 5MB, image types only
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
- **CSRF protection**: Double-submit cookie pattern. Login sets `bh-csrf-token` cookie (JS-readable). All admin fetch calls use `adminFetch()` from `src/lib/csrf.ts` which sends the token as `X-CSRF-Token` header. Middleware (`proxy.ts`) validates header matches cookie on POST/PUT/DELETE to `/api/admin/*`. **Always use `adminFetch()` instead of `fetch()` for admin API calls.**
|
||||||
|
- File upload validates: MIME type, file extension, whitelisted folder (no path traversal)
|
||||||
|
- API routes validate: input types, string lengths, numeric IDs
|
||||||
|
- Public MC registration: length-limited but **no rate limiting yet** (add before production)
|
||||||
|
|
||||||
## AST Index
|
## AST Index
|
||||||
- **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles
|
- **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles
|
||||||
- Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes
|
- Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes
|
||||||
- Covers all 31 TS/TSX files + 4 CSS files
|
|
||||||
- Update the index when adding/removing/renaming files or exports
|
- Update the index when adding/removing/renaming files or exports
|
||||||
|
|
||||||
## Database Migrations
|
## Database Migrations
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
|
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||||
|
|
||||||
interface InputFieldProps {
|
interface InputFieldProps {
|
||||||
@@ -379,7 +380,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("folder", "team");
|
formData.append("folder", "team");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/upload", { method: "POST", body: formData });
|
const res = await adminFetch("/api/admin/upload", { method: "POST", body: formData });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.path) {
|
if (result.path) {
|
||||||
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Loader2, Check, AlertCircle } from "lucide-react";
|
import { Loader2, Check, AlertCircle } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
|
||||||
interface SectionEditorProps<T> {
|
interface SectionEditorProps<T> {
|
||||||
sectionKey: string;
|
sectionKey: string;
|
||||||
@@ -24,7 +25,7 @@ export function SectionEditor<T>({
|
|||||||
const initialLoadRef = useRef(true);
|
const initialLoadRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/admin/sections/${sectionKey}`)
|
adminFetch(`/api/admin/sections/${sectionKey}`)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!r.ok) throw new Error("Failed to load");
|
if (!r.ok) throw new Error("Failed to load");
|
||||||
return r.json();
|
return r.json();
|
||||||
@@ -39,7 +40,7 @@ export function SectionEditor<T>({
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/sections/${sectionKey}`, {
|
const res = await adminFetch(`/api/admin/sections/${sectionKey}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(dataToSave),
|
body: JSON.stringify(dataToSave),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SectionEditor } from "../_components/SectionEditor";
|
|||||||
import { InputField, TextareaField } from "../_components/FormField";
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check, ChevronDown, ChevronUp, Instagram, Send, Trash2, Pencil } from "lucide-react";
|
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check, ChevronDown, ChevronUp, Instagram, Send, Trash2, Pencil } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
||||||
|
|
||||||
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||||
@@ -335,7 +336,7 @@ function ImageUploadField({
|
|||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("folder", "master-classes");
|
formData.append("folder", "master-classes");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/upload", {
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -506,7 +507,7 @@ function RegistrationRow({
|
|||||||
instagram: `@${ig.trim()}`,
|
instagram: `@${ig.trim()}`,
|
||||||
telegram: tg.trim() ? `@${tg.trim()}` : undefined,
|
telegram: tg.trim() ? `@${tg.trim()}` : undefined,
|
||||||
};
|
};
|
||||||
const res = await fetch("/api/admin/mc-registrations", {
|
const res = await adminFetch("/api/admin/mc-registrations", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -647,7 +648,7 @@ function RegistrationsList({ title }: { title: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
fetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
|
adminFetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: McRegistration[]) => {
|
.then((data: McRegistration[]) => {
|
||||||
setCount(data.length);
|
setCount(data.length);
|
||||||
@@ -659,7 +660,7 @@ function RegistrationsList({ title }: { title: string }) {
|
|||||||
function toggle() {
|
function toggle() {
|
||||||
if (!open && regs.length === 0 && count !== 0) {
|
if (!open && regs.length === 0 && count !== 0) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
|
adminFetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: McRegistration[]) => {
|
.then((data: McRegistration[]) => {
|
||||||
setRegs(data);
|
setRegs(data);
|
||||||
@@ -680,7 +681,7 @@ function RegistrationsList({ title }: { title: string }) {
|
|||||||
instagram: `@${newIg.trim()}`,
|
instagram: `@${newIg.trim()}`,
|
||||||
telegram: newTg.trim() ? `@${newTg.trim()}` : undefined,
|
telegram: newTg.trim() ? `@${newTg.trim()}` : undefined,
|
||||||
};
|
};
|
||||||
const res = await fetch("/api/admin/mc-registrations", {
|
const res = await adminFetch("/api/admin/mc-registrations", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -705,7 +706,7 @@ function RegistrationsList({ title }: { title: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
async function handleDelete(id: number) {
|
||||||
await fetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
|
await adminFetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
|
||||||
setRegs((prev) => prev.filter((r) => r.id !== id));
|
setRegs((prev) => prev.filter((r) => r.id !== id));
|
||||||
setCount((prev) => (prev !== null ? prev - 1 : null));
|
setCount((prev) => (prev !== null ? prev - 1 : null));
|
||||||
}
|
}
|
||||||
@@ -823,7 +824,7 @@ export default function MasterClassesEditorPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch trainers from team
|
// Fetch trainers from team
|
||||||
fetch("/api/admin/team")
|
adminFetch("/api/admin/team")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((members: { name: string }[]) => {
|
.then((members: { name: string }[]) => {
|
||||||
setTrainers(members.map((m) => m.name));
|
setTrainers(members.map((m) => m.name));
|
||||||
@@ -831,7 +832,7 @@ export default function MasterClassesEditorPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
// Fetch styles from classes section
|
// Fetch styles from classes section
|
||||||
fetch("/api/admin/sections/classes")
|
adminFetch("/api/admin/sections/classes")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { items: { name: string }[] }) => {
|
.then((data: { items: { name: string }[] }) => {
|
||||||
setStyles(data.items.map((c) => c.name));
|
setStyles(data.items.map((c) => c.name));
|
||||||
@@ -839,7 +840,7 @@ export default function MasterClassesEditorPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
// Fetch locations from schedule section
|
// Fetch locations from schedule section
|
||||||
fetch("/api/admin/sections/schedule")
|
adminFetch("/api/admin/sections/schedule")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { locations: { name: string; address: string }[] }) => {
|
.then((data: { locations: { name: string; address: string }[] }) => {
|
||||||
setLocations(data.locations);
|
setLocations(data.locations);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SectionEditor } from "../_components/SectionEditor";
|
|||||||
import { InputField, TextareaField } from "../_components/FormField";
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
import { Upload, Loader2, ImageIcon, X } from "lucide-react";
|
import { Upload, Loader2, ImageIcon, X } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { NewsItem } from "@/types/content";
|
import type { NewsItem } from "@/types/content";
|
||||||
|
|
||||||
interface NewsData {
|
interface NewsData {
|
||||||
@@ -30,7 +31,7 @@ function ImageUploadField({
|
|||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("folder", "news");
|
formData.append("folder", "news");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/upload", {
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -122,12 +123,15 @@ export default function NewsEditorPage() {
|
|||||||
value={item.title}
|
value={item.title}
|
||||||
onChange={(v) => updateItem({ ...item, title: v })}
|
onChange={(v) => updateItem({ ...item, title: v })}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<div>
|
||||||
label="Дата"
|
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
|
||||||
value={item.date}
|
<input
|
||||||
onChange={(v) => updateItem({ ...item, date: v })}
|
type="date"
|
||||||
placeholder="2026-03-15"
|
value={item.date}
|
||||||
/>
|
onChange={(e) => updateItem({ ...item, date: e.target.value })}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TextareaField
|
<TextareaField
|
||||||
label="Текст"
|
label="Текст"
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Users,
|
Users,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Star,
|
||||||
Calendar,
|
Calendar,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
Newspaper,
|
||||||
Phone,
|
Phone,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -17,9 +19,11 @@ const CARDS = [
|
|||||||
{ href: "/admin/about", label: "О студии", icon: FileText, desc: "Текст о студии" },
|
{ href: "/admin/about", label: "О студии", icon: FileText, desc: "Текст о студии" },
|
||||||
{ href: "/admin/team", label: "Команда", icon: Users, desc: "Тренеры и инструкторы" },
|
{ href: "/admin/team", label: "Команда", icon: Users, desc: "Тренеры и инструкторы" },
|
||||||
{ href: "/admin/classes", label: "Направления", icon: BookOpen, desc: "Типы занятий" },
|
{ href: "/admin/classes", label: "Направления", icon: BookOpen, desc: "Типы занятий" },
|
||||||
|
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star, desc: "Мастер-классы и записи" },
|
||||||
{ href: "/admin/schedule", label: "Расписание", icon: Calendar, desc: "Расписание занятий" },
|
{ href: "/admin/schedule", label: "Расписание", icon: Calendar, desc: "Расписание занятий" },
|
||||||
{ href: "/admin/pricing", label: "Цены", icon: DollarSign, desc: "Абонементы и аренда" },
|
{ href: "/admin/pricing", label: "Цены", icon: DollarSign, desc: "Абонементы и аренда" },
|
||||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, desc: "Часто задаваемые вопросы" },
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, desc: "Часто задаваемые вопросы" },
|
||||||
|
{ href: "/admin/news", label: "Новости", icon: Newspaper, desc: "Новости и анонсы" },
|
||||||
{ href: "/admin/contact", label: "Контакты", icon: Phone, desc: "Адреса, телефон, карта" },
|
{ href: "/admin/contact", label: "Контакты", icon: Phone, desc: "Адреса, телефон, карта" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
|
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
|
||||||
import { Plus, X, Trash2 } from "lucide-react";
|
import { Plus, X, Trash2 } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
|
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
|
||||||
|
|
||||||
interface ScheduleData {
|
interface ScheduleData {
|
||||||
@@ -1113,21 +1114,21 @@ export default function ScheduleEditorPage() {
|
|||||||
const [classTypes, setClassTypes] = useState<string[]>([]);
|
const [classTypes, setClassTypes] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/team")
|
adminFetch("/api/admin/team")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((members: { name: string }[]) => {
|
.then((members: { name: string }[]) => {
|
||||||
setTrainers(members.map((m) => m.name));
|
setTrainers(members.map((m) => m.name));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
fetch("/api/admin/sections/contact")
|
adminFetch("/api/admin/sections/contact")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((contact: { addresses?: string[] }) => {
|
.then((contact: { addresses?: string[] }) => {
|
||||||
setAddresses(contact.addresses ?? []);
|
setAddresses(contact.addresses ?? []);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
fetch("/api/admin/sections/classes")
|
adminFetch("/api/admin/sections/classes")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((classes: { items?: { name: string }[] }) => {
|
.then((classes: { items?: { name: string }[] }) => {
|
||||||
setClassTypes((classes.items ?? []).map((c) => c.name));
|
setClassTypes((classes.items ?? []).map((c) => c.name));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter, useParams } from "next/navigation";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
|
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
|
||||||
import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField";
|
import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||||
|
|
||||||
function extractUsername(value: string): string {
|
function extractUsername(value: string): string {
|
||||||
@@ -55,7 +56,7 @@ export default function TeamMemberEditorPage() {
|
|||||||
setIgStatus("checking");
|
setIgStatus("checking");
|
||||||
igTimerRef.current = setTimeout(async () => {
|
igTimerRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
|
const res = await adminFetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
setIgStatus(result.valid ? "valid" : "invalid");
|
setIgStatus(result.valid ? "valid" : "invalid");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -106,7 +107,7 @@ export default function TeamMemberEditorPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNew) return;
|
if (isNew) return;
|
||||||
fetch(`/api/admin/team/${id}`)
|
adminFetch(`/api/admin/team/${id}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((member) => {
|
.then((member) => {
|
||||||
const username = extractUsername(member.instagram || "");
|
const username = extractUsername(member.instagram || "");
|
||||||
@@ -139,7 +140,7 @@ export default function TeamMemberEditorPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const res = await fetch("/api/admin/team", {
|
const res = await adminFetch("/api/admin/team", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -148,7 +149,7 @@ export default function TeamMemberEditorPage() {
|
|||||||
router.push("/admin/team");
|
router.push("/admin/team");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch(`/api/admin/team/${id}`, {
|
const res = await adminFetch(`/api/admin/team/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -171,7 +172,7 @@ export default function TeamMemberEditorPage() {
|
|||||||
formData.append("folder", "team");
|
formData.append("folder", "team");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/upload", {
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
GripVertical,
|
GripVertical,
|
||||||
Check,
|
Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { TeamMember } from "@/types/content";
|
import type { TeamMember } from "@/types/content";
|
||||||
|
|
||||||
type Member = TeamMember & { id: number };
|
type Member = TeamMember & { id: number };
|
||||||
@@ -29,7 +30,7 @@ export default function TeamEditorPage() {
|
|||||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/team")
|
adminFetch("/api/admin/team")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(setMembers)
|
.then(setMembers)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -38,7 +39,7 @@ export default function TeamEditorPage() {
|
|||||||
const saveOrder = useCallback(async (updated: Member[]) => {
|
const saveOrder = useCallback(async (updated: Member[]) => {
|
||||||
setMembers(updated);
|
setMembers(updated);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await fetch("/api/admin/team/reorder", {
|
await adminFetch("/api/admin/team/reorder", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
||||||
@@ -159,7 +160,7 @@ export default function TeamEditorPage() {
|
|||||||
|
|
||||||
async function deleteMember(id: number) {
|
async function deleteMember(id: number) {
|
||||||
if (!confirm("Удалить этого участника?")) return;
|
if (!confirm("Удалить этого участника?")) return;
|
||||||
await fetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
||||||
setMembers((prev) => prev.filter((m) => m.id !== id));
|
setMembers((prev) => prev.filter((m) => m.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ import { revalidatePath } from "next/cache";
|
|||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
function parseId(raw: string): number | null {
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const member = getTeamMember(Number(id));
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const member = getTeamMember(numId);
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -15,15 +24,23 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
export async function PUT(request: NextRequest, { params }: Params) {
|
export async function PUT(request: NextRequest, { params }: Params) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
updateTeamMember(Number(id), data);
|
updateTeamMember(numId, data);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
deleteTeamMember(Number(id));
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
deleteTeamMember(numId);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { revalidatePath } from "next/cache";
|
|||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
const { ids } = await request.json() as { ids: number[] };
|
const { ids } = await request.json() as { ids: number[] };
|
||||||
|
|
||||||
if (!Array.isArray(ids) || ids.length === 0) {
|
if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => Number.isInteger(id) && id > 0)) {
|
||||||
return NextResponse.json({ error: "ids array required" }, { status: 400 });
|
return NextResponse.json({ error: "ids must be a non-empty array of positive integers" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderTeamMembers(ids);
|
reorderTeamMembers(ids);
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { writeFile, mkdir } from "fs/promises";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
|
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
|
||||||
|
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
|
||||||
|
const ALLOWED_FOLDERS = ["team", "master-classes", "news", "classes"];
|
||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
const folder = (formData.get("folder") as string) || "team";
|
const rawFolder = (formData.get("folder") as string) || "team";
|
||||||
|
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : "team";
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
@@ -28,8 +31,14 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize filename
|
// Validate and sanitize filename
|
||||||
const ext = path.extname(file.name) || ".webp";
|
const ext = path.extname(file.name).toLowerCase() || ".webp";
|
||||||
|
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid file extension" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
const baseName = file.name
|
const baseName = file.name
|
||||||
.replace(ext, "")
|
.replace(ext, "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { verifyPassword, signToken, COOKIE_NAME } from "@/lib/auth";
|
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json() as { password?: string };
|
const body = await request.json() as { password?: string };
|
||||||
@@ -9,6 +9,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = signToken();
|
const token = signToken();
|
||||||
|
const csrfToken = generateCsrfToken();
|
||||||
const response = NextResponse.json({ ok: true });
|
const response = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
response.cookies.set(COOKIE_NAME, token, {
|
response.cookies.set(COOKIE_NAME, token, {
|
||||||
@@ -16,7 +17,15 @@ export async function POST(request: NextRequest) {
|
|||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
maxAge: 60 * 60 * 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
||||||
|
httpOnly: false, // JS must read this to send as header
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -6,21 +6,24 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { masterClassTitle, name, instagram, telegram } = body;
|
const { masterClassTitle, name, instagram, telegram } = body;
|
||||||
|
|
||||||
if (!masterClassTitle || typeof masterClassTitle !== "string") {
|
if (!masterClassTitle || typeof masterClassTitle !== "string" || masterClassTitle.length > 200) {
|
||||||
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 });
|
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!name || typeof name !== "string" || !name.trim()) {
|
if (!name || typeof name !== "string" || !name.trim() || name.length > 100) {
|
||||||
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
return NextResponse.json({ error: "name is required (max 100 chars)" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!instagram || typeof instagram !== "string" || !instagram.trim()) {
|
if (!instagram || typeof instagram !== "string" || !instagram.trim() || instagram.length > 100) {
|
||||||
return NextResponse.json({ error: "Instagram аккаунт обязателен" }, { status: 400 });
|
return NextResponse.json({ error: "Instagram аккаунт обязателен" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (telegram && (typeof telegram !== "string" || telegram.length > 100)) {
|
||||||
|
return NextResponse.json({ error: "Telegram too long" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const id = addMcRegistration(
|
const id = addMcRegistration(
|
||||||
masterClassTitle.trim(),
|
masterClassTitle.trim().slice(0, 200),
|
||||||
name.trim(),
|
name.trim().slice(0, 100),
|
||||||
instagram.trim(),
|
instagram.trim().slice(0, 100),
|
||||||
telegram && typeof telegram === "string" ? telegram.trim() : undefined
|
telegram && typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, id });
|
return NextResponse.json({ ok: true, id });
|
||||||
|
|||||||
@@ -100,14 +100,14 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-8 md:flex">
|
<nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex">
|
||||||
{visibleLinks.map((link) => {
|
{visibleLinks.map((link) => {
|
||||||
const isActive = activeSection === link.href.replace("#", "");
|
const isActive = activeSection === link.href.replace("#", "");
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className={`relative py-1 text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
|
className={`relative whitespace-nowrap py-1 text-xs lg:text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
|
||||||
isActive
|
isActive
|
||||||
? "text-gold-light after:w-full"
|
? "text-gold-light after:w-full"
|
||||||
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
|
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
|
||||||
@@ -125,7 +125,7 @@ export function Header() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 md:hidden">
|
<div className="flex items-center gap-2 lg:hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
aria-label="Меню"
|
aria-label="Меню"
|
||||||
@@ -138,7 +138,7 @@ export function Header() {
|
|||||||
|
|
||||||
{/* Mobile menu */}
|
{/* Mobile menu */}
|
||||||
<div
|
<div
|
||||||
className={`overflow-hidden transition-all duration-300 md:hidden ${
|
className={`overflow-hidden transition-all duration-300 lg:hidden ${
|
||||||
menuOpen ? "max-h-80 opacity-100" : "max-h-0 opacity-0"
|
menuOpen ? "max-h-80 opacity-100" : "max-h-0 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -175,7 +175,7 @@ export function Header() {
|
|||||||
{/* Floating booking button — visible on scroll, mobile */}
|
{/* Floating booking button — visible on scroll, mobile */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setBookingOpen(true)}
|
onClick={() => setBookingOpen(true)}
|
||||||
className={`fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-gold px-5 py-3 text-sm font-semibold text-black shadow-lg shadow-gold/25 transition-all duration-500 hover:bg-gold-light hover:shadow-xl hover:shadow-gold/30 cursor-pointer md:hidden ${
|
className={`fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-gold px-5 py-3 text-sm font-semibold text-black shadow-lg shadow-gold/25 transition-all duration-500 hover:bg-gold-light hover:shadow-xl hover:shadow-gold/30 cursor-pointer lg:hidden ${
|
||||||
scrolled ? "translate-y-0 opacity-100" : "translate-y-16 opacity-0 pointer-events-none"
|
scrolled ? "translate-y-0 opacity-100" : "translate-y-16 opacity-0 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,16 +3,22 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { X, Instagram, Send, CheckCircle, Phone } from "lucide-react";
|
import { X, Instagram, Send, CheckCircle, Phone } from "lucide-react";
|
||||||
import { siteContent } from "@/data/content";
|
import { BRAND } from "@/lib/constants";
|
||||||
|
|
||||||
interface BookingModalProps {
|
interface BookingModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
groupInfo?: string;
|
groupInfo?: string;
|
||||||
|
contact?: { instagram: string; phone: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BookingModal({ open, onClose, groupInfo }: BookingModalProps) {
|
const DEFAULT_CONTACT = {
|
||||||
const { contact } = siteContent;
|
instagram: BRAND.instagram,
|
||||||
|
phone: "+375 29 389-70-01",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BookingModal({ open, onClose, groupInfo, contact: contactProp }: BookingModalProps) {
|
||||||
|
const contact = contactProp ?? DEFAULT_CONTACT;
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [phone, setPhone] = useState("+375 ");
|
const [phone, setPhone] = useState("+375 ");
|
||||||
|
|
||||||
@@ -72,7 +78,7 @@ export function BookingModal({ open, onClose, groupInfo }: BookingModalProps) {
|
|||||||
window.open(instagramUrl, "_blank");
|
window.open(instagramUrl, "_blank");
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
},
|
},
|
||||||
[name, phone]
|
[name, phone, groupInfo, contact]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
|
|||||||
@@ -63,4 +63,10 @@ function verifyTokenNode(token: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CSRF_COOKIE_NAME = "bh-csrf-token";
|
||||||
|
|
||||||
|
export function generateCsrfToken(): string {
|
||||||
|
return crypto.randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
export { COOKIE_NAME };
|
export { COOKIE_NAME };
|
||||||
|
|||||||
17
src/lib/csrf.ts
Normal file
17
src/lib/csrf.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const CSRF_COOKIE_NAME = "bh-csrf-token";
|
||||||
|
|
||||||
|
function getCsrfToken(): string {
|
||||||
|
const match = document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((c) => c.startsWith(`${CSRF_COOKIE_NAME}=`));
|
||||||
|
return match ? match.split("=")[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrapper around fetch that auto-includes the CSRF token header for admin API calls */
|
||||||
|
export function adminFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
if (!headers.has("x-csrf-token")) {
|
||||||
|
headers.set("x-csrf-token", getCsrfToken());
|
||||||
|
}
|
||||||
|
return fetch(url, { ...init, headers });
|
||||||
|
}
|
||||||
14
src/proxy.ts
14
src/proxy.ts
@@ -1,6 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { verifyToken, COOKIE_NAME } from "@/lib/auth-edge";
|
import { verifyToken, COOKIE_NAME } from "@/lib/auth-edge";
|
||||||
|
|
||||||
|
const CSRF_COOKIE_NAME = "bh-csrf-token";
|
||||||
|
const CSRF_HEADER_NAME = "x-csrf-token";
|
||||||
|
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
@@ -20,6 +24,16 @@ export async function proxy(request: NextRequest) {
|
|||||||
return NextResponse.redirect(new URL("/admin/login", request.url));
|
return NextResponse.redirect(new URL("/admin/login", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF check on state-changing API requests
|
||||||
|
if (pathname.startsWith("/api/admin/") && STATE_CHANGING_METHODS.has(request.method)) {
|
||||||
|
const csrfCookie = request.cookies.get(CSRF_COOKIE_NAME)?.value;
|
||||||
|
const csrfHeader = request.headers.get(CSRF_HEADER_NAME);
|
||||||
|
|
||||||
|
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
|
||||||
|
return NextResponse.json({ error: "CSRF token mismatch" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user