// app.jsx — Exo Studio single-pager
/* global React, ReactDOM, DOMPurify, useTweaks, TweaksPanel, TweakSection, TweakRadio, TweakColor, TweakSlider, TweakToggle */
const { useState, useEffect, useMemo, useRef, useCallback, createContext, useContext } = React;
/* ─── Rich-text rendering (admin uses Quill → stores HTML) ───────── */
const RICH_ALLOWED_TAGS = [
"p", "br", "span", "div", "strong", "em", "u", "s", "b", "i",
"h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li",
"a", "blockquote",
];
const RICH_ALLOWED_ATTR = ["href", "target", "rel", "style", "class"];
function looksLikeHtml(s) {
return typeof s === "string" && /<\w+[\s\S]*>/.test(s);
}
// Quill 2 хранит списки как
…
// + внутри li. Без квилловского CSS это рендерится как
// нумерованный список. Конвертим в стандартные /.
function normalizeQuillHtml(html) {
if (!html || !html.includes("data-list")) return html;
const tmp = document.createElement("div");
tmp.innerHTML = html;
tmp.querySelectorAll("ol, ul").forEach((list) => {
const items = Array.from(list.children).filter((n) => n.tagName === "LI");
if (!items.length || !items.some((li) => li.hasAttribute("data-list"))) return;
const parent = list.parentNode;
let group = null;
let groupTag = null;
for (const li of items) {
const t = li.getAttribute("data-list") === "bullet" ? "ul" : "ol";
if (t !== groupTag) {
group = document.createElement(t);
parent.insertBefore(group, list);
groupTag = t;
}
li.removeAttribute("data-list");
li.querySelectorAll("span.ql-ui").forEach((s) => s.remove());
group.appendChild(li);
}
list.remove();
});
return tmp.innerHTML;
}
function sanitizeRichHtml(html) {
if (!html) return "";
const normalized = normalizeQuillHtml(html);
if (typeof DOMPurify !== "undefined") {
return DOMPurify.sanitize(normalized, {
ALLOWED_TAGS: RICH_ALLOWED_TAGS,
ALLOWED_ATTR: RICH_ALLOWED_ATTR,
ADD_ATTR: ["target", "rel"],
});
}
return normalized;
}
function RichText({ value, className, as }) {
const Tag = as || "div";
if (!value) return ;
if (looksLikeHtml(value)) {
const html = sanitizeRichHtml(value);
return ;
}
return {value} ;
}
const STUDIO_ADDRESS = "Новороссийск, проспект Ленина, 91А";
const MAPS_URL = `https://yandex.ru/maps/?text=${encodeURIComponent(STUDIO_ADDRESS)}`;
// Русские склонения: plural(4, ["направление","направления","направлений"]) → "направления"
function plural(n, forms) {
const n10 = Math.abs(n) % 10;
const n100 = Math.abs(n) % 100;
if (n100 >= 11 && n100 <= 14) return forms[2];
if (n10 === 1) return forms[0];
if (n10 >= 2 && n10 <= 4) return forms[1];
return forms[2];
}
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"palette": ["#d14a8c", "#0c0810", "#ead6df"],
"displayFont": "антиква",
"density": "средне",
"showMarquee": true
}/*EDITMODE-END*/;
const PALETTES = [
["#d14a8c", "#0c0810", "#ead6df"], // noir rose — чёрный и холодная фуксия
["#1f6b4e", "#0f1411", "#ece7da"], // emerald
["#b8362c", "#15110f", "#f1ebe1"], // bordeaux
["#1a1a1a", "#0a0908", "#e6e2d8"], // mono
];
const FONT_OPTS = {
"антиква": '"Spectral", "Cormorant Garamond", Georgia, serif',
"гарамон": '"Cormorant Garamond", "Spectral", Georgia, serif',
"классик": '"PT Serif", "Spectral", Georgia, serif',
};
/* ────────────────────────────────────────────────────────────────
DATA CONTEXT
──────────────────────────────────────────────────────────────── */
const DataCtx = createContext(null);
const useData = () => useContext(DataCtx);
/* ── ценовые хелперы ── */
const fmtRub = (n) => Number(n).toLocaleString("ru-RU");
const sesWord = (n) => {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return "занятие";
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "занятия";
return "занятий";
};
const durText = (d) => (d === 90 ? "1,5 часа" : "1 час");
// минимальная цена пробного из PB; fallback — если коллекция ещё не загрузилась
function useTrialMin(fallback = 600) {
const { PRICES } = useData();
const t = PRICES.filter((p) => p.kind === "trial").map((p) => Number(p.price)).filter(Boolean);
return t.length ? Math.min(...t) : fallback;
}
/* ────────────────────────────────────────────────────────────────
NAV
──────────────────────────────────────────────────────────────── */
function Nav() {
return (
Exo Studio
);
}
/* ────────────────────────────────────────────────────────────────
HERO
──────────────────────────────────────────────────────────────── */
function Hero({ showMarquee }) {
const trialMin = useTrialMin();
return (
Танцуйте,
как хочется
— а не как
принято.
Камерная студия растяжки и танца на пилоне — в сердце Южного района.
Мини-группы до восьми человек, к каждой — свой подход. Без ограничений
по возрасту, весу и спортивному прошлому.
📍
просп. Ленина, 91А · открыть в Яндекс.Картах
↗
Зал один, 56 м²
Пилоны четыре, спин/статик
Группы до 8 человек
Часы пн–сб · до 22:00
{showMarquee && (
Pole Exotic Strip-пластика High Heels
Pole Sport Twerk Stretching
Pole Exotic Strip-пластика High Heels
Pole Sport Twerk Stretching
)}
);
}
function HeroVideoPlayer() {
const { HERO_VIDEO } = useData();
const src = HERO_VIDEO?.url || "/hero.mp4";
return (
);
}
/* ────────────────────────────────────────────────────────────────
INFOSTRIP
──────────────────────────────────────────────────────────────── */
function InfoStrip() {
const { TRAINERS } = useData();
const trialMin = useTrialMin();
const items = [
{ n: "с 2017", l: "Работаем здесь" },
{ n: "4", l: "Пилона в зале" },
{ n: String(TRAINERS.length), l: "Тренеров" },
{ n: `от ${fmtRub(trialMin)}₽`, l: "Пробное занятие" },
];
return (
);
}
/* ────────────────────────────────────────────────────────────────
DIRECTIONS
──────────────────────────────────────────────────────────────── */
function Directions({ onPick }) {
const { DIRECTIONS } = useData();
return (
{DIRECTIONS.length === 6 ? "Шесть" : DIRECTIONS.length}
{plural(DIRECTIONS.length, ["направление", "направления", "направлений"])}.
{DIRECTIONS.length} {plural(DIRECTIONS.length, ["направление", "направления", "направлений"])}
Большинство приходят за чем-то одним — на exotic или на strip-пластику —
и так и танцуют годами. Кто-то постепенно пробует соседнее.
Жёстких связок «начни с этого, потом то» у нас нет.
{DIRECTIONS.map((d) => (
onPick?.(d.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPick?.(d.id);
}
}}
>
{d.no} · {d.sessions}
{d.name}{d.accent}
{d.levels}
↗
))}
);
}
/* ────────────────────────────────────────────────────────────────
TRAINERS + MODAL
──────────────────────────────────────────────────────────────── */
function TrainerPhoto({ trainer, large }) {
const url = large ? (trainer.photo_url_lg || trainer.photo_url) : trainer.photo_url;
if (url) {
return (
);
}
return
;
}
function Trainers({ onOpen }) {
const { TRAINERS } = useData();
return (
Кто будет
вас ловить.
В команде · {TRAINERS.length} {TRAINERS.length === 1 ? "человек" : "человека"}
У каждого тренера — свои группы, свои постановки и свой характер.
Нажмите на карточку, чтобы посмотреть профиль, расписание и записаться лично.
{TRAINERS.map((t) => (
onOpen(t)}>
{t.role}
{t.first} {t.last}
))}
);
}
function TrainerModal({ trainer, onClose }) {
useEffect(() => {
if (!trainer) return;
const h = (e) => e.key === "Escape" && onClose();
document.addEventListener("keydown", h);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", h);
document.body.style.overflow = "";
};
}, [trainer, onClose]);
return (
e.stopPropagation()}>
Профиль тренера{trainer ? ` · ${trainer.first} ${trainer.last}` : ""}
✕
{trainer && (
<>
{trainer.first}{trainer.last}
{trainer.role}
{trainer.tags.map((tag) => {tag} )}
Учениц
{trainer.stats.students}
Стиль
{trainer.stats.style}
{trainer.schedule.length > 0 && (
<>
Расписание · {trainer.first}
{trainer.schedule.map((s, i) => (
))}
>
)}
Записаться к {trainer.first} →
>
)}
);
}
/* ────────────────────────────────────────────────────────────────
SCHEDULE
──────────────────────────────────────────────────────────────── */
function Schedule() {
const { SCHEDULE } = useData();
const { days, times, sample } = SCHEDULE;
return (
Расписаниена неделю.
Пн — Сб · до 22:00
Сетка обновляется каждый понедельник. Полное расписание с бронью —
в личном кабинете и в нашем телеграм-боте.
{days.map((d) =>
{d}
)}
{times.map((time) => (
{time}
{days.map((d) => {
const k = `${time}-${d}`;
const s = sample[k];
return (
{s && (
<>
{s.name}
{s.who}
>
)}
);
})}
))}
);
}
/* ────────────────────────────────────────────────────────────────
PRICES
──────────────────────────────────────────────────────────────── */
function Prices() {
const { PRICES } = useData();
const packs = PRICES.filter((p) => p.kind === "pack");
const trials = PRICES.filter((p) => p.kind === "trial").sort((a, b) => a.duration - b.duration);
const unlims = PRICES.filter((p) => p.kind === "unlimited");
const durations = [...new Set(packs.map((p) => p.duration).filter(Boolean))].sort((a, b) => a - b);
const [durSel, setDurSel] = useState(0);
const dur = durations.includes(durSel) ? durSel : (durations[0] || 0);
const rows = packs
.filter((p) => !p.duration || p.duration === dur)
.sort((a, b) => (a.sessions - b.sessions) || (a.order - b.order));
const perSession = (p) => (p.sessions ? Math.round(Number(p.price) / p.sessions) : 0);
const best = rows.reduce((min, p) => {
const v = perSession(p);
return v && (!min || v < min) ? v : min;
}, 0);
// пункты, общие для всех абонементов, уходят одной строкой под таблицу;
// в строках остаются только различия (заморозка)
const common = rows.length
? rows[0].items.filter((it) => rows.every((r) => r.items.includes(it)))
: [];
const trialMin = useTrialMin();
return (
Сколькостоит.
в рублях, НДС включён
Оплата картой, СБП или переводом. Первый раз — пробное за {fmtRub(trialMin)} ₽,
чтобы понять, ваше или не ваше. Дальше — абонемент: чем больше занятий,
тем дешевле каждое.
{common.length > 0 && (
Во всех абонементах: {common.join(" · ").toLowerCase()}. Срок действия — 30 дней.
)}
);
}
/* ────────────────────────────────────────────────────────────────
FAQ
──────────────────────────────────────────────────────────────── */
function FaqSection() {
const { FAQ } = useData();
const [open, setOpen] = useState(0);
return (
Стесняетесь?Это нормально.
Частые вопросы · для новичков
{FAQ.length} самых частых вопросов, которые нам задают перед первым занятием.
Если вашего здесь нет — пишите в Telegram, отвечаем в течение часа.
{FAQ.map((f, i) => (
setOpen(open === i ? -1 : i)}
>
№{String(i + 1).padStart(2, "0")}
{f.q}
+
))}
);
}
/* ────────────────────────────────────────────────────────────────
BOOK FORM — реальный POST в PocketBase
──────────────────────────────────────────────────────────────── */
function BookForm({ preset, onConsumed }) {
const { DIRECTIONS, PRICES } = useData();
const trials = PRICES.filter((p) => p.kind === "trial").sort((a, b) => a.duration - b.duration);
const trialMin = useTrialMin();
const [form, setForm] = useState({
name: "", phone: "", tg: "",
direction: "", level: "Пробное",
when: "", comment: "", consent: true,
});
useEffect(() => {
if (preset) {
setForm((f) => ({ ...f, direction: preset }));
onConsumed?.();
}
}, [preset, onConsumed]);
const [errors, setErrors] = useState({});
const [state, setState] = useState("idle"); // idle | sending | done | error
const [serverErr, setServerErr] = useState("");
const [ticket, setTicket] = useState(null);
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const validate = () => {
const e = {};
if (form.name.trim().length < 2) e.name = "Как вас зовут?";
const digits = form.phone.replace(/\D/g, "");
if (digits.length < 10) e.phone = "Похоже на неполный номер";
if (!form.direction) e.direction = "Выберите направление";
if (!form.consent) e.consent = "Нужно согласие";
return e;
};
const submit = async (ev) => {
ev.preventDefault();
const e = validate();
setErrors(e);
if (Object.keys(e).length) return;
setState("sending");
setServerErr("");
const payload = {
name: form.name.trim(),
phone: form.phone.trim(),
tg: form.tg.trim(),
direction: form.direction,
level: form.level,
when: form.when.trim(),
comment: form.comment.trim(),
consent: form.consent,
status: "new",
source: "site",
meta: {
ua: navigator.userAgent,
ref: document.referrer || null,
},
};
try {
const r = await fetch("/api/collections/bookings/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.message || `HTTP ${r.status}`);
}
const j = await r.json();
setTicket(j.id ? j.id.slice(-6).toUpperCase() : "—");
setState("done");
} catch (err) {
console.error("booking submit failed:", err);
setServerErr(err.message || "Не удалось отправить");
setState("error");
}
};
if (state === "done") {
return (
Заявка №{ticket} · Принята
Готово.
До встречи на пилоне.
Мы получили заявку. Администратор Лена напишет вам в течение часа
в Telegram или позвонит — выбирайте, как удобнее. Если что-то срочное —
стучите нам в мессенджеры прямо сейчас.
Что дальше
1. Подтверждаем занятие
Лена позвонит в течение часа, согласует точное время и тренера.
2. Приходите за 15 минут
Покажем раздевалку, выдадим каблуки или подстелим коврик. Душ, фен, шампунь — на месте.
3. Танцуйте и выдыхайте
После урока — чай, разбор полетов. Если всё ок, выберете абонемент со скидкой 10%.
);
}
return (
{(() => {
const sel = DIRECTIONS.find((d) => d.id === form.direction);
const tail = sel ? ` · ${(sel.name + (sel.accent || "")).trim()}` : "";
const price = form.level === "Разовое" ? "900 ₽" : `от ${fmtRub(trialMin)} ₽`;
const kind = form.level || "Пробное";
return `Запись · ${kind} · ${price}${tail}`;
})()}
Запишитесь
на занятие.
Лена перезвонит в течение часа, подберёт время и тренера.
Пробное — {trials.length
? trials.map((t) => `${fmtRub(t.price)} ₽ (${durText(t.duration)})`).join(" или ")
: "от 600 ₽"}, разовое — 900 ₽, оплата на месте.
Платить сейчас ничего не нужно.
);
}
/* ────────────────────────────────────────────────────────────────
GALLERY
──────────────────────────────────────────────────────────────── */
function Gallery() {
const { GALLERY } = useData();
const [open, setOpen] = useState(null);
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === "Escape") setOpen(null); };
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open]);
if (!GALLERY || GALLERY.length === 0) return null;
return (
Атмосфера · {GALLERY.length} {plural(GALLERY.length, ["кадр", "кадра", "кадров"])}
Студия изнутри.
Зал, пилоны, ночной свет, зеркала. Атмосфера, ради которой возвращаются.
{GALLERY.map((g) => g.placeholder ? (
) : (
setOpen({ url: g.url_lg || g.url, caption: g.caption })}
aria-label={g.caption || "Открыть фото"}
>
{g.caption && {g.caption}
}
))}
{open && (
setOpen(null)} role="dialog" aria-modal="true">
{ e.stopPropagation(); setOpen(null); }} aria-label="Закрыть">✕
e.stopPropagation()} />
{open.caption &&
{open.caption}
}
)}
);
}
/* ────────────────────────────────────────────────────────────────
REVIEWS
──────────────────────────────────────────────────────────────── */
const REVIEW_SOURCE_LABEL = {
yandex: "Yandex Maps",
vk: "ВКонтакте",
telegram: "Telegram",
google: "Google",
"on-site": "Лично",
other: "",
};
function Reviews() {
const { REVIEWS } = useData();
if (!REVIEWS || REVIEWS.length === 0) return null;
return (
Истории тех, кто пришёл «попробовать» — и остался.
Все — реальные, без правок и фотошопа.
{REVIEWS.map((r) => {
const srcLabel = REVIEW_SOURCE_LABEL[r.source] || "";
return (
«
{r.name}
{r.rating ? (
{"★".repeat(r.rating)}{"★".repeat(5 - r.rating)}
) : null}
{srcLabel && (
{r.source_url ? (
{srcLabel} ↗
) : srcLabel}
)}
{r.date &&
{r.date} }
);
})}
);
}
/* ────────────────────────────────────────────────────────────────
FOOTER
──────────────────────────────────────────────────────────────── */
function Footer() {
return (
);
}
/* ────────────────────────────────────────────────────────────────
TWEAKS
──────────────────────────────────────────────────────────────── */
function ThemeTweaks({ t, setTweak }) {
return (
setTweak("palette", v)}
/>
setTweak("displayFont", v)}
/>
setTweak("density", v)}
/>
setTweak("showMarquee", v)}
/>
);
}
/* ────────────────────────────────────────────────────────────────
APP
──────────────────────────────────────────────────────────────── */
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [trainer, setTrainer] = useState(null);
const [presetDir, setPresetDir] = useState(null);
const pickDirection = useCallback((id) => {
setPresetDir(id);
document.getElementById("book")?.scrollIntoView({ behavior: "smooth", block: "start" });
}, []);
const consumeDirection = useCallback(() => setPresetDir(null), []);
useEffect(() => {
const r = document.documentElement;
const p = t.palette || PALETTES[0];
r.style.setProperty("--c-accent", p[0]);
r.style.setProperty("--c-bg", p[1]);
r.style.setProperty("--c-paper", p[2]);
const isNoir = p[0].toLowerCase() === "#d14a8c";
r.style.setProperty("--c-accent-2", isNoir ? "#7c3aa8" : p[0]);
r.style.setProperty("--f-display", FONT_OPTS[t.displayFont] || FONT_OPTS["антиква"]);
document.body.dataset.density = t.density || "средне";
}, [t.palette, t.displayFont, t.density]);
return (
<>
setTrainer(null)} />
>
);
}
/* ────────────────────────────────────────────────────────────────
ROOT — загрузка данных перед рендером
──────────────────────────────────────────────────────────────── */
function Loader({ message }) {
return (
{message || "Загружаем…"}
);
}
function Root() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
window.STRIP_LOAD()
.then(setData)
.catch((e) => {
console.error("data load failed:", e);
setError(e.message || String(e));
});
}, []);
if (error) {
return (
);
}
if (!data) return ;
return (
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );