Compare commits
14 Commits
97663c514e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1571b63ec3 | |||
| 8c84da279e | |||
| 03d3cad0a7 | |||
| 89f132634d | |||
| b7eacce479 | |||
| a832af9344 | |||
| b738976111 | |||
| a00fdaa760 | |||
| 9f86bcbce9 | |||
| 28afcc18bc | |||
| b9510213d7 | |||
| bac46aeb34 | |||
| 3621503470 | |||
| a080ef5a8e |
@@ -52,13 +52,13 @@ export function ConfirmDialog({
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-neutral-900 p-6 shadow-2xl"
|
||||
className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
aria-label="Закрыть"
|
||||
className="absolute right-3 top-3 rounded-full p-1 text-neutral-500 hover:text-white transition-colors"
|
||||
className="absolute right-3 top-3 rounded-full p-1 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -70,8 +70,8 @@ export function ConfirmDialog({
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">{title}</h3>
|
||||
<p className="mt-1.5 text-sm text-neutral-400">{message}</p>
|
||||
<h3 className="text-base font-bold text-neutral-900 dark:text-white">{title}</h3>
|
||||
<p className="mt-1.5 text-sm text-neutral-600 dark:text-neutral-400">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ConfirmDialog({
|
||||
<button
|
||||
ref={cancelRef}
|
||||
onClick={onCancel}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-neutral-300 hover:bg-white/[0.06] transition-colors cursor-pointer"
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 transition-colors cursor-pointer dark:text-neutral-300 dark:hover:bg-white/[0.06]"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
|
||||
@@ -12,10 +12,10 @@ interface InputFieldProps {
|
||||
type?: "text" | "url" | "tel";
|
||||
}
|
||||
|
||||
const baseInput = "w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none hover:border-gold/30 focus:border-gold transition-colors";
|
||||
const baseInput = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500";
|
||||
const textAreaInput = `${baseInput} resize-none overflow-hidden`;
|
||||
const smallInput = "rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none hover:border-gold/30 focus:border-gold transition-colors";
|
||||
const dashedInput = "flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors";
|
||||
const smallInput = "rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600";
|
||||
const dashedInput = "flex-1 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-4 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors dark:border-white/10 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-600";
|
||||
const inputCls = baseInput;
|
||||
|
||||
export function InputField({
|
||||
@@ -311,7 +311,7 @@ export function RichTextarea({
|
||||
`rounded p-1.5 transition-colors ${
|
||||
active
|
||||
? "text-gold bg-gold/15"
|
||||
: "text-neutral-500 hover:text-white hover:bg-white/10"
|
||||
: "text-neutral-500 hover:text-neutral-900 hover:bg-neutral-200 dark:hover:text-white dark:hover:bg-white/10"
|
||||
}`;
|
||||
|
||||
// Preview mode: show rendered markup
|
||||
@@ -324,9 +324,9 @@ export function RichTextarea({
|
||||
setEditing(true);
|
||||
requestAnimationFrame(() => ref.current?.focus());
|
||||
}}
|
||||
className="group rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 cursor-text hover:border-gold/30 transition-colors relative"
|
||||
className="group rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 cursor-text hover:border-gold/30 transition-colors relative dark:border-white/10 dark:bg-neutral-800"
|
||||
>
|
||||
<div className="text-sm leading-relaxed text-neutral-300">
|
||||
<div className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
|
||||
{formatMarkup(value)}
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
@@ -343,9 +343,9 @@ export function RichTextarea({
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div className="rounded-lg border border-white/10 bg-neutral-800 overflow-hidden hover:border-gold/30 focus-within:border-gold transition-colors">
|
||||
<div className="rounded-lg border border-neutral-200 bg-neutral-100 overflow-hidden hover:border-gold/30 focus-within:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-0.5 px-2 py-1 border-b border-white/5">
|
||||
<div className="flex items-center gap-0.5 px-2 py-1 border-b border-neutral-200 dark:border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
@@ -396,7 +396,7 @@ export function RichTextarea({
|
||||
onBlur={() => setEditing(false)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none resize-none"
|
||||
className="w-full bg-transparent px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none resize-none dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,8 +480,8 @@ export function SelectField({
|
||||
{label}
|
||||
{hint && (
|
||||
<span className="group relative">
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-white/15 text-[10px] text-neutral-500 hover:text-white hover:border-white/30 transition-colors cursor-help">?</span>
|
||||
<span className="absolute left-6 top-1/2 -translate-y-1/2 z-50 w-52 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity">
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-neutral-300 text-[10px] text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors cursor-help dark:border-white/15 dark:hover:text-white dark:hover:border-white/30">?</span>
|
||||
<span className="absolute left-6 top-1/2 -translate-y-1/2 z-50 w-52 rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[11px] leading-relaxed text-neutral-700 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
{hint}
|
||||
</span>
|
||||
</span>
|
||||
@@ -500,9 +500,9 @@ export function SelectField({
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
placeholder={placeholder || "Выберите..."}
|
||||
className={`w-full rounded-lg border bg-neutral-800 outline-none transition-colors ${
|
||||
className={`w-full rounded-lg border bg-neutral-100 text-neutral-900 outline-none transition-colors dark:bg-neutral-800 dark:text-white ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-white/10"} ${!open && value ? "text-white" : "text-white"} placeholder-neutral-500`}
|
||||
} ${open ? "border-gold" : "border-neutral-200 dark:border-white/10"} placeholder-neutral-400 dark:placeholder-neutral-500`}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
@@ -511,16 +511,16 @@ export function SelectField({
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
|
||||
className={`w-full rounded-lg border bg-neutral-100 text-left outline-none transition-colors dark:bg-neutral-800 ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
|
||||
} ${open ? "border-gold" : "border-neutral-200 dark:border-white/10"} ${value ? "text-neutral-900 dark:text-white" : "text-neutral-500"}`}
|
||||
>
|
||||
{selectedLabel || placeholder || "Выберите..."}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div role="listbox" className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
<div role="listbox" className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/10 dark:bg-neutral-800">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
|
||||
@@ -541,8 +541,8 @@ export function SelectField({
|
||||
inputRef.current?.blur();
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
|
||||
idx === highlightIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
} ${opt.value === value ? "text-gold bg-gold/5" : "text-white"}`}
|
||||
idx === highlightIndex ? "bg-neutral-100 dark:bg-white/10" : "hover:bg-neutral-50 dark:hover:bg-white/5"
|
||||
} ${opt.value === value ? "text-gold bg-gold/5" : "text-neutral-900 dark:text-white"}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
@@ -598,7 +598,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
|
||||
value={start}
|
||||
onChange={(e) => handleStartChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]"
|
||||
/>
|
||||
<span className="text-neutral-500">–</span>
|
||||
<input
|
||||
@@ -606,7 +606,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
|
||||
value={end}
|
||||
onChange={(e) => handleEndChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -677,7 +677,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => update(i, e.target.value)}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -778,13 +778,13 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5 transition-colors hover:border-gold/30 hover:bg-neutral-800/80 focus-within:border-gold/50 focus-within:bg-neutral-800">
|
||||
<div key={i} className="rounded-lg border border-neutral-200 bg-neutral-100/80 p-2.5 space-y-1.5 transition-colors hover:border-gold/30 hover:bg-neutral-200/80 focus-within:border-gold/50 focus-within:bg-neutral-200 dark:border-white/10 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/80 dark:focus-within:bg-neutral-800">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
onChange={(e) => updateText(i, e.target.value)}
|
||||
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -796,7 +796,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.image ? (
|
||||
<div className="flex items-center gap-1 rounded bg-neutral-700/50 px-1.5 py-0.5 text-[11px] text-neutral-300">
|
||||
<div className="flex items-center gap-1 rounded bg-neutral-200 px-1.5 py-0.5 text-[11px] text-neutral-700 dark:bg-neutral-700/50 dark:text-neutral-300">
|
||||
<ImageIcon size={10} className="text-gold" />
|
||||
<span className="max-w-[80px] truncate">{item.image.split("/").pop()}</span>
|
||||
<button type="button" onClick={() => removeImage(i)} className="text-neutral-500 hover:text-red-400">
|
||||
@@ -886,8 +886,8 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
|
||||
validate(e.target.value);
|
||||
}}
|
||||
placeholder={placeholder || "Ссылка..."}
|
||||
className={`w-full rounded-md border bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none transition-colors ${
|
||||
error ? "border-red-500/50" : "border-white/5 focus:border-gold/50"
|
||||
className={`w-full rounded-md border bg-neutral-100 px-2 py-1 text-xs text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600 ${
|
||||
error ? "border-red-500/50" : "border-neutral-200 focus:border-gold/50 dark:border-white/5"
|
||||
}`}
|
||||
/>
|
||||
{error && (
|
||||
@@ -965,8 +965,8 @@ export function AutocompleteMulti({
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div
|
||||
onClick={() => { setOpen(true); inputRef.current?.focus(); }}
|
||||
className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-800 px-3 py-2 min-h-[42px] cursor-text transition-colors ${
|
||||
open ? "border-gold" : "border-white/10 hover:border-gold/30"
|
||||
className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-100 px-3 py-2 min-h-[42px] cursor-text transition-colors dark:bg-neutral-800 ${
|
||||
open ? "border-gold" : "border-neutral-200 hover:border-gold/30 dark:border-white/10"
|
||||
}`}
|
||||
>
|
||||
{selected.map((item) => (
|
||||
@@ -985,14 +985,14 @@ export function AutocompleteMulti({
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={selected.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-neutral-500 outline-none"
|
||||
className="flex-1 min-w-[80px] bg-transparent text-sm text-neutral-900 placeholder-neutral-400 outline-none dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden max-h-48 overflow-y-auto">
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden max-h-48 overflow-y-auto dark:border-white/10 dark:bg-neutral-800">
|
||||
{filtered.map((opt) => (
|
||||
<button key={opt} type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => addItem(opt)}
|
||||
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/5 transition-colors">
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-900 hover:bg-neutral-50 transition-colors dark:text-white dark:hover:bg-white/5">
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -106,14 +106,14 @@ export function ImageCropField({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||
{label} <span className="text-neutral-600">(перетащите · Ctrl+колёсико для масштаба)</span>
|
||||
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">
|
||||
{label} <span className="text-neutral-400 dark:text-neutral-600">(перетащите · Ctrl+колёсико для масштаба)</span>
|
||||
</label>
|
||||
{image ? (
|
||||
<div className={`${maxWidth} space-y-2`}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative ${aspect} overflow-hidden rounded-lg border border-white/10 cursor-grab active:cursor-grabbing select-none`}
|
||||
className={`relative ${aspect} overflow-hidden rounded-lg border border-neutral-200 cursor-grab active:cursor-grabbing select-none dark:border-white/10`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
@@ -155,7 +155,7 @@ export function ImageCropField({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-white/10 px-2.5 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-neutral-200 px-2.5 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25">
|
||||
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||
Заменить
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
@@ -170,7 +170,7 @@ export function ImageCropField({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/15 px-4 py-2.5 text-neutral-500 hover:border-gold/30 hover:text-neutral-300 transition-colors">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-neutral-500 hover:border-gold/30 hover:text-neutral-700 transition-colors dark:border-white/15 dark:hover:text-neutral-300">
|
||||
{uploading ? <Loader2 size={14} className="animate-spin" /> : <ImageIcon size={14} />}
|
||||
<span className="text-xs">{uploading ? "Загрузка..." : "Загрузить фото"}</span>
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
|
||||
@@ -12,8 +12,8 @@ export function PriceField({ label, value, onChange, placeholder = "0" }: PriceF
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
|
||||
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">{label}</label>
|
||||
<div className="flex rounded-lg border border-neutral-200 bg-neutral-100 focus-within:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
@@ -23,7 +23,7 @@ export function PriceField({ label, value, onChange, placeholder = "0" }: PriceF
|
||||
onChange(v ? `${v} BYN` : "");
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
|
||||
className="flex-1 bg-transparent px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none min-w-0 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
|
||||
BYN
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Admin UI Primitives
|
||||
*
|
||||
* Single source of truth for admin panel styling.
|
||||
* Every input, select, button, badge, card, and modal in /admin should use these.
|
||||
*/
|
||||
|
||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||
|
||||
/* ============================== */
|
||||
/* Style tokens */
|
||||
/* ============================== */
|
||||
|
||||
export const adminStyles = {
|
||||
/** Standard input — full width, rounded-lg */
|
||||
input:
|
||||
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500",
|
||||
|
||||
/** Compact input — smaller padding, text-sm */
|
||||
inputSm:
|
||||
"rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600",
|
||||
|
||||
/** Dashed input — for "add new" fields */
|
||||
inputDashed:
|
||||
"flex-1 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-4 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors dark:border-white/10 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-600",
|
||||
|
||||
/** Textarea — same as input + resize-none */
|
||||
textarea:
|
||||
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500",
|
||||
|
||||
/** Native select */
|
||||
select:
|
||||
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]",
|
||||
|
||||
/** Select option */
|
||||
option: "bg-white dark:bg-neutral-900",
|
||||
|
||||
/** Primary button — gold solid */
|
||||
btnPrimary:
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-all hover:bg-gold-light hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
|
||||
/** Secondary button — outline */
|
||||
btnSecondary:
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-200 dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700",
|
||||
|
||||
/** Small gold accent button */
|
||||
btnGoldSm:
|
||||
"rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-amber-700 hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed dark:text-gold",
|
||||
|
||||
/** Cancel/muted small button */
|
||||
btnCancelSm:
|
||||
"rounded-md border border-neutral-200 px-3 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25",
|
||||
|
||||
/** Danger button */
|
||||
btnDanger:
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-red-500",
|
||||
|
||||
/** Card container */
|
||||
card:
|
||||
"rounded-xl border border-neutral-200 bg-white p-5 dark:border-white/10 dark:bg-neutral-900",
|
||||
|
||||
/** Modal overlay */
|
||||
modalOverlay:
|
||||
"fixed inset-0 z-50 flex items-center justify-center p-4",
|
||||
|
||||
/** Modal backdrop */
|
||||
modalBackdrop:
|
||||
"absolute inset-0 bg-black/70 backdrop-blur-sm",
|
||||
|
||||
/** Modal content */
|
||||
modalContent:
|
||||
"relative w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]",
|
||||
|
||||
/** Modal close button */
|
||||
modalClose:
|
||||
"absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 transition-colors cursor-pointer dark:hover:bg-white/[0.06] dark:hover:text-white",
|
||||
|
||||
/** Label */
|
||||
label:
|
||||
"block text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400",
|
||||
|
||||
/** Section heading in admin */
|
||||
sectionTitle:
|
||||
"text-lg font-bold text-neutral-900 dark:text-white",
|
||||
|
||||
/** Dashed add-item button */
|
||||
addButton:
|
||||
"flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40",
|
||||
} as const;
|
||||
|
||||
/* ============================== */
|
||||
/* Input components */
|
||||
/* ============================== */
|
||||
|
||||
interface AdminInputProps extends ComponentPropsWithoutRef<"input"> {
|
||||
variant?: "default" | "sm" | "dashed";
|
||||
}
|
||||
|
||||
export const AdminInput = forwardRef<HTMLInputElement, AdminInputProps>(
|
||||
function AdminInput({ variant = "default", className = "", ...props }, ref) {
|
||||
const base =
|
||||
variant === "sm" ? adminStyles.inputSm
|
||||
: variant === "dashed" ? adminStyles.inputDashed
|
||||
: adminStyles.input;
|
||||
return <input ref={ref} className={`${base} ${className}`} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
interface AdminTextareaProps extends ComponentPropsWithoutRef<"textarea"> {
|
||||
autoResize?: boolean;
|
||||
}
|
||||
|
||||
export const AdminTextarea = forwardRef<HTMLTextAreaElement, AdminTextareaProps>(
|
||||
function AdminTextarea({ autoResize, className = "", ...props }, ref) {
|
||||
function handleInput(e: React.FormEvent<HTMLTextAreaElement>) {
|
||||
if (autoResize) {
|
||||
const el = e.currentTarget;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}
|
||||
}
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={`${adminStyles.textarea} ${className}`}
|
||||
{...props}
|
||||
{...(autoResize ? { onInput: handleInput } : {})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface AdminSelectProps extends ComponentPropsWithoutRef<"select"> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AdminSelect = forwardRef<HTMLSelectElement, AdminSelectProps>(
|
||||
function AdminSelect({ className = "", children, ...props }, ref) {
|
||||
return (
|
||||
<select ref={ref} className={`${adminStyles.select} ${className}`} {...props}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/* ============================== */
|
||||
/* Button components */
|
||||
/* ============================== */
|
||||
|
||||
interface AdminButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
variant?: "primary" | "secondary" | "danger" | "goldSm" | "cancelSm";
|
||||
}
|
||||
|
||||
export function AdminButton({ variant = "primary", className = "", ...props }: AdminButtonProps) {
|
||||
const base =
|
||||
variant === "secondary" ? adminStyles.btnSecondary
|
||||
: variant === "danger" ? adminStyles.btnDanger
|
||||
: variant === "goldSm" ? adminStyles.btnGoldSm
|
||||
: variant === "cancelSm" ? adminStyles.btnCancelSm
|
||||
: adminStyles.btnPrimary;
|
||||
return <button className={`${base} ${className}`} {...props} />;
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* Modal component */
|
||||
/* ============================== */
|
||||
|
||||
interface AdminModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
maxWidth?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AdminModal({ open, onClose, title, maxWidth = "max-w-sm", children }: AdminModalProps) {
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={adminStyles.modalOverlay} onClick={onClose}>
|
||||
<div className={adminStyles.modalBackdrop} />
|
||||
<div
|
||||
ref={focusTrapRef}
|
||||
className={`${adminStyles.modalContent} ${maxWidth}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<button onClick={onClose} className={adminStyles.modalClose} aria-label="Закрыть">
|
||||
<X size={16} />
|
||||
</button>
|
||||
{title && <h3 className="text-sm font-bold text-neutral-900 dark:text-white mb-4">{title}</h3>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export default function AboutEditorPage() {
|
||||
value={text}
|
||||
onChange={(e) => updateItem(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
|
||||
placeholder="Текст параграфа..."
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -65,7 +65,7 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
<div
|
||||
onClick={() => { setOpen(true); setTimeout(() => inputRef.current?.focus(), 0); }}
|
||||
className={`flex items-center gap-2 w-full rounded-lg border px-3 py-2 text-sm cursor-text transition-colors ${
|
||||
open ? "border-gold/40 bg-white/[0.06]" : "border-white/[0.08] bg-white/[0.04]"
|
||||
open ? "border-gold/40 bg-neutral-200/60 dark:bg-white/[0.06]" : "border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
{open ? (
|
||||
@@ -75,14 +75,14 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={selected ? selected.label : placeholder}
|
||||
className="flex-1 bg-transparent text-white placeholder-neutral-500 outline-none text-sm"
|
||||
className="flex-1 bg-transparent text-neutral-900 placeholder-neutral-400 outline-none text-sm dark:text-white dark:placeholder-neutral-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") { setOpen(false); setSearch(""); }
|
||||
if (e.key === "Backspace" && !search && value) { onChange(""); }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className={`flex-1 truncate ${selected ? "text-white" : "text-neutral-500"}`}>
|
||||
<span className={`flex-1 truncate ${selected ? "text-neutral-900 dark:text-white" : "text-neutral-500"}`}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
)}
|
||||
@@ -100,7 +100,7 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full rounded-lg border border-white/[0.08] shadow-xl overflow-hidden" style={{ backgroundColor: "#141414" }}>
|
||||
<div className="absolute z-20 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/[0.08] dark:bg-[#141414]">
|
||||
<div className="max-h-48 overflow-y-scroll admin-scrollbar">
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p>
|
||||
@@ -112,7 +112,7 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => { onChange(o.value); setOpen(false); setSearch(""); }}
|
||||
className={`w-full px-3 py-2 text-left text-sm transition-colors ${
|
||||
o.value === value ? "bg-gold/10 text-gold" : "text-white hover:bg-white/[0.05]"
|
||||
o.value === value ? "bg-gold/10 text-gold" : "text-neutral-900 hover:bg-neutral-50 dark:text-white dark:hover:bg-white/[0.05]"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
@@ -288,7 +288,7 @@ export function AddBookingModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const inputClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 placeholder-neutral-500";
|
||||
const inputClass = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 placeholder-neutral-400 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500";
|
||||
|
||||
const canSubmit = name.trim() && phone.trim() && !saving
|
||||
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
|
||||
@@ -297,21 +297,21 @@ export function AddBookingModal({
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
|
||||
<div className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<h3 className="text-base font-bold text-white">Добавить запись</h3>
|
||||
<p className="mt-1 text-xs text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
|
||||
<h3 className="text-base font-bold text-neutral-900 dark:text-white">Добавить запись</h3>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Type selector — single row */}
|
||||
<div className="flex rounded-lg border border-white/[0.08] bg-white/[0.03] p-0.5">
|
||||
<div className="flex rounded-lg border border-neutral-200 bg-neutral-100 p-0.5 dark:border-white/[0.08] dark:bg-white/[0.03]">
|
||||
<button
|
||||
onClick={() => setTab("classes")}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "classes" ? "bg-gold/20 text-gold shadow-sm" : "text-neutral-400 hover:text-white"
|
||||
tab === "classes" ? "bg-gold/20 text-gold shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Занятие
|
||||
@@ -320,7 +320,7 @@ export function AddBookingModal({
|
||||
<button
|
||||
onClick={() => { setTab("events"); setEventType("master-class"); }}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "events" && eventType === "master-class" ? "bg-purple-500/15 text-purple-400 shadow-sm" : "text-neutral-400 hover:text-white"
|
||||
tab === "events" && eventType === "master-class" ? "bg-purple-500/15 text-purple-400 shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Мастер-класс
|
||||
@@ -330,7 +330,7 @@ export function AddBookingModal({
|
||||
<button
|
||||
onClick={() => { setTab("events"); setEventType("open-day"); }}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "events" && eventType === "open-day" ? "bg-blue-500/15 text-blue-400 shadow-sm" : "text-neutral-400 hover:text-white"
|
||||
tab === "events" && eventType === "open-day" ? "bg-blue-500/15 text-blue-400 shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Open Day
|
||||
|
||||
@@ -43,10 +43,10 @@ export function SearchBar({
|
||||
value={query}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Поиск по имени или телефону..."
|
||||
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] py-2 pl-9 pr-8 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 py-2 pl-9 pr-8 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/40 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
{query && (
|
||||
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white">
|
||||
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 dark:hover:text-white">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -109,8 +109,8 @@ function IconPicker({
|
||||
setSearch("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-800 px-4 py-2.5 text-left text-white outline-none transition-colors ${
|
||||
open ? "border-gold" : "border-white/10"
|
||||
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-100 px-4 py-2.5 text-left text-neutral-900 outline-none transition-colors dark:bg-neutral-800 dark:text-white ${
|
||||
open ? "border-gold" : "border-neutral-200 dark:border-white/10"
|
||||
}`}
|
||||
>
|
||||
{SelectedIcon ? (
|
||||
@@ -118,13 +118,13 @@ function IconPicker({
|
||||
<SelectedIcon size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-white/10 text-neutral-500">?</span>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-500 dark:bg-white/10">?</span>
|
||||
)}
|
||||
<span className="text-sm">{selected?.label || value}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/10 dark:bg-neutral-800">
|
||||
<div className="p-2 pb-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -132,7 +132,7 @@ function IconPicker({
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск..."
|
||||
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
|
||||
className="w-full rounded-md border border-neutral-200 bg-neutral-100 px-3 py-1.5 text-sm text-neutral-900 outline-none focus:border-gold/50 placeholder:text-neutral-400 dark:border-white/10 dark:bg-neutral-900 dark:text-white dark:placeholder:text-neutral-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 max-h-56 overflow-y-auto">
|
||||
@@ -153,7 +153,7 @@ function IconPicker({
|
||||
className={`flex flex-col items-center gap-0.5 rounded-lg p-2 transition-colors ${
|
||||
key === value
|
||||
? "bg-gold/20 text-gold-light"
|
||||
: "text-neutral-400 hover:bg-white/5 hover:text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
|
||||
@@ -41,8 +41,8 @@ function PhoneField({ value, onChange }: { value: string; onChange: (v: string)
|
||||
value={value ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="+375 (XX) XXX-XX-XX"
|
||||
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||||
value && !isComplete ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
|
||||
value && !isComplete ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
{isComplete && (
|
||||
@@ -106,12 +106,12 @@ function InstagramField({ value, onChange }: { value: string; onChange: (v: stri
|
||||
validateUsername(username);
|
||||
}}
|
||||
placeholder="blackheartdancehouse"
|
||||
className={`w-full rounded-lg border bg-neutral-800 pl-8 pr-10 py-2.5 text-white placeholder-neutral-500 outline-none hover:border-gold/30 transition-colors ${
|
||||
className={`w-full rounded-lg border bg-neutral-100 pl-8 pr-10 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
|
||||
status === "invalid"
|
||||
? "border-red-500 focus:border-red-500"
|
||||
: status === "valid"
|
||||
? "border-green-500/50 focus:border-green-500"
|
||||
: "border-white/10 focus:border-gold"
|
||||
: "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
@@ -164,7 +164,7 @@ function AddressList({ items, onChange }: { items: string[]; onChange: (items: s
|
||||
type="text"
|
||||
value={addr}
|
||||
onChange={(e) => update(i, e.target.value)}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -183,7 +183,7 @@ function AddressList({ items, onChange }: { items: string[]; onChange: (items: s
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||
onBlur={add}
|
||||
placeholder="Добавить адрес..."
|
||||
className="flex-1 rounded-lg border border-dashed border-white/15 bg-neutral-800/50 px-4 py-2.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/50 transition-colors"
|
||||
className="flex-1 rounded-lg border border-dashed border-neutral-300 bg-neutral-100/50 px-4 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/50 transition-colors dark:border-white/15 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+17
-11
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Sparkles,
|
||||
@@ -88,7 +89,7 @@ export default function AdminLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-neutral-950 text-white">
|
||||
<div className="flex min-h-screen bg-neutral-50 text-neutral-900 dark:bg-neutral-950 dark:text-white">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
@@ -99,18 +100,18 @@ export default function AdminLayout({
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-white/10 bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
|
||||
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-white/10 px-5 py-4">
|
||||
<Link href="/admin" className="text-lg font-bold">
|
||||
BLACK HEART
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label="Закрыть меню"
|
||||
className="lg:hidden text-neutral-400 hover:text-white"
|
||||
className="lg:hidden text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -128,7 +129,7 @@ export default function AdminLayout({
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${
|
||||
active
|
||||
? "bg-gold/10 text-gold font-medium"
|
||||
: "text-neutral-400 hover:text-white hover:bg-white/5"
|
||||
: "text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
@@ -143,18 +144,22 @@ export default function AdminLayout({
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-white/10 p-3 space-y-1">
|
||||
<div className="border-t border-neutral-200 dark:border-white/10 p-3 space-y-1">
|
||||
<div className="flex items-center justify-between px-3 py-1">
|
||||
<span className="text-xs text-neutral-400 dark:text-neutral-500">Тема</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
target="_blank"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
Открыть сайт
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-red-400 hover:bg-white/5 transition-colors"
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-500 hover:text-red-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-red-400 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Выйти
|
||||
@@ -165,16 +170,17 @@ export default function AdminLayout({
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Top bar (mobile) */}
|
||||
<header className="sticky top-0 z-30 flex items-center gap-3 border-b border-white/10 bg-neutral-950 px-4 py-3 lg:hidden">
|
||||
<header className="sticky top-0 z-30 flex items-center gap-3 border-b border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-950 px-4 py-3 lg:hidden">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Открыть меню"
|
||||
aria-expanded={sidebarOpen}
|
||||
className="text-neutral-400 hover:text-white"
|
||||
className="text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<a href="/admin" className="font-bold hover:text-gold transition-colors">BLACK HEART</a>
|
||||
<a href="/admin" className="font-bold hover:text-gold transition-colors flex-1">BLACK HEART</a>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
|
||||
|
||||
@@ -36,18 +36,18 @@ export default function AdminLoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-950 px-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4 dark:bg-neutral-950">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-sm space-y-6 rounded-2xl border border-white/10 bg-neutral-900 p-8"
|
||||
className="w-full max-w-sm space-y-6 rounded-2xl border border-neutral-200 bg-white p-8 dark:border-white/10 dark:bg-neutral-900"
|
||||
>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-white">BLACK HEART</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">Панель управления</p>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">BLACK HEART</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Панель управления</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm text-neutral-400 mb-2">
|
||||
<label htmlFor="password" className="block text-sm text-neutral-500 mb-2 dark:text-neutral-400">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -56,7 +56,7 @@ export default function AdminLoginPage() {
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-3 pr-11 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-3 pr-11 text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
|
||||
placeholder="Введите пароль"
|
||||
autoFocus
|
||||
aria-describedby={error ? "login-error" : undefined}
|
||||
@@ -65,7 +65,7 @@ export default function AdminLoginPage() {
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
|
||||
@@ -68,7 +68,7 @@ function LocationSelect({
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
active
|
||||
? "bg-gold/20 text-gold border border-gold/40"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{active && <Check size={10} className="inline mr-1" />}
|
||||
@@ -144,16 +144,16 @@ function SlotsField({
|
||||
type="date"
|
||||
value={slot.date}
|
||||
onChange={(e) => updateSlot(i, { date: e.target.value })}
|
||||
className={`w-[140px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||
!slot.date ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
className={`w-[140px] rounded-lg border bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
|
||||
!slot.date ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={slot.startTime}
|
||||
onChange={(e) => updateSlot(i, { startTime: e.target.value })}
|
||||
className={`w-[100px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||
timeError ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
className={`w-[100px] rounded-lg border bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
|
||||
timeError ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-neutral-500 text-xs">–</span>
|
||||
@@ -161,12 +161,12 @@ function SlotsField({
|
||||
type="time"
|
||||
value={slot.endTime}
|
||||
onChange={(e) => updateSlot(i, { endTime: e.target.value })}
|
||||
className={`w-[100px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||
timeError ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
className={`w-[100px] rounded-lg border bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
|
||||
timeError ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
{dur && (
|
||||
<span className="text-[11px] text-neutral-500 bg-neutral-800/50 rounded-full px-2 py-0.5">
|
||||
<span className="text-[11px] text-neutral-500 bg-neutral-200/50 rounded-full px-2 py-0.5 dark:bg-neutral-800/50">
|
||||
{dur}
|
||||
</span>
|
||||
)}
|
||||
@@ -190,7 +190,7 @@ function SlotsField({
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSlot}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors dark:border-white/10 dark:bg-neutral-800/50"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Добавить дату
|
||||
@@ -223,8 +223,8 @@ function InstagramLinkField({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://instagram.com/p/... или /reel/..."
|
||||
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||||
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
|
||||
error ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
{value && !error && (
|
||||
@@ -318,13 +318,13 @@ function FilterBar({
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Поиск по названию или тренеру..."
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 pl-10 pr-4 py-2.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 pl-10 pr-4 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -340,7 +340,7 @@ function FilterBar({
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||
dateFilter === key
|
||||
? "bg-gold/20 text-gold border border-gold/40"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{DATE_FILTER_LABELS[key]}
|
||||
@@ -359,7 +359,7 @@ function FilterBar({
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||
locationFilter === loc.name
|
||||
? "bg-gold/20 text-gold border border-gold/40"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{loc.name}
|
||||
|
||||
@@ -63,13 +63,13 @@ function UnreadWidget({ counts }: { counts: UnreadCounts }) {
|
||||
<UserPlus size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-medium text-white">
|
||||
<h2 className="font-medium text-neutral-900 dark:text-white">
|
||||
Новые записи
|
||||
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-red-500 text-white text-[11px] font-bold min-w-[20px] h-[20px] px-1.5">
|
||||
{counts.total}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-400">Не подтверждённые заявки</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">Не подтверждённые заявки</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -78,7 +78,7 @@ function UnreadWidget({ counts }: { counts: UnreadCounts }) {
|
||||
<span className="rounded-full bg-gold/15 text-gold font-medium px-2 py-0.5">
|
||||
{item.count}
|
||||
</span>
|
||||
<span className="text-neutral-400">{item.label}</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -99,7 +99,7 @@ export default function AdminDashboard() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Панель управления</h1>
|
||||
<p className="mt-1 text-neutral-400">Выберите раздел для редактирования</p>
|
||||
<p className="mt-1 text-neutral-500 dark:text-neutral-400">Выберите раздел для редактирования</p>
|
||||
|
||||
{/* Unread bookings widget */}
|
||||
{counts && counts.total > 0 && (
|
||||
@@ -116,14 +116,14 @@ export default function AdminDashboard() {
|
||||
<Link
|
||||
key={card.href}
|
||||
href={card.href}
|
||||
className="group rounded-xl border border-white/10 bg-neutral-900 p-5 transition-all hover:border-gold/30 hover:bg-neutral-900/80"
|
||||
className="group rounded-xl border border-neutral-200 bg-white p-5 transition-all hover:border-gold/30 hover:bg-neutral-50 dark:border-white/10 dark:bg-neutral-900 dark:hover:bg-neutral-900/80"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gold/10 text-gold">
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-medium text-white group-hover:text-gold transition-colors flex items-center gap-2">
|
||||
<h2 className="font-medium text-neutral-900 group-hover:text-gold transition-colors flex items-center gap-2 dark:text-white">
|
||||
{card.label}
|
||||
{isBookings && counts && counts.total > 0 && (
|
||||
<span className="rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||
@@ -131,7 +131,7 @@ export default function AdminDashboard() {
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-500">{card.desc}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-500">{card.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -222,7 +222,7 @@ function ClassBlock({
|
||||
: {}),
|
||||
}}
|
||||
className={`absolute left-1 right-1 rounded-md border border-white/20 border-l-3 px-2 py-0.5 text-left text-xs text-white cursor-grab active:cursor-grabbing overflow-hidden select-none ${colors} ${
|
||||
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-neutral-900" : ""
|
||||
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-white dark:ring-offset-neutral-900" : ""
|
||||
} ${isDragging ? "opacity-30" : "hover:opacity-90 hover:border-white/40"}`}
|
||||
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` · ${cls.level}` : ""}${cls.status ? ` · ${cls.status}` : ""}`}
|
||||
>
|
||||
@@ -415,14 +415,14 @@ function ClassModal({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border border-white/10 bg-neutral-900 p-6 shadow-2xl"
|
||||
className="w-full max-w-md rounded-xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/10 dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
<h3 className="text-lg font-bold text-neutral-900 dark:text-white">
|
||||
{isNew ? "Новое занятие" : "Редактировать занятие"}
|
||||
</h3>
|
||||
<button type="button" onClick={onClose} className="text-neutral-400 hover:text-white">
|
||||
<button type="button" onClick={onClose} className="text-neutral-400 hover:text-neutral-900 transition-colors dark:hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -431,7 +431,7 @@ function ClassModal({
|
||||
{/* Day selector */}
|
||||
{allDays.length > 1 && (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Дни</label>
|
||||
<label className="block text-sm text-neutral-500 mb-2 dark:text-neutral-400">Дни</label>
|
||||
|
||||
{/* Day toggle buttons */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -445,7 +445,7 @@ function ClassModal({
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
active
|
||||
? "bg-gold/20 text-gold border border-gold/40"
|
||||
: "border border-white/10 text-neutral-500 hover:text-white hover:border-white/20"
|
||||
: "border border-neutral-200 text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 dark:border-white/10 dark:hover:text-white dark:hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
{d.dayShort}
|
||||
@@ -473,10 +473,10 @@ function ClassModal({
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-neutral-300 select-none"
|
||||
className="flex items-center gap-2 text-sm text-neutral-600 select-none dark:text-neutral-300"
|
||||
>
|
||||
<span className={`inline-flex items-center justify-center w-4 h-4 rounded border transition-colors ${
|
||||
sameTime ? "bg-gold border-gold" : "border-white/20 bg-neutral-800"
|
||||
sameTime ? "bg-gold border-gold" : "border-neutral-300 bg-neutral-100 dark:border-white/20 dark:bg-neutral-800"
|
||||
}`}>
|
||||
{sameTime && <span className="text-black text-xs font-bold leading-none">✓</span>}
|
||||
</span>
|
||||
@@ -492,10 +492,10 @@ function ClassModal({
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm text-neutral-400">Время по дням</label>
|
||||
<label className="block text-sm text-neutral-500 dark:text-neutral-400">Время по дням</label>
|
||||
{allDays.filter((d) => selectedDays.has(d.day)).map((d) => (
|
||||
<div key={d.day} className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-xs font-medium text-neutral-400 min-w-[28px]">
|
||||
<span className="shrink-0 text-xs font-medium text-neutral-500 min-w-[28px] dark:text-neutral-400">
|
||||
{d.dayShort}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
@@ -576,7 +576,7 @@ function ClassModal({
|
||||
}}
|
||||
className={`flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-opacity ${
|
||||
touched && !isValid
|
||||
? "bg-neutral-700 text-neutral-400 cursor-not-allowed"
|
||||
? "bg-neutral-200 text-neutral-400 cursor-not-allowed dark:bg-neutral-700"
|
||||
: "bg-gold text-black hover:opacity-90"
|
||||
}`}
|
||||
>
|
||||
@@ -973,18 +973,18 @@ function CalendarGrid({
|
||||
|
||||
{/* Calendar */}
|
||||
{sortedDays.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-white/10" ref={gridRef}>
|
||||
<div className="overflow-x-auto rounded-lg border border-neutral-200 dark:border-white/10" ref={gridRef}>
|
||||
<div className="min-w-[600px]">
|
||||
{/* Day headers */}
|
||||
<div className="flex border-b border-white/10 bg-neutral-800/50">
|
||||
<div className="w-14 shrink-0 bg-neutral-900" />
|
||||
<div className="flex border-b border-neutral-200 bg-neutral-100 dark:border-white/10 dark:bg-neutral-800/50">
|
||||
<div className="w-14 shrink-0 bg-neutral-50 dark:bg-neutral-900" />
|
||||
{sortedDays.map((day, di) => (
|
||||
<div
|
||||
key={day.day}
|
||||
className="flex-1 border-l border-white/10 px-2 py-2 text-center"
|
||||
className="flex-1 border-l border-neutral-200 px-2 py-2 text-center dark:border-white/10"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-sm font-medium text-white">{day.dayShort}</span>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-white">{day.dayShort}</span>
|
||||
<span className="text-xs text-neutral-500">({day.classes.length})</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1017,7 +1017,7 @@ function CalendarGrid({
|
||||
<div
|
||||
key={day.day}
|
||||
ref={(el) => { columnRefs.current[di] = el; }}
|
||||
className={`flex-1 border-l border-white/10 relative ${drag ? "cursor-grabbing" : "cursor-pointer"}`}
|
||||
className={`flex-1 border-l border-neutral-200 relative dark:border-white/10 ${drag ? "cursor-grabbing" : "cursor-pointer"}`}
|
||||
style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}
|
||||
onMouseMove={(e) => {
|
||||
if (drag) return;
|
||||
@@ -1044,7 +1044,7 @@ function CalendarGrid({
|
||||
{hours.slice(0, -1).map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="absolute left-0 right-0 border-t border-white/5"
|
||||
className="absolute left-0 right-0 border-t border-neutral-200/60 dark:border-white/5"
|
||||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT}px` }}
|
||||
/>
|
||||
))}
|
||||
@@ -1052,7 +1052,7 @@ function CalendarGrid({
|
||||
{hours.slice(0, -1).map((h) => (
|
||||
<div
|
||||
key={`${h}-30`}
|
||||
className="absolute left-0 right-0 border-t border-white/[0.02]"
|
||||
className="absolute left-0 right-0 border-t border-neutral-200/30 dark:border-white/[0.02]"
|
||||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + HOUR_HEIGHT / 2}px` }}
|
||||
/>
|
||||
))}
|
||||
@@ -1330,7 +1330,7 @@ export default function ScheduleEditorPage() {
|
||||
className={`flex items-center gap-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
i === activeLocation
|
||||
? "bg-gold/10 text-gold border border-gold/30"
|
||||
: "border border-white/10 text-neutral-400 hover:text-white"
|
||||
: "border border-neutral-200 text-neutral-500 hover:text-neutral-900 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
@@ -1362,7 +1362,7 @@ export default function ScheduleEditorPage() {
|
||||
update({ ...data, locations: newLocations });
|
||||
setActiveLocation(newLocations.length - 1);
|
||||
}}
|
||||
className="rounded-lg border border-dashed border-white/20 px-4 py-2 text-sm text-neutral-500 hover:text-white transition-colors"
|
||||
className="rounded-lg border border-dashed border-neutral-300 px-4 py-2 text-sm text-neutral-500 hover:text-neutral-900 transition-colors dark:border-white/20 dark:hover:text-white"
|
||||
>
|
||||
<Plus size={14} className="inline" /> Локация
|
||||
</button>
|
||||
|
||||
@@ -165,18 +165,18 @@ export default function TeamEditorPage() {
|
||||
renderItem={(member) => (
|
||||
<Link
|
||||
href={`/admin/team/${member.id}`}
|
||||
className="flex items-center gap-4 flex-1 min-w-0 rounded-lg px-2 py-1.5 -my-1.5 hover:bg-white/[0.04] transition-colors"
|
||||
className="flex items-center gap-4 flex-1 min-w-0 rounded-lg px-2 py-1.5 -my-1.5 hover:bg-neutral-100 transition-colors dark:hover:bg-white/[0.04]"
|
||||
>
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg">
|
||||
{member.image ? (
|
||||
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="h-full w-full bg-neutral-800" />
|
||||
<div className="h-full w-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white truncate">{member.name}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||
<p className="font-medium text-neutral-900 truncate dark:text-white">{member.name}</p>
|
||||
<p className="text-sm text-neutral-500 truncate dark:text-neutral-400">{member.role}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,7 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* ===== Selection ===== */
|
||||
|
||||
::selection {
|
||||
|
||||
+8
-1
@@ -43,7 +43,14 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ru" className="dark">
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem('theme');if(t==='light'){document.documentElement.classList.remove('dark')}else{document.documentElement.classList.add('dark')}}catch(e){}})();`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${inter.variable} ${oswald.variable} surface-base font-sans antialiased`}
|
||||
>
|
||||
|
||||
+20
-6
@@ -1,15 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { Hero } from "@/components/sections/Hero";
|
||||
import { Team } from "@/components/sections/Team";
|
||||
import { About } from "@/components/sections/About";
|
||||
import { Classes } from "@/components/sections/Classes";
|
||||
import { TeamPreview } from "@/components/sections/TeamPreview";
|
||||
import { MasterClasses } from "@/components/sections/MasterClasses";
|
||||
import { Schedule } from "@/components/sections/Schedule";
|
||||
import { Pricing } from "@/components/sections/Pricing";
|
||||
import { News } from "@/components/sections/News";
|
||||
import { FAQ } from "@/components/sections/FAQ";
|
||||
import { Contact } from "@/components/sections/Contact";
|
||||
import { BackToTop } from "@/components/ui/BackToTop";
|
||||
import { FloatingContact } from "@/components/ui/FloatingContact";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { ClientShell } from "@/components/layout/ClientShell";
|
||||
@@ -26,7 +27,7 @@ export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<ClientShell>
|
||||
<Header />
|
||||
<Header popups={content?.popups} />
|
||||
<main id="main-content">
|
||||
{content?.hero && <Hero data={content.hero} />}
|
||||
{content?.about && (
|
||||
@@ -40,16 +41,29 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
{content?.classes && <Classes data={content.classes} />}
|
||||
{content?.team && <Team data={content.team} schedule={content.schedule?.locations} scheduleConfig={content.scheduleConfig} />}
|
||||
{content?.team && <TeamPreview title={content.team.title} members={content.team.members} schedule={content.schedule?.locations} scheduleConfig={content.scheduleConfig} />}
|
||||
{openDayData && content?.popups && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team?.members ?? []} locations={content.schedule?.locations} />}
|
||||
{content?.schedule && <Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes?.items ?? []} teamMembers={content.team?.members ?? []} />}
|
||||
{content?.schedule && (
|
||||
<section id="schedule" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505] overflow-hidden">
|
||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||
<div className="section-container text-center">
|
||||
<SectionHeading centered>{content.schedule.title}</SectionHeading>
|
||||
<p className="mt-4 text-neutral-500 dark:text-neutral-400">
|
||||
{content.schedule.locations.length} студии · {content.schedule.locations.reduce((sum, loc) => sum + loc.days.reduce((s, d) => s + d.classes.length, 0), 0)} занятий в неделю
|
||||
</p>
|
||||
<Link href="/schedule" className="mt-6 inline-flex items-center gap-2 rounded-full bg-gold px-8 py-3 text-sm font-semibold text-black transition-all hover:shadow-[0_0_24px_rgba(201,169,110,0.4)]">
|
||||
Смотреть расписание
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{content?.pricing && <Pricing data={content.pricing} />}
|
||||
{content?.masterClasses && <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} locations={content.schedule?.locations} />}
|
||||
{content?.news && <News data={content.news} />}
|
||||
{content?.faq && <FAQ data={content.faq} />}
|
||||
{content?.contact && <Contact data={content.contact} />}
|
||||
<BackToTop />
|
||||
<FloatingContact />
|
||||
<FloatingContact popups={content?.popups} contactInstagram={content?.contact?.instagram} contactPhone={content?.contact?.phone} />
|
||||
</main>
|
||||
<Footer />
|
||||
</ClientShell>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Metadata } from "next";
|
||||
import { getContent } from "@/lib/content";
|
||||
import { Schedule } from "@/components/sections/Schedule";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { ClientShell } from "@/components/layout/ClientShell";
|
||||
import { BackToTop } from "@/components/ui/BackToTop";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Расписание | BLACK HEART DANCE HOUSE",
|
||||
};
|
||||
|
||||
export default function SchedulePage() {
|
||||
const content = getContent();
|
||||
|
||||
if (!content?.schedule) {
|
||||
return (
|
||||
<ClientShell>
|
||||
<Header popups={content?.popups} />
|
||||
<main id="main-content" className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-neutral-400">Расписание не найдено.</p>
|
||||
</main>
|
||||
<Footer />
|
||||
</ClientShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientShell>
|
||||
<Header popups={content.popups} />
|
||||
<main id="main-content">
|
||||
<Schedule
|
||||
data={content.schedule}
|
||||
scheduleConfig={content.scheduleConfig}
|
||||
classItems={content.classes?.items ?? []}
|
||||
teamMembers={content.team?.members ?? []}
|
||||
/>
|
||||
<BackToTop />
|
||||
</main>
|
||||
<Footer />
|
||||
</ClientShell>
|
||||
);
|
||||
}
|
||||
@@ -288,6 +288,31 @@ html:not(.dark) .gradient-text {
|
||||
animation: modal-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ===== Team Marquee ===== */
|
||||
|
||||
@keyframes team-marquee-left {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@keyframes team-marquee-right {
|
||||
from { transform: translateX(-50%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ===== Team Grid Card Entrance ===== */
|
||||
|
||||
@keyframes team-grid-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Team Info Fade ===== */
|
||||
|
||||
@keyframes team-info-in {
|
||||
@@ -362,11 +387,11 @@ html:not(.dark) .gradient-text {
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.4), transparent);
|
||||
background: linear-gradient(90deg, transparent 5%, rgba(201, 169, 110, 0.4) 30%, rgba(201, 169, 110, 0.5) 50%, rgba(201, 169, 110, 0.4) 70%, transparent 95%);
|
||||
}
|
||||
|
||||
:is(.dark) .section-divider {
|
||||
background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.15), transparent);
|
||||
background: linear-gradient(90deg, transparent 5%, rgba(201, 169, 110, 0.12) 30%, rgba(201, 169, 110, 0.2) 50%, rgba(201, 169, 110, 0.12) 70%, transparent 95%);
|
||||
}
|
||||
|
||||
/* ===== No-JS Fallback ===== */
|
||||
@@ -422,4 +447,10 @@ noscript ~ * [style*="opacity: 0"],
|
||||
.team-card-glitter::before {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
[style*="team-grid-card-in"] {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +52,11 @@
|
||||
/* ===== Layout ===== */
|
||||
|
||||
.section-padding {
|
||||
@apply py-20 sm:py-32;
|
||||
@apply py-20 sm:py-28;
|
||||
}
|
||||
|
||||
.section-container {
|
||||
@apply mx-auto max-w-6xl px-6 sm:px-8;
|
||||
@apply mx-auto max-w-7xl px-6 sm:px-10;
|
||||
}
|
||||
|
||||
/* ===== Section Glow Backgrounds ===== */
|
||||
@@ -65,33 +65,17 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section-glow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(600px, 100%);
|
||||
height: 400px;
|
||||
background: radial-gradient(ellipse, rgba(201, 169, 110, 0.15), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:is(.dark) .section-glow::before {
|
||||
background: radial-gradient(ellipse, rgba(201, 169, 110, 0.05), transparent 70%);
|
||||
}
|
||||
|
||||
/* ===== Glass Card ===== */
|
||||
|
||||
.glass-card {
|
||||
@apply rounded-2xl border backdrop-blur-sm transition-all duration-300;
|
||||
@apply rounded-2xl border backdrop-blur-md transition-all duration-300;
|
||||
@apply border-neutral-200/80 bg-white/90 shadow-sm shadow-gold/[0.04];
|
||||
@apply dark:border-white/[0.06] dark:bg-white/[0.04] dark:shadow-none;
|
||||
@apply dark:border-white/[0.08] dark:bg-white/[0.05] dark:backdrop-blur-lg dark:shadow-none;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
@apply border-gold/30 bg-white shadow-md shadow-gold/[0.08];
|
||||
@apply dark:border-gold/15 dark:bg-white/[0.06] dark:shadow-none;
|
||||
@apply dark:border-gold/20 dark:bg-white/[0.08] dark:shadow-[0_0_30px_rgba(201,169,110,0.06)];
|
||||
}
|
||||
|
||||
/* ===== Photo Filter ===== */
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { ClientShell } from "@/components/layout/ClientShell";
|
||||
import { BackToTop } from "@/components/ui/BackToTop";
|
||||
import { TeamGrid } from "@/components/sections/team/TeamGrid";
|
||||
import { getContent } from "@/lib/content";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Команда | BLACK HEART DANCE HOUSE",
|
||||
};
|
||||
|
||||
export default function TeamPage() {
|
||||
const content = getContent();
|
||||
|
||||
if (!content?.team) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientShell>
|
||||
<Header popups={content?.popups} />
|
||||
<main id="main-content" className="pt-16">
|
||||
<TeamGrid
|
||||
data={content.team}
|
||||
schedule={content.schedule?.locations}
|
||||
scheduleConfig={content.scheduleConfig}
|
||||
/>
|
||||
<BackToTop />
|
||||
</main>
|
||||
<Footer />
|
||||
</ClientShell>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,13 @@ import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||
import { useBooking } from "@/contexts/BookingContext";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
export function Header() {
|
||||
interface HeaderProps {
|
||||
popups?: SiteContent["popups"];
|
||||
}
|
||||
|
||||
export function Header({ popups }: HeaderProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
@@ -60,13 +65,33 @@ export function Header() {
|
||||
prevMenuOpenRef.current = menuOpen;
|
||||
}, [menuOpen]);
|
||||
|
||||
// Detect if we're on a sub-page (not the landing page)
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
useEffect(() => {
|
||||
setCurrentPath(window.location.pathname);
|
||||
}, []);
|
||||
const isSubPage = currentPath !== "/";
|
||||
|
||||
// Filter out nav links whose target section doesn't exist on the page
|
||||
// On sub-pages, show all links — hash links point back to landing (e.g. /#about)
|
||||
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname;
|
||||
if (path !== "/") {
|
||||
// Sub-page: show all links, prefix hash links with /
|
||||
setVisibleLinks(
|
||||
NAV_LINKS.map((l) =>
|
||||
l.href.startsWith("#") ? { ...l, href: `/${l.href}` } : l
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Landing page: filter by existing DOM sections
|
||||
setVisibleLinks(
|
||||
NAV_LINKS
|
||||
.filter((l) => document.getElementById(l.href.replace("#", "")))
|
||||
.filter((l) => l.href.startsWith("/") || document.getElementById(l.href.replace("#", "")))
|
||||
.map((l) => {
|
||||
if (l.href.startsWith("/")) return l;
|
||||
const section = document.getElementById(l.href.replace("#", ""));
|
||||
const heading = section?.querySelector("h2");
|
||||
if (heading?.textContent && l.href !== "#hero") {
|
||||
@@ -78,7 +103,7 @@ export function Header() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sectionIds = visibleLinks.map((l) => l.href.replace("#", ""));
|
||||
const sectionIds = visibleLinks.filter((l) => l.href.startsWith("#")).map((l) => l.href.replace("#", ""));
|
||||
const observers: IntersectionObserver[] = [];
|
||||
|
||||
// Observe hero — when visible, clear active section
|
||||
@@ -125,8 +150,8 @@ export function Header() {
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 z-50 w-full transition-all duration-500 ${
|
||||
scrolled || menuOpen
|
||||
? "bg-white/90 shadow-none backdrop-blur-xl dark:bg-black/40"
|
||||
scrolled || menuOpen || isSubPage
|
||||
? "backdrop-blur-xl border-b border-white/[0.08]"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
@@ -155,7 +180,7 @@ export function Header() {
|
||||
|
||||
<nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex" aria-label="Основная навигация">
|
||||
{visibleLinks.map((link) => {
|
||||
const isActive = activeSection === link.href.replace("#", "");
|
||||
const isActive = link.href.startsWith("/") ? currentPath === link.href : activeSection === link.href.replace("#", "");
|
||||
return (
|
||||
<a
|
||||
key={link.href}
|
||||
@@ -164,7 +189,7 @@ export function Header() {
|
||||
className={`relative whitespace-nowrap 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 ${
|
||||
isActive
|
||||
? "text-gold after:w-full"
|
||||
: scrolled
|
||||
: scrolled || isSubPage
|
||||
? "text-neutral-600 after:w-0 hover:text-neutral-900 hover:after:w-full dark:text-neutral-400 dark:hover:text-white"
|
||||
: "text-white/80 after:w-0 hover:text-white hover:after:w-full"
|
||||
}`}
|
||||
@@ -199,7 +224,7 @@ export function Header() {
|
||||
>
|
||||
<nav className="border-t border-neutral-200/60 dark:border-white/[0.06] px-6 py-4 text-center sm:px-8" aria-label="Основная навигация">
|
||||
{visibleLinks.map((link, index) => {
|
||||
const isActive = activeSection === link.href.replace("#", "");
|
||||
const isActive = link.href.startsWith("/") ? currentPath === link.href : activeSection === link.href.replace("#", "");
|
||||
return (
|
||||
<a
|
||||
key={link.href}
|
||||
@@ -221,7 +246,17 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
|
||||
<SignupModal open={bookingOpen} onClose={closeBooking} endpoint="/api/group-booking" />
|
||||
<SignupModal
|
||||
open={bookingOpen}
|
||||
onClose={closeBooking}
|
||||
subtitle={popups?.bookingSubtitle || undefined}
|
||||
endpoint="/api/group-booking"
|
||||
successMessage={popups?.successMessage}
|
||||
waitingMessage={popups?.waitingListText}
|
||||
errorMessage={popups?.errorMessage}
|
||||
instagramHint={popups?.instagramHint}
|
||||
instagramUrl={popups?.contactInstagram}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function About({ data: about, stats }: AboutProps) {
|
||||
<div
|
||||
key={i}
|
||||
aria-label={stat.ariaLabel}
|
||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/80 p-6 shadow-sm shadow-gold/[0.06] transition-all duration-300 hover:border-gold/30 hover:shadow-md hover:shadow-gold/[0.1] sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:shadow-none dark:hover:border-gold/20 dark:hover:shadow-none"
|
||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/80 p-6 shadow-sm shadow-gold/[0.06] transition-all duration-300 hover:border-gold/30 hover:shadow-md hover:shadow-gold/[0.1] sm:p-8 dark:border-white/[0.08] dark:bg-white/[0.04] dark:backdrop-blur-md dark:shadow-none dark:hover:border-gold/20 dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.06)]"
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/20 dark:text-gold-light" aria-hidden="true">
|
||||
{stat.icon}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
@@ -7,16 +8,13 @@ import {
|
||||
Dumbbell, Wind, Moon, Sun, Ribbon, Gem, Feather, CircleDot,
|
||||
Activity, Drama, PersonStanding, Footprints, PartyPopper, Flower2,
|
||||
Waves, Eye, Orbit, Brush, Palette, HandMetal, Theater,
|
||||
ArrowUpRight, X,
|
||||
} from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
||||
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
||||
import type { ClassItem, SiteContent } from "@/types";
|
||||
import { UI_CONFIG } from "@/lib/config";
|
||||
import { formatMarkup } from "@/lib/markup";
|
||||
|
||||
/** Map of kebab-case icon keys to their components (curated for dance school) */
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
"flame": Flame, "heart": Heart, "heart-pulse": HeartPulse, "star": Star,
|
||||
"sparkles": Sparkles, "music": Music, "zap": Zap, "crown": Crown,
|
||||
@@ -30,7 +28,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
|
||||
function getIcon(key: string) {
|
||||
const Icon = ICON_MAP[key];
|
||||
return Icon ? <Icon size={20} /> : null;
|
||||
return Icon ? <Icon size={16} /> : null;
|
||||
}
|
||||
|
||||
interface ClassesProps {
|
||||
@@ -38,100 +36,138 @@ interface ClassesProps {
|
||||
}
|
||||
|
||||
export function Classes({ data: classes }: ClassesProps) {
|
||||
const [selected, setSelected] = useState<ClassItem | null>(null);
|
||||
|
||||
if (!classes?.items?.length) return null;
|
||||
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
||||
totalItems: classes.items.length,
|
||||
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
|
||||
});
|
||||
|
||||
return (
|
||||
<section id="classes" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#080808]">
|
||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||
<div className="section-container">
|
||||
<div className="mx-auto max-w-[96rem] px-4 sm:px-6">
|
||||
<Reveal>
|
||||
<SectionHeading centered>{classes.title}</SectionHeading>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-14">
|
||||
<Reveal>
|
||||
<ShowcaseLayout<ClassItem>
|
||||
items={classes.items}
|
||||
activeIndex={activeIndex}
|
||||
onSelect={select}
|
||||
onHoverChange={setHovering}
|
||||
getItemLabel={(item) => item.name}
|
||||
renderDetail={(item) => (
|
||||
<div>
|
||||
{/* Hero image */}
|
||||
{item.images && item.images[0] && (
|
||||
<div className="team-card-glitter relative aspect-[16/9] w-full overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
src={item.images[0]}
|
||||
alt={item.name}
|
||||
fill
|
||||
loading="lazy"
|
||||
sizes="(min-width: 1024px) 60vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
|
||||
transform: item.imageZoom && item.imageZoom > 1 ? `scale(${item.imageZoom})` : undefined,
|
||||
}}
|
||||
/>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||
|
||||
{/* Icon + name overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 flex items-center gap-3">
|
||||
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm">
|
||||
{getIcon(item.icon)}
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white drop-shadow-[0_2px_8px_rgba(0,0,0,0.5)]">
|
||||
{item.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{item.detailedDescription && (
|
||||
<div className="mt-5 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
|
||||
{formatMarkup(item.detailedDescription)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
renderSelectorItem={(item, _i, isActive) => (
|
||||
<div className="flex items-center gap-2 px-3 py-2 lg:gap-3 lg:p-3">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`flex h-7 w-7 lg:h-9 lg:w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${
|
||||
isActive
|
||||
? "bg-gold/20 text-gold-light"
|
||||
: "bg-neutral-200/50 text-neutral-500 dark:bg-white/[0.06] dark:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{getIcon(item.icon)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={`text-xs lg:text-sm font-semibold truncate transition-colors ${
|
||||
isActive
|
||||
? "text-gold"
|
||||
: "text-neutral-700 dark:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
<div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{classes.items.map((item, i) => (
|
||||
<Reveal key={i}>
|
||||
<button
|
||||
onClick={() => setSelected(item)}
|
||||
className="group relative w-full text-left rounded-2xl border border-neutral-200 bg-white p-5 transition-all duration-300 hover:border-gold/30 hover:shadow-lg hover:shadow-gold/[0.08] cursor-pointer dark:border-white/[0.08] dark:bg-white/[0.03] dark:hover:border-gold/20 dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.06)]"
|
||||
>
|
||||
{/* Header: icon + name + arrow */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gold/10 text-gold-dark dark:text-gold-light">
|
||||
{getIcon(item.icon)}
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-neutral-900 dark:text-white">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="hidden lg:block text-xs text-neutral-600 dark:text-neutral-500 truncate">
|
||||
{item.description}
|
||||
</p>
|
||||
</h3>
|
||||
</div>
|
||||
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-neutral-200 text-neutral-400 transition-all group-hover:border-gold/40 group-hover:text-gold dark:border-white/10 dark:text-neutral-500">
|
||||
<ArrowUpRight size={14} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
{/* Description */}
|
||||
<p className="mt-2.5 text-sm leading-relaxed text-neutral-500 dark:text-neutral-400 line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
{/* Photo */}
|
||||
{item.images?.[0] && (
|
||||
<div className="relative mt-4 aspect-[4/3] overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={item.images[0]}
|
||||
alt={item.name}
|
||||
fill
|
||||
loading="lazy"
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
style={{
|
||||
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
|
||||
transform: item.imageZoom && item.imageZoom > 1 ? `scale(${item.imageZoom})` : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail modal */}
|
||||
{selected && (
|
||||
<ClassDetailModal item={selected} onClose={() => setSelected(null)} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassDetailModal({ item, onClose }: { item: ClassItem; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={item.name}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div
|
||||
className="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-neutral-950 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Hero image */}
|
||||
{item.images?.[0] && (
|
||||
<div className="relative aspect-[16/9] overflow-hidden rounded-t-2xl">
|
||||
<Image
|
||||
src={item.images[0]}
|
||||
alt={item.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 672px, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gold/20 text-gold-light backdrop-blur-sm">
|
||||
{getIcon(item.icon)}
|
||||
</span>
|
||||
<h3 className="text-2xl font-bold text-white drop-shadow-[0_2px_8px_rgba(0,0,0,0.5)]">
|
||||
{item.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full bg-black/40 text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 sm:p-8">
|
||||
{item.detailedDescription && (
|
||||
<div className="text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
|
||||
{formatMarkup(item.detailedDescription)}
|
||||
</div>
|
||||
)}
|
||||
{!item.detailedDescription && item.description && (
|
||||
<p className="text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function FAQ({ data: faq }: FAQProps) {
|
||||
className={`rounded-xl border transition-all duration-300 ${
|
||||
isOpen
|
||||
? "border-gold/30 bg-gradient-to-br from-gold/[0.06] via-transparent to-gold/[0.03] shadow-md shadow-gold/5"
|
||||
: "border-neutral-200 bg-white shadow-sm shadow-gold/[0.03] hover:border-neutral-300 hover:shadow-md hover:shadow-gold/[0.06] dark:border-white/[0.06] dark:bg-neutral-950 dark:shadow-none dark:hover:border-white/[0.12] dark:hover:shadow-none"
|
||||
: "border-neutral-200 bg-white shadow-sm shadow-gold/[0.03] hover:border-neutral-300 hover:shadow-md hover:shadow-gold/[0.06] dark:border-white/[0.08] dark:bg-white/[0.03] dark:backdrop-blur-md dark:shadow-none dark:hover:border-white/[0.15] dark:hover:shadow-[0_0_20px_rgba(201,169,110,0.05)]"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -98,7 +98,7 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
}, [scrollToNext]);
|
||||
|
||||
return (
|
||||
<section id="hero" ref={sectionRef} aria-label="Главный баннер" className="relative flex min-h-svh items-center justify-center overflow-hidden bg-neutral-100 dark:bg-neutral-950">
|
||||
<section id="hero" ref={sectionRef} aria-label="Главный баннер" className="relative flex min-h-svh items-center justify-center overflow-hidden bg-neutral-950">
|
||||
{/* Videos render only after hydration to avoid SSR mismatch */}
|
||||
{mounted && (
|
||||
<>
|
||||
@@ -161,7 +161,7 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
{/* Loading overlay — covers videos but not content */}
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="absolute inset-0 z-[5] bg-neutral-100 dark:bg-neutral-950 pointer-events-none transition-opacity duration-1000"
|
||||
className="absolute inset-0 z-[5] bg-neutral-950 pointer-events-none transition-opacity duration-1000"
|
||||
/>
|
||||
|
||||
{/* Vignette — dark edges to guide eye to center */}
|
||||
@@ -186,11 +186,11 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<h1 className="hero-title font-display text-4xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
|
||||
<span className="gradient-text">{hero.headline}</span>
|
||||
<h1 className="hero-title font-display text-4xl font-bold tracking-tight text-gold sm:text-6xl lg:text-8xl">
|
||||
{hero.headline}
|
||||
</h1>
|
||||
|
||||
<p className="hero-subtitle mx-auto mt-5 max-w-xl text-lg text-gold-light sm:mt-8 sm:text-2xl">
|
||||
<p className="hero-subtitle mx-auto mt-5 max-w-xl text-lg text-white/80 sm:mt-8 sm:text-2xl">
|
||||
{hero.subheadline}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
|
||||
isPopular
|
||||
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10"
|
||||
: "border-neutral-200 bg-white shadow-sm shadow-gold/[0.04] hover:shadow-md hover:shadow-gold/[0.08] dark:border-white/[0.06] dark:bg-neutral-950 dark:shadow-none dark:hover:shadow-none"
|
||||
: "border-neutral-200 bg-white shadow-sm shadow-gold/[0.04] hover:shadow-md hover:shadow-gold/[0.08] dark:border-white/[0.08] dark:bg-white/[0.03] dark:backdrop-blur-md dark:shadow-none dark:hover:border-white/[0.15] dark:hover:shadow-[0_0_20px_rgba(201,169,110,0.05)]"
|
||||
}`}
|
||||
>
|
||||
{/* Popular badge */}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, X } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { TeamProfile } from "@/components/sections/team/TeamProfile";
|
||||
import type { TeamMember, ScheduleLocation, SiteContent } from "@/types/content";
|
||||
|
||||
interface TeamPreviewProps {
|
||||
title: string;
|
||||
members: TeamMember[];
|
||||
schedule?: ScheduleLocation[];
|
||||
scheduleConfig?: SiteContent["scheduleConfig"];
|
||||
}
|
||||
|
||||
export function TeamPreview({ title, members, schedule, scheduleConfig }: TeamPreviewProps) {
|
||||
if (!members.length) return null;
|
||||
|
||||
const [activeMember, setActiveMember] = useState<TeamMember | null>(null);
|
||||
|
||||
const openMember = useCallback((member: TeamMember) => {
|
||||
setActiveMember(member);
|
||||
document.body.style.overflow = "hidden";
|
||||
}, []);
|
||||
|
||||
const closeMember = useCallback(() => {
|
||||
setActiveMember(null);
|
||||
document.body.style.overflow = "";
|
||||
}, []);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!activeMember) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") closeMember();
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [activeMember, closeMember]);
|
||||
|
||||
// Close on browser back
|
||||
useEffect(() => {
|
||||
if (!activeMember) return;
|
||||
history.pushState({ trainerModal: true }, "");
|
||||
function onPop() { closeMember(); }
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, [activeMember, closeMember]);
|
||||
|
||||
// Double the list for seamless infinite scroll
|
||||
const strip = [...members, ...members];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
id="team"
|
||||
className="section-glow relative py-16 sm:py-24 bg-neutral-50 dark:bg-[#050505] overflow-hidden"
|
||||
>
|
||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||
|
||||
<Reveal>
|
||||
<div className="text-center mb-10 sm:mb-14 px-6">
|
||||
<SectionHeading centered>{title}</SectionHeading>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* ── Photo Marquee ── */}
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-16 sm:w-32 z-10 bg-gradient-to-r from-neutral-50 dark:from-[#050505] to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-16 sm:w-32 z-10 bg-gradient-to-l from-neutral-50 dark:from-[#050505] to-transparent" />
|
||||
|
||||
<MarqueeStrip members={strip} onMemberClick={openMember} />
|
||||
</div>
|
||||
|
||||
{/* ── CTA to full team page ── */}
|
||||
<Reveal>
|
||||
<div className="mt-10 sm:mt-14 text-center px-6">
|
||||
<Link
|
||||
href="/team"
|
||||
className="group inline-flex items-center gap-3 text-sm font-medium text-gold transition-all duration-300 hover:gap-4"
|
||||
>
|
||||
<span className="relative">
|
||||
Познакомиться с командой
|
||||
<span className="absolute -bottom-1 left-0 w-full h-px bg-gold/30 group-hover:bg-gold transition-colors duration-300" />
|
||||
</span>
|
||||
<ArrowRight size={15} className="transition-transform duration-300 group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* ── Trainer Profile Modal ── */}
|
||||
{activeMember && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-neutral-50/95 dark:bg-[#050505]/95 backdrop-blur-sm overflow-y-auto"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) closeMember(); }}
|
||||
>
|
||||
<button
|
||||
onClick={closeMember}
|
||||
aria-label="Закрыть"
|
||||
className="fixed top-5 right-5 z-50 rounded-full bg-neutral-200 p-2.5 text-neutral-600 hover:text-neutral-900 hover:bg-neutral-300 transition-colors cursor-pointer
|
||||
dark:bg-white/10 dark:text-white/60 dark:hover:text-white dark:hover:bg-white/20"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="px-4 sm:px-6 lg:px-8 pt-16 pb-10 min-h-screen"
|
||||
style={{ animation: "modal-content-in 0.4s cubic-bezier(0.16, 1, 0.3, 1)" }}>
|
||||
<TeamProfile
|
||||
member={activeMember}
|
||||
onBack={closeMember}
|
||||
schedule={schedule}
|
||||
scheduleConfig={scheduleConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Marquee Strip ─────────────────────────────────────────────────── */
|
||||
|
||||
function MarqueeStrip({ members, onMemberClick }: { members: TeamMember[]; onMemberClick: (m: TeamMember) => void }) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
function pause() { track!.style.animationPlayState = "paused"; }
|
||||
function play() { track!.style.animationPlayState = "running"; }
|
||||
track.addEventListener("mouseenter", pause);
|
||||
track.addEventListener("mouseleave", play);
|
||||
return () => {
|
||||
track.removeEventListener("mouseenter", pause);
|
||||
track.removeEventListener("mouseleave", play);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const duration = `${members.length * 2.5}s`;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="flex gap-3 sm:gap-4 w-max"
|
||||
style={{ animation: `team-marquee-left ${duration} linear infinite` }}
|
||||
>
|
||||
{members.map((m, i) => (
|
||||
<button
|
||||
key={`${m.name}-${i}`}
|
||||
onClick={() => onMemberClick(m)}
|
||||
className="group relative flex-shrink-0 w-36 sm:w-44 lg:w-52 overflow-hidden rounded-xl cursor-pointer text-left"
|
||||
>
|
||||
<div className="relative aspect-[3/4]">
|
||||
<Image
|
||||
src={m.image}
|
||||
alt={m.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 208px, (min-width: 640px) 176px, 144px"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent
|
||||
opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3
|
||||
translate-y-1 group-hover:translate-y-0
|
||||
opacity-0 group-hover:opacity-100
|
||||
transition-all duration-300">
|
||||
<p className="font-display text-xs sm:text-sm font-semibold text-white uppercase tracking-wide leading-tight">
|
||||
{m.name}
|
||||
</p>
|
||||
<p className="text-[10px] sm:text-[11px] text-gold-light/80 mt-0.5">{m.role}</p>
|
||||
</div>
|
||||
<div className="absolute inset-0 rounded-xl border border-transparent group-hover:border-gold/25 transition-colors duration-300" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,12 +33,12 @@ function ClassRow({
|
||||
}) {
|
||||
return (
|
||||
<div className="px-5 py-3.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-white/40">
|
||||
<Clock size={13} />
|
||||
<Clock size={13} className="shrink-0" />
|
||||
<span className="font-semibold">{cls.time}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
{cls.status && (() => {
|
||||
const cfg = findStatusConfig(statuses, cls.status);
|
||||
return <ScheduleBadge>{cfg?.label || cls.status}</ScheduleBadge>;
|
||||
@@ -82,7 +82,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleF
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white shadow-sm shadow-gold/[0.04] dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:shadow-none overflow-hidden">
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white shadow-sm shadow-gold/[0.04] dark:border-white/[0.08] dark:bg-white/[0.03] dark:backdrop-blur-md dark:shadow-none overflow-hidden">
|
||||
{/* Day header */}
|
||||
<div className="border-b border-neutral-100 bg-neutral-50 px-5 py-4 dark:border-white/[0.04] dark:bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { TeamProfile } from "./TeamProfile";
|
||||
import type { SiteContent, ScheduleLocation, TeamMember } from "@/types/content";
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────────────── */
|
||||
|
||||
interface TeamGridProps {
|
||||
data: SiteContent["team"];
|
||||
schedule?: ScheduleLocation[];
|
||||
scheduleConfig?: SiteContent["scheduleConfig"];
|
||||
}
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────────── */
|
||||
|
||||
function extractStyles(members: TeamMember[]): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const m of members) {
|
||||
for (const part of m.role.split(" · ")) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed) set.add(trimmed);
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
function toColumns<T>(items: T[], count: number): T[][] {
|
||||
const cols: T[][] = Array.from({ length: count }, () => []);
|
||||
items.forEach((item, i) => cols[i % count].push(item));
|
||||
return cols;
|
||||
}
|
||||
|
||||
function useColumnCount(ref: React.RefObject<HTMLDivElement | null>): number {
|
||||
const [cols, setCols] = useState(4);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
function calc() {
|
||||
const w = el!.clientWidth;
|
||||
if (w >= 1200) setCols(5);
|
||||
else if (w >= 900) setCols(4);
|
||||
else if (w >= 600) setCols(3);
|
||||
else setCols(2);
|
||||
}
|
||||
|
||||
calc();
|
||||
const ro = new ResizeObserver(calc);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [ref]);
|
||||
|
||||
return cols;
|
||||
}
|
||||
|
||||
/* ── Main Component ────────────────────────────────────────────────── */
|
||||
|
||||
export function TeamGrid({ data, schedule, scheduleConfig }: TeamGridProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeStyle, setActiveStyle] = useState<string | null>(null);
|
||||
const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const columnCount = useColumnCount(gridRef);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const members = data?.members ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const trainerName = searchParams.get("trainer");
|
||||
if (trainerName) {
|
||||
const found = members.find((m) => m.name === trainerName);
|
||||
if (found) setSelectedMember(found);
|
||||
}
|
||||
}, [searchParams, members]);
|
||||
|
||||
const styles = useMemo(() => extractStyles(members), [members]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return members.filter((m) => {
|
||||
if (q && !m.name.toLowerCase().includes(q)) return false;
|
||||
if (activeStyle && !m.role.split(" · ").map((s) => s.trim()).includes(activeStyle)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [members, search, activeStyle]);
|
||||
|
||||
const columns = useMemo(() => toColumns(filtered, columnCount), [filtered, columnCount]);
|
||||
|
||||
const openProfile = useCallback((member: TeamMember) => {
|
||||
setSelectedMember(member);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const closeProfile = useCallback(() => setSelectedMember(null), []);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSearch("");
|
||||
setActiveStyle(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-neutral-50 dark:bg-[#050505]">
|
||||
{/* Profile view — shown on top, grid stays mounted but hidden */}
|
||||
{selectedMember && (
|
||||
<div className="px-4 sm:px-6 lg:px-8 pt-4 pb-10">
|
||||
<TeamProfile
|
||||
member={selectedMember}
|
||||
onBack={closeProfile}
|
||||
schedule={schedule}
|
||||
scheduleConfig={scheduleConfig}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid + filters — hidden while profile is open so ResizeObserver stays alive */}
|
||||
<div className={selectedMember ? "hidden" : ""}>
|
||||
{/* Filter Bar */}
|
||||
<div className="sticky top-16 z-30 border-b border-neutral-200/50 dark:border-white/[0.04] bg-neutral-50/80 dark:bg-[#050505]/80 backdrop-blur-xl">
|
||||
<div className="mx-auto max-w-[1600px] px-4 sm:px-6">
|
||||
<div className="flex items-center gap-2 py-3 overflow-x-auto scrollbar-hide">
|
||||
<div className="relative flex-shrink-0">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск…"
|
||||
className="w-32 sm:w-40 rounded-full bg-neutral-200/60 pl-8 pr-3 py-1.5 text-sm text-neutral-800 placeholder-neutral-400 outline-none transition-all
|
||||
focus:w-48 focus:bg-neutral-200
|
||||
dark:bg-white/[0.06] dark:text-white dark:placeholder-neutral-500
|
||||
dark:focus:bg-white/[0.1]"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 dark:hover:text-white cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-white/[0.08] flex-shrink-0" />
|
||||
|
||||
<Chip active={!activeStyle} onClick={() => setActiveStyle(null)}>Все</Chip>
|
||||
{styles.map((s) => (
|
||||
<Chip
|
||||
key={s}
|
||||
active={activeStyle === s}
|
||||
onClick={() => setActiveStyle(activeStyle === s ? null : s)}
|
||||
>
|
||||
{s}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Masonry Grid */}
|
||||
<div ref={gridRef} className="mx-auto max-w-[1600px] px-2 sm:px-4 py-4">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<p className="text-neutral-400 dark:text-neutral-500 text-sm mb-4">Тренеры не найдены</p>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="rounded-full border border-gold/40 px-5 py-2 text-xs font-medium text-gold hover:bg-gold/10 transition-colors cursor-pointer"
|
||||
>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-1.5 sm:gap-2">
|
||||
{columns.map((col, ci) => (
|
||||
<div key={ci} className="flex-1 flex flex-col gap-1.5 sm:gap-2">
|
||||
{col.map((member, mi) => (
|
||||
<PinCard
|
||||
key={member.name}
|
||||
member={member}
|
||||
index={ci * 100 + mi}
|
||||
onClick={openProfile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Filter Chip ───────────────────────────────────────────────────── */
|
||||
|
||||
function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"flex-shrink-0 rounded-full px-3.5 py-1.5 text-xs font-medium transition-all duration-200 cursor-pointer whitespace-nowrap",
|
||||
active
|
||||
? "bg-gold text-black shadow-[0_0_12px_rgba(201,169,110,0.25)]"
|
||||
: "bg-neutral-200/60 text-neutral-600 hover:bg-neutral-300/60 dark:bg-white/[0.06] dark:text-neutral-400 dark:hover:bg-white/[0.1] dark:hover:text-white",
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Pin Card ──────────────────────────────────────────────────────── */
|
||||
|
||||
function PinCard({ member, index, onClick }: { member: TeamMember; index: number; onClick: (m: TeamMember) => void }) {
|
||||
const delay = Math.min((index % 100) * 50, 400);
|
||||
|
||||
return (
|
||||
<article
|
||||
onClick={() => onClick(member)}
|
||||
className="group relative overflow-hidden rounded-xl cursor-pointer"
|
||||
style={{
|
||||
animation: `team-grid-card-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) ${delay}ms both`,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={member.image}
|
||||
alt={member.name}
|
||||
width={400}
|
||||
height={600}
|
||||
sizes="(min-width: 1200px) 20vw, (min-width: 900px) 25vw, (min-width: 600px) 33vw, 50vw"
|
||||
className="w-full h-auto block rounded-xl transition-transform duration-500 ease-out group-hover:scale-[1.03]"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 rounded-xl bg-gradient-to-t from-black/80 via-black/10 to-transparent
|
||||
opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 sm:p-4
|
||||
translate-y-2 group-hover:translate-y-0
|
||||
opacity-0 group-hover:opacity-100
|
||||
transition-all duration-300 ease-out">
|
||||
<p className="font-display text-sm sm:text-base font-semibold text-white leading-tight tracking-wide uppercase">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] sm:text-xs text-gold-light/80 leading-snug">
|
||||
{member.role}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 rounded-xl border border-transparent group-hover:border-gold/20 transition-colors duration-300 pointer-events-none" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||
|
||||
interface ModalBaseProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
ariaLabel: string;
|
||||
maxWidth?: string;
|
||||
children: React.ReactNode;
|
||||
/** Hide the default close button (e.g. when content has its own) */
|
||||
hideClose?: boolean;
|
||||
}
|
||||
|
||||
export function ModalBase({ open, onClose, ariaLabel, maxWidth = "max-w-md", children, hideClose }: ModalBaseProps) {
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div
|
||||
ref={focusTrapRef}
|
||||
className={`modal-content relative w-full ${maxWidth} rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-neutral-950 p-6 sm:p-8 shadow-2xl`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!hideClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full text-neutral-500 dark:text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,12 @@ export function SectionHeading({ children, className = "", centered = false }: S
|
||||
return (
|
||||
<div className={centered ? "text-center" : ""}>
|
||||
<h2
|
||||
className={`font-display text-4xl font-bold uppercase tracking-wide sm:text-5xl lg:text-6xl gradient-text ${className}`}
|
||||
className={`font-display text-4xl font-bold uppercase tracking-wider sm:text-5xl lg:text-7xl text-gold ${className}`}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
<span
|
||||
className={`mt-4 block h-[1px] w-20 bg-gradient-to-r from-gold to-transparent ${
|
||||
className={`mt-5 block h-[1px] w-24 bg-gradient-to-r from-gold via-gold/40 to-transparent ${
|
||||
centered ? "mx-auto" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
interface TabButtonProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const activeClass = "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]";
|
||||
const inactiveClass =
|
||||
"border border-neutral-300 text-neutral-600 hover:border-neutral-400 hover:text-neutral-800 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20";
|
||||
|
||||
export function TabButton({ active, onClick, children, className = "" }: TabButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
|
||||
active ? activeClass : inactiveClass
|
||||
} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -20,15 +20,32 @@ function cleanAddress(addr: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function useIsDark() {
|
||||
const [dark, setDark] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
function check() {
|
||||
setDark(document.documentElement.classList.contains("dark"));
|
||||
}
|
||||
check();
|
||||
|
||||
const observer = new MutationObserver(check);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return dark;
|
||||
}
|
||||
|
||||
export function YandexMap({ addresses, height = 380 }: YandexMapProps) {
|
||||
const [mapSrc, setMapSrc] = useState<string | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState<string | null>(null);
|
||||
const isDark = useIsDark();
|
||||
|
||||
useEffect(() => {
|
||||
if (!addresses.length) return;
|
||||
let cancelled = false;
|
||||
|
||||
async function build() {
|
||||
// Geocode all addresses in parallel
|
||||
const results = await Promise.allSettled(
|
||||
addresses.map(async (addr) => {
|
||||
const cleaned = cleanAddress(addr);
|
||||
@@ -55,9 +72,9 @@ export function YandexMap({ addresses, height = 380 }: YandexMapProps) {
|
||||
const centerLat = points.reduce((s, p) => s + p.lat, 0) / points.length;
|
||||
const centerLon = points.reduce((s, p) => s + p.lon, 0) / points.length;
|
||||
const zoom = points.length === 1 ? 15 : 12;
|
||||
|
||||
const pts = points.map((p) => `${p.lon},${p.lat},pm2ntl`).join("~");
|
||||
setMapSrc(`https://yandex.ru/map-widget/v1/?ll=${centerLon},${centerLat}&z=${zoom}&pt=${pts}&l=map&theme=dark`);
|
||||
|
||||
setBaseUrl(`https://yandex.ru/map-widget/v1/?ll=${centerLon},${centerLat}&z=${zoom}&pt=${pts}&l=map`);
|
||||
}
|
||||
|
||||
build();
|
||||
@@ -66,7 +83,7 @@ export function YandexMap({ addresses, height = 380 }: YandexMapProps) {
|
||||
|
||||
if (!addresses.length) return null;
|
||||
|
||||
if (!mapSrc) {
|
||||
if (!baseUrl) {
|
||||
return (
|
||||
<div style={{ width: "100%", height }} className="flex items-center justify-center text-neutral-500 text-sm">
|
||||
Загрузка карты...
|
||||
@@ -74,8 +91,11 @@ export function YandexMap({ addresses, height = 380 }: YandexMapProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const mapSrc = `${baseUrl}&theme=${isDark ? "dark" : "light"}`;
|
||||
|
||||
return (
|
||||
<iframe
|
||||
key={mapSrc}
|
||||
src={mapSrc}
|
||||
width="100%"
|
||||
height={height}
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
import type { SiteContent } from "@/types";
|
||||
|
||||
export const siteContent: SiteContent = {
|
||||
meta: {
|
||||
title: "BLACK HEART DANCE HOUSE | Школа танцев",
|
||||
description:
|
||||
"Школа танцев BLACK HEART DANCE HOUSE — pole dance, exotic, body plastic и другие направления в Минске",
|
||||
},
|
||||
hero: {
|
||||
headline: "BLACK HEART DANCE HOUSE",
|
||||
subheadline:
|
||||
"Открой для себя яркий, завораживающий и незабываемый мир танцев на пилоне!",
|
||||
ctaText: "Записаться",
|
||||
ctaHref: "#contact",
|
||||
videos: ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"],
|
||||
},
|
||||
about: {
|
||||
title: "О нас",
|
||||
paragraphs: [
|
||||
"Топовые тренеры, стильные залы, чувственные хореографии и мощная спортивная подготовка.",
|
||||
"Обучаем с нуля до профи!",
|
||||
],
|
||||
},
|
||||
team: {
|
||||
title: "Настоящие профи!",
|
||||
members: [
|
||||
{
|
||||
name: "Виктор Артёмов",
|
||||
role: "Pole Fitness · Exotic · Strip",
|
||||
image: "/images/team/viktor-artyomov.webp",
|
||||
instagram: "https://instagram.com/viktor.artyomov/",
|
||||
description:
|
||||
"Я тренер со специальной методикой для подготовки учеников в Pole Fitness, Pole Exotic и Strip хореографии. Научу вас базовым стойкам, перекатам, а также более сложным комбинациям и трюкам. В спорте более 30 лет — спортивная гимнастика, тайский бокс, артистическая деятельность. Призёр внутренних и международных чемпионатов по пилону и фитнесу. Судья чемпионатов по пилону и танцам. Основатель студии Black Heart Dance House.",
|
||||
},
|
||||
{
|
||||
name: "Анна Тарыба",
|
||||
role: "Exotic Pole Dance",
|
||||
image: "/images/team/anna-taryba.webp",
|
||||
instagram: "https://instagram.com/annataryba/",
|
||||
description:
|
||||
"Мощь и сила в каждой связке. Мои акцентные хореографии созданы для продвинутого уровня, где вы сможете раскрыть свой потенциал и почувствовать себя настоящей королевой танца. Готовьтесь к интенсивному погружению в мир уверенных движений и сложных элементов, где каждое занятие — это новый вызов и триумф!",
|
||||
},
|
||||
{
|
||||
name: "Анастасия Чалей",
|
||||
role: "Exotic Pole Dance",
|
||||
image: "/images/team/anastasia-chaley.webp",
|
||||
instagram: "https://instagram.com/nastya_chaley/",
|
||||
description:
|
||||
"Вас ждут креативные хореографии, акцент на музыкальность и подачу, развитие уверенности и раскрытие вашей индивидуальности. Присоединяйтесь к тренировкам, где царит атмосфера радости и танцевального вдохновения! Мой вайб — «танцы — это радость».",
|
||||
},
|
||||
{
|
||||
name: "Ольга Демидова",
|
||||
role: "Pole Dance",
|
||||
image: "/images/team/olga-demidova.webp",
|
||||
instagram: "https://instagram.com/don_olga_red/",
|
||||
description:
|
||||
"Я вдохновляющий лидер, который открывает двери в мир удивительного Pole Dance. С каждым занятием помогаю своим ученикам преодолевать собственные границы и достигать результатов, которые казались недостижимыми.",
|
||||
},
|
||||
{
|
||||
name: "Ирина Третьякович",
|
||||
role: "Exotic Pole Dance",
|
||||
image: "/images/team/irina-tretyukovich.webp",
|
||||
instagram: "https://instagram.com/irkatretya/",
|
||||
description:
|
||||
"Вас ждёт калейдоскоп эмоций: от сексуальной связки до нежной лирики и даже мистического драйва. Мои хореографии всегда энергичны и непредсказуемы, пробуждают самые смелые ваши стороны. Приготовьтесь к скоростному погружению в мир танца, где каждое движение — это вызов и откровение!",
|
||||
},
|
||||
{
|
||||
name: "Надежда Сыч",
|
||||
role: "Exotic Pole Dance · Body Plastic",
|
||||
image: "/images/team/nadezhda-sukh.webp",
|
||||
instagram: "https://instagram.com/nadja.dance/",
|
||||
description:
|
||||
"Со мной вы научитесь кайфовать от себя и раскрывать свою сексуальность. Помогу развить силу, баланс и пластику, а главное — почувствовать себя желанной и привлекательной.",
|
||||
},
|
||||
{
|
||||
name: "Ирина Карпусь",
|
||||
role: "Exotic Pole Dance",
|
||||
image: "/images/team/irina-karpus.webp",
|
||||
instagram: "https://instagram.com/karpus_iri/",
|
||||
description:
|
||||
"Я проводник в мир чувственного Exotic Pole Dance. Мои хореографии проникают в самое сердце, а занятия — идеальный старт для тех, кто хочет раскрыть свою женственность и уверенность в себе.",
|
||||
},
|
||||
{
|
||||
name: "Юлия Книга",
|
||||
role: "Erotic Pole Dance",
|
||||
image: "/images/team/yuliya-kniga.webp",
|
||||
instagram: "https://instagram.com/knigynzel/",
|
||||
description:
|
||||
"Я не просто инструктор, я настоящий вдохновитель и проводник в мир Erotic Pole Dance. Мои тренировки — это не просто набор упражнений, это целое искусство, в котором каждая из вас чувствует себя особенной и ценной.",
|
||||
},
|
||||
{
|
||||
name: "Алёна Чигилейчик",
|
||||
role: "Exotic Pole Dance",
|
||||
image: "/images/team/elena-chigileychik.webp",
|
||||
instagram: "https://instagram.com/alenachygi/",
|
||||
description:
|
||||
"Создаю атмосферу, где каждая деталь имеет значение. Мои занятия — это разнообразие стилей, где внимание уделяется каждому движению, а дружелюбная атмосфера помогает раскрыться и почувствовать себя уверенно.",
|
||||
},
|
||||
{
|
||||
name: "Елена Тарасевич",
|
||||
role: "Body Plastic",
|
||||
image: "/images/team/elena-tarasevic.webp",
|
||||
instagram: "https://instagram.com/cerceia/",
|
||||
description:
|
||||
"Ваш ключ к здоровому, гибкому и гармоничному телу. Знаю каждую связку, каждую клеточку вашего тела. Чувствую ваши ограничения, предугадываю ваши возможности и бережно веду вас к границам вашей гибкости.",
|
||||
},
|
||||
{
|
||||
name: "Кристина Войтович",
|
||||
role: "Exotic Pole Dance",
|
||||
image: "/images/team/kristina-voytovich.webp",
|
||||
instagram: "https://instagram.com/chris_voytovich/",
|
||||
description:
|
||||
"В моих танцах кипит безумная смесь силы и чувственности. Обожаю переключаться между разными хореографиями: чувственными, дерзкими, меланхоличными, сексуальными... Каждая из них — это взрыв эмоций.",
|
||||
},
|
||||
{
|
||||
name: "Екатерина Матлахова",
|
||||
role: "Exotic · Pole Dance",
|
||||
image: "/images/team/ekaterina-matlakhova.webp",
|
||||
description:
|
||||
"Создаю чувственные хореографии, где женственность расцветает в сексуальных движениях, изящных линиях и плавных переходах, подкреплённых эстетичными силовыми элементами. В моих танцах рождаются богини!",
|
||||
},
|
||||
{
|
||||
name: "Лилия Огурцова",
|
||||
role: "Exotic · Pole Dance",
|
||||
image: "/images/team/liliya-ogurtsova.webp",
|
||||
description:
|
||||
"Я проведу вас в мир акцентных и чарующих хореографий. Мои занятия наполнены мистическим вайбом, драйвом и энергией. Уделяю особое внимание развитию силы, прокачке тела и чистоте движений, а также эмоциональной подаче в танце.",
|
||||
},
|
||||
{
|
||||
name: "Наталья Анцух",
|
||||
role: "Exotic Pole Dance",
|
||||
image: "/images/team/natalya-antsukh.webp",
|
||||
description:
|
||||
"Каждое занятие — это праздник для тела и души, где стиль, грация и внутренняя сила объединяются воедино. Новичок или профессионал — я научу вас танцевать с уверенностью, раскрывать свою женственность и получать удовольствие от каждого движения.",
|
||||
},
|
||||
{
|
||||
name: "Яна Артюкевич",
|
||||
role: "Pole Dance",
|
||||
image: "/images/team/yana-artyukevich.webp",
|
||||
description:
|
||||
"На моих занятиях вы научитесь красиво и уверенно владеть своим телом, освоите базовые трюки и элементы на пилоне — шаг за шагом, в уютной и вдохновляющей атмосфере. Укрепим мышцы, улучшим растяжку и осанку, а в процессе — почувствуете невероятную уверенность, сексуальность и внутреннюю силу.",
|
||||
},
|
||||
{
|
||||
name: "Анжела Бобко",
|
||||
role: "Pole Dance",
|
||||
image: "/images/team/anzhela-bobko.webp",
|
||||
description:
|
||||
"Мой индивидуальный подход и внимательное отношение к каждому ученику создают атмосферу доверия и поддержки. Со мной вы не просто осваиваете технику — вы преодолеваете себя и становитесь лучшей версией себя.",
|
||||
},
|
||||
],
|
||||
},
|
||||
classes: {
|
||||
title: "Направления",
|
||||
items: [
|
||||
{
|
||||
name: "Exotic Pole Dance",
|
||||
description:
|
||||
"Чувственная хореография с элементами pole dance в каблуках.",
|
||||
icon: "sparkles",
|
||||
detailedDescription:
|
||||
"Стиль танца на пилоне, где акцент делается на чувственность, пластику. В Exotic Pole Dance используется обувь на высоких каблуках (стрипы), развивающий гибкость, силу, женственность и уверенность.\n\nВы получаете:\n— уверенность в себе,\n— красивую фигуру и развитие всех групп мышц,\n— раскрытие себя с новой стороны,\n— вы учитесь наслаждаться собой.",
|
||||
images: ["/images/classes/exot.webp", "/images/classes/exot-w.webp"],
|
||||
},
|
||||
{
|
||||
name: "Pole Dance",
|
||||
description:
|
||||
"Искусство на пилоне: акробатические трюки, силовые элементы и грация.",
|
||||
icon: "flame",
|
||||
detailedDescription:
|
||||
"Вид искусства на пилоне, включающий акробатические трюки, силовые элементы и грациозные движения. Подходит для развития силы, выносливости и уровня технического мастерства.\n\nВы получите:\n— силу и грацию,\n— прекрасную растяжку,\n— правильную осанку,\n— прекрасное настроение.",
|
||||
images: ["/images/classes/pole-dance.webp"],
|
||||
},
|
||||
{
|
||||
name: "Body Plastic",
|
||||
description:
|
||||
"Пластичность, гибкость и осознанность тела в каждом движении.",
|
||||
icon: "wind",
|
||||
detailedDescription:
|
||||
"Тренировка, направленная на пластичность, гибкость и осознанность всего тела, помогает лучше управлять своим движением. Body Plastic объединяет растяжку, силу, контроль и пластичность, что помогает развивать тело гармонично и быстро.\n\nВместо односторонней растяжки он учит не только растягиваться, но и сохранять баланс, управлять каждым движением, что особенно важно для pole dance, акробатики и других тренировок.",
|
||||
images: ["/images/classes/body-plastic.webp"],
|
||||
},
|
||||
{
|
||||
name: "Трюковые комбинации с пилоном",
|
||||
description:
|
||||
"Яркие трюки, акробатические элементы и впечатляющие комбинации.",
|
||||
icon: "zap",
|
||||
detailedDescription:
|
||||
"Направление с акцентом на выполнение трюков, акробатических элементов и их комбинаций. Идеально подходит для тех, кто хочет освоить яркие, эффектные трюки и создать впечатляющие комбинации для выступлений и личного развития.",
|
||||
images: ["/images/classes/parter-1.webp", "/images/classes/parter-2.webp"],
|
||||
},
|
||||
{
|
||||
name: "Мастер классы",
|
||||
description:
|
||||
"Уникальные занятия с приглашёнными топовыми тренерами.",
|
||||
icon: "star",
|
||||
detailedDescription:
|
||||
"Мастер-классы — это уникальная возможность погрузиться в чувственный мир танца, где каждое движение наполнено грацией и страстью. Наши мастер-классы созданы для тех, кто хочет открыть в себе новые грани женственности и научиться выражать свои эмоции через танец.\n\nПриходя на наши мастер-классы, вы получите:\n— уверенность в себе и своих возможностях,\n— возможность раскрыть свою чувственность и сексуальность,\n— умение наслаждаться каждым моментом и каждым движением,\n— опыт от профессиональных тренеров.",
|
||||
images: ["/images/classes/master-class-1.webp", "/images/classes/master-class-2.webp", "/images/classes/master-class-3.webp"],
|
||||
},
|
||||
{
|
||||
name: "Онлайн занятия",
|
||||
description: "Тренировки в удобное время из любой точки мира.",
|
||||
icon: "monitor",
|
||||
detailedDescription:
|
||||
"Если вы находитесь не в Минске, у вас всё равно есть уникальная возможность тренироваться, расти и развиваться с нами! Мы предлагаем занятия онлайн по следующим направлениям: партерная акробатика, Pole Dance, Exotic Pole Dance, Exo-tricks, полёты.\n\nМы предлагаем два способа работы: самостоятельный и VIP. В самостоятельный тариф входит доступ к видеозаписям уроков по выбранному направлению, в VIP-тарифе вы также получите доступ к чату с куратором в Telegram.",
|
||||
images: ["/images/classes/online-classes.webp"],
|
||||
},
|
||||
],
|
||||
},
|
||||
faq: {
|
||||
title: "Частые вопросы",
|
||||
items: [
|
||||
{
|
||||
question: "Что такое Exotic Pole Dance, Pole Dance и Body Plastic?",
|
||||
answer:
|
||||
"Exotic Pole Dance — стиль танца на пилоне, где акцент делается на чувственность, пластику. Используется обувь на высоких каблуках (стрипы), развивающий гибкость, силу, женственность и уверенность.\n\nPole Dance — вид искусства на пилоне, включающий акробатические трюки, силовые элементы и грациозные движения. Подходит для развития силы, выносливости и технического мастерства.\n\nBody Plastic — тренировка, направленная на пластичность, гибкость и осознанность всего тела, помогает лучше управлять своим движением.",
|
||||
},
|
||||
{
|
||||
question: "Нужно ли иметь специальную подготовку, чтобы начать заниматься?",
|
||||
answer:
|
||||
"Нет, специальная подготовка не требуется. Уровень физической подготовки будет расти постепенно в процессе тренировок. Важно иметь желание и готовность к обучению.",
|
||||
},
|
||||
{
|
||||
question: "Какая одежда нужна для занятий?",
|
||||
answer:
|
||||
"Pole Dance: важны шорты и топ, чтобы кожа на бёдрах и животе соприкасалась с пилоном для сцепления.\n\nExotic Pole Dance: на начальных этапах лучше шорты, можно леггинсы, топ/лиф, наколенники и желательно стрипы. На начальном этапе можно начинать без стрипов в носочках.",
|
||||
},
|
||||
{
|
||||
question: "Какие группы по уровню существуют в вашей студии?",
|
||||
answer:
|
||||
"У нас есть группы для начинающих — «С нуля», где вы можете освоить базовые движения и технику. Также есть группы для продолжающих и для любого уровня подготовки — чтобы все могли развиваться и совершенствоваться в приятной и поддерживающей атмосфере.",
|
||||
},
|
||||
{
|
||||
question: "Можно ли начать заниматься Exotic Pole Dance в любом возрасте?",
|
||||
answer:
|
||||
"Да, конечно! Возраст не имеет значения — этот вид спорта подходит для всех желающих развивать силу, гибкость и уверенность в себе. Единственное ограничение — от 18 лет.",
|
||||
},
|
||||
{
|
||||
question: "Я чувствую себя скованно. Как раскрепоститься на тренировках Exotic Pole Dance?",
|
||||
answer:
|
||||
"Exotic Pole Dance — это про самовыражение и принятие себя. Не бойтесь проявлять свои эмоции, экспериментировать с движениями. Постепенно вы почувствуете себя увереннее и свободнее. Наши тренеры создают на занятиях комфортную и поддерживающую атмосферу.",
|
||||
},
|
||||
{
|
||||
question: "Как быстро я смогу делать трюки на пилоне?",
|
||||
answer:
|
||||
"Это индивидуально и зависит от вашей физической подготовки, регулярности тренировок и способностей к обучению. Первые простые трюки обычно осваиваются в течение нескольких недель.",
|
||||
},
|
||||
{
|
||||
question: "Body Plastic — это растяжка?",
|
||||
answer:
|
||||
"Body Plastic — это не только про растяжку. Body Plastic объединяет растяжку, силу, контроль и пластичность, что помогает развивать тело гармонично и быстро. Вместо односторонней растяжки он учит не только растягиваться, но и сохранять баланс, управлять каждым движением, что особенно важно для pole dance, акробатики и других тренировок.",
|
||||
},
|
||||
{
|
||||
question: "Что включает направление «Трюковые комбинации с пилоном»?",
|
||||
answer:
|
||||
"Трюковые комбинации с пилоном — это направление с акцентом на выполнение трюков, акробатических элементов и их комбинаций. Это направление идеально подходит для тех, кто хочет освоить яркие, эффектные трюки и создать впечатляющие комбинации для выступлений и личного развития.",
|
||||
},
|
||||
{
|
||||
question: "Сколько раз в неделю нужно заниматься?",
|
||||
answer:
|
||||
"Для новичков рекомендуется начинать с 2–3 раз в неделю. По мере развития физической формы и навыков можно увеличивать количество тренировок.",
|
||||
},
|
||||
{
|
||||
question: "Участие в чемпионатах: обязательно ли это?",
|
||||
answer:
|
||||
"Нет, участие в чемпионатах — это не обязательно. Это скорее вопрос вашего личного желания и готовности. Если вы чувствуете в себе силы, мотивацию и хотите попробовать что-то новое, то не стесняйтесь сообщить об этом своему тренеру! Он поможет оценить ваши возможности и подготовиться к чемпионату наилучшим образом.",
|
||||
},
|
||||
],
|
||||
},
|
||||
pricing: {
|
||||
title: "Стоимость",
|
||||
subtitle: "Все абонементы идут с привязкой к группе, кроме безлимитного",
|
||||
items: [
|
||||
{ name: "Абонемент 8 × 90 мин", price: "175 BYN" },
|
||||
{ name: "Абонемент 4 × 90 мин", price: "105 BYN" },
|
||||
{ name: "Абонемент 8 × 60 мин", price: "145 BYN" },
|
||||
{ name: "Абонемент 4 × 60 мин", price: "105 BYN" },
|
||||
{ name: "Разовое занятие 1,5 часа", price: "30 BYN" },
|
||||
{ name: "Разовое занятие 1 час", price: "25 BYN" },
|
||||
{ name: "Пробное занятие", price: "25 BYN", note: "1,5 часа или 1 час" },
|
||||
{
|
||||
name: "Безлимитный абонемент",
|
||||
price: "240 / 410 BYN",
|
||||
note: "2 недели / месяц (обязательна предварительная запись)",
|
||||
},
|
||||
],
|
||||
rentalTitle: "Аренда зала",
|
||||
rentalItems: [
|
||||
{ name: "С абонементом", price: "20 BYN", note: "+5 BYN за каждого доп. человека" },
|
||||
{
|
||||
name: "Без абонемента (Машерова 17/4, 6 этаж + Притыцкого 62/М)",
|
||||
price: "35 BYN",
|
||||
note: "+5 BYN за каждого доп. человека",
|
||||
},
|
||||
{
|
||||
name: "Без абонемента (Машерова 17/4, 2 этаж)",
|
||||
price: "25 BYN",
|
||||
note: "+5 BYN за каждого доп. человека",
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
"Абонемент является персональным и не подлежит передаче другим лицам.",
|
||||
"Абонемент необходимо предъявлять администратору перед каждым занятием.",
|
||||
"Оплата абонементов и разовых посещений производится до начала занятия.",
|
||||
"Компенсация за пропущенные занятия не предусмотрена.",
|
||||
"Срок действия абонемента — 4 недели.",
|
||||
"Абонемент можно заморозить не более двух раз в год на срок до 2 недель (на время отпуска или командировки).",
|
||||
"В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.",
|
||||
],
|
||||
},
|
||||
masterClasses: {
|
||||
title: "Мастер-классы",
|
||||
items: [],
|
||||
},
|
||||
popups: {
|
||||
successMessage: "Вы записаны!",
|
||||
waitingListText: "Все места заняты, но мы добавили вас в лист ожидания.\nЕсли кто-то откажется — мы предложим место вам.",
|
||||
errorMessage: "Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!",
|
||||
instagramHint: "По вопросам пишите в Instagram",
|
||||
},
|
||||
schedule: {
|
||||
title: "Расписание",
|
||||
locations: [
|
||||
{
|
||||
name: "Притыцкого 62/М",
|
||||
address: "г. Минск, Притыцкого, 62/М",
|
||||
days: [
|
||||
{
|
||||
day: "Понедельник",
|
||||
dayShort: "ПН",
|
||||
classes: [
|
||||
{ time: "11:00–12:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||
{ time: "18:00–19:30", trainer: "Надежда Сыч", type: "Exotic Pole Dance" },
|
||||
{ time: "19:30–21:00", trainer: "Екатерина Матлахова", type: "Exotic Pole Dance" },
|
||||
{ time: "21:00–22:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Вторник",
|
||||
dayShort: "ВТ",
|
||||
classes: [
|
||||
{ time: "10:00–11:30", trainer: "Анжела Бобко", type: "Pole Dance", recruiting: true },
|
||||
{ time: "18:00–19:30", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||
{ time: "19:30–21:00", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||
{ time: "21:00–22:30", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Среда",
|
||||
dayShort: "СР",
|
||||
classes: [
|
||||
{ time: "18:30–20:00", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном", level: "Продвинутый" },
|
||||
{ time: "20:00–21:30", trainer: "Алёна Чигилейчик", type: "Exotic Pole Dance" },
|
||||
{ time: "21:30–22:30", trainer: "Алёна Чигилейчик", type: "Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Четверг",
|
||||
dayShort: "ЧТ",
|
||||
classes: [
|
||||
{ time: "11:00–12:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||
{ time: "18:00–19:30", trainer: "Надежда Сыч", type: "Exotic Pole Dance" },
|
||||
{ time: "19:30–21:00", trainer: "Екатерина Матлахова", type: "Exotic Pole Dance" },
|
||||
{ time: "21:00–22:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Пятница",
|
||||
dayShort: "ПТ",
|
||||
classes: [
|
||||
{ time: "10:00–11:30", trainer: "Анжела Бобко", type: "Pole Dance", recruiting: true },
|
||||
{ time: "18:00–19:30", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||
{ time: "19:30–21:00", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||
{ time: "21:00–22:30", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Суббота",
|
||||
dayShort: "СБ",
|
||||
classes: [
|
||||
{ time: "14:00–15:00", trainer: "Алёна Чигилейчик", type: "Pole Dance" },
|
||||
{ time: "15:00–16:30", trainer: "Алёна Чигилейчик", type: "Exotic Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Воскресенье",
|
||||
dayShort: "ВС",
|
||||
classes: [
|
||||
{ time: "12:00–13:30", trainer: "Кристина Войтович", type: "Body Plastic" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Машерова 17/4",
|
||||
address: "г. Минск, Машерова, 17/4",
|
||||
days: [
|
||||
{
|
||||
day: "Понедельник",
|
||||
dayShort: "ПН",
|
||||
classes: [
|
||||
{ time: "18:00–19:00", trainer: "Ирина Карпусь", type: "Exotic Pole Dance" },
|
||||
{ time: "19:00–20:30", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||
{ time: "20:30–22:00", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Вторник",
|
||||
dayShort: "ВТ",
|
||||
classes: [
|
||||
{ time: "18:30–20:00", trainer: "Анастасия Чалей", type: "Exotic Pole Dance" },
|
||||
{ time: "21:30–23:00", trainer: "Лилия Огурцова", type: "Exotic Pole Dance", hasSlots: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Среда",
|
||||
dayShort: "СР",
|
||||
classes: [
|
||||
{ time: "18:00–19:30", trainer: "Ольга Демидова", type: "Pole Dance" },
|
||||
{ time: "19:30–21:00", trainer: "Ольга Демидова", type: "Body Plastic" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Четверг",
|
||||
dayShort: "ЧТ",
|
||||
classes: [
|
||||
{ time: "18:00–19:00", trainer: "Ирина Карпусь", type: "Exotic Pole Dance" },
|
||||
{ time: "19:00–20:30", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||
{ time: "20:30–22:00", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Пятница",
|
||||
dayShort: "ПТ",
|
||||
classes: [
|
||||
{ time: "18:30–20:00", trainer: "Анастасия Чалей", type: "Exotic Pole Dance" },
|
||||
{ time: "21:30–23:00", trainer: "Лилия Огурцова", type: "Exotic Pole Dance", hasSlots: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Суббота",
|
||||
dayShort: "СБ",
|
||||
classes: [
|
||||
{ time: "10:30–12:00", trainer: "Елена Тарасевич", type: "Body Plastic" },
|
||||
{ time: "12:00–13:30", trainer: "Ольга Демидова", type: "Pole Dance" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
scheduleConfig: {
|
||||
levels: [
|
||||
{ value: "Начинающий/Без опыта", description: "Для тех, кто только начинает заниматься" },
|
||||
{ value: "Продвинутый", description: "Для учеников с опытом от 6 месяцев" },
|
||||
],
|
||||
statuses: [
|
||||
{ key: "hasSlots", label: "Есть места", description: "В группе есть свободные места" },
|
||||
{ key: "recruiting", label: "Набор открыт", description: "Идёт набор в новую группу" },
|
||||
],
|
||||
},
|
||||
news: {
|
||||
title: "Новости",
|
||||
items: [],
|
||||
},
|
||||
contact: {
|
||||
title: "Контакты",
|
||||
addresses: [
|
||||
"г. Минск, Машерова, 17/4",
|
||||
"г. Минск, Притыцкого, 62/М",
|
||||
],
|
||||
phone: "+375 29 389-70-01",
|
||||
instagram: "https://instagram.com/blackheartdancehouse/",
|
||||
mapEmbedUrl:
|
||||
"https://yandex.ru/map-widget/v1/?ll=27.512%2C53.912&z=12&l=map&pt=27.5656%2C53.91583%2Cpm2rdm~27.45974%2C53.90832%2Cpm2rdm",
|
||||
workingHours: "Пн — Сб: 10:00 — 22:00",
|
||||
},
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Seed script — populates the SQLite database from content.ts
|
||||
* Run: npx tsx src/data/seed.ts
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import { siteContent } from "./content";
|
||||
|
||||
const DB_PATH =
|
||||
process.env.DATABASE_PATH ||
|
||||
path.join(process.cwd(), "db", "blackheart.db");
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
||||
// Create tables
|
||||
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'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Seed sections (team members go in their own table)
|
||||
const sectionData: Record<string, unknown> = {
|
||||
meta: siteContent.meta,
|
||||
hero: siteContent.hero,
|
||||
about: siteContent.about,
|
||||
classes: siteContent.classes,
|
||||
masterClasses: siteContent.masterClasses,
|
||||
faq: siteContent.faq,
|
||||
pricing: siteContent.pricing,
|
||||
schedule: siteContent.schedule,
|
||||
contact: siteContent.contact,
|
||||
};
|
||||
|
||||
// Team section stores only the title
|
||||
sectionData.team = { title: siteContent.team.title };
|
||||
|
||||
const upsertSection = 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`
|
||||
);
|
||||
|
||||
const insertMember = db.prepare(
|
||||
`INSERT INTO team_members (name, role, image, instagram, description, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
// Upsert all sections
|
||||
for (const [key, data] of Object.entries(sectionData)) {
|
||||
upsertSection.run(key, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// Clear existing team members and re-insert
|
||||
db.prepare("DELETE FROM team_members").run();
|
||||
|
||||
siteContent.team.members.forEach((m, i) => {
|
||||
insertMember.run(
|
||||
m.name,
|
||||
m.role,
|
||||
m.image,
|
||||
m.instagram ?? null,
|
||||
m.description ?? null,
|
||||
i
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
tx();
|
||||
|
||||
const sectionCount = (
|
||||
db.prepare("SELECT COUNT(*) as c FROM sections").get() as { c: number }
|
||||
).c;
|
||||
const memberCount = (
|
||||
db.prepare("SELECT COUNT(*) as c FROM team_members").get() as { c: number }
|
||||
).c;
|
||||
|
||||
console.log(`Seeded ${sectionCount} sections and ${memberCount} team members.`);
|
||||
console.log(`Database: ${DB_PATH}`);
|
||||
|
||||
db.close();
|
||||
@@ -15,7 +15,7 @@ export const NAV_LINKS: NavLink[] = [
|
||||
{ label: "Направления", href: "#classes" },
|
||||
{ label: "Команда", href: "#team" },
|
||||
{ label: "День открытых дверей", href: "#open-day" },
|
||||
{ label: "Расписание", href: "#schedule" },
|
||||
{ label: "Расписание", href: "/schedule" },
|
||||
{ label: "Стоимость", href: "#pricing" },
|
||||
{ label: "Мастер-классы", href: "#master-classes" },
|
||||
{ label: "Новости", href: "#news" },
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ function parseInline(text: string, keyPrefix: string): React.ReactNode[] {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
if (match[2]) {
|
||||
parts.push(<strong key={`${keyPrefix}-b${key++}`} className="font-semibold text-white">{match[2]}</strong>);
|
||||
parts.push(<strong key={`${keyPrefix}-b${key++}`} className="font-semibold text-neutral-900 dark:text-white">{match[2]}</strong>);
|
||||
} else if (match[3]) {
|
||||
parts.push(<em key={`${keyPrefix}-i${key++}`}>{match[3]}</em>);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function formatMarkup(text: string): React.ReactNode {
|
||||
// ## Heading
|
||||
if (trimmed.startsWith("## ")) {
|
||||
elements.push(
|
||||
<span key={key++} className="block mt-3 mb-1 text-sm font-semibold text-white">
|
||||
<span key={key++} className="block mt-3 mb-1 text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
{parseInline(trimmed.slice(3), `h${key}`)}
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user