const { useEffect, useMemo, useRef, useState } = React;
window.__SHOP_BOOT_STAGE__ = "app-js-loaded";
const RUNTIME_VERSION_KEY = "shop-runtime-version";
const CURRENT_RUNTIME_VERSION = window.__SHOP_RUNTIME_VERSION__ || "dev";
function clearShopClientState() {
try {
const keysToDelete = [];
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key && key.startsWith("shop-")) {
keysToDelete.push(key);
}
}
keysToDelete.forEach((key) => localStorage.removeItem(key));
sessionStorage.clear();
} catch (err) {
console.warn("Failed to clear cached shop state.", err);
}
}
function enforceRuntimeVersion() {
try {
const storedVersion = localStorage.getItem(RUNTIME_VERSION_KEY);
if (storedVersion !== CURRENT_RUNTIME_VERSION) {
clearShopClientState();
localStorage.setItem(RUNTIME_VERSION_KEY, CURRENT_RUNTIME_VERSION);
}
} catch (err) {
console.warn("Failed to enforce runtime version.", err);
}
}
enforceRuntimeVersion();
const API_BASE =
window.__SHOP_API_BASE__ ||
localStorage.getItem("shop-api-base") ||
(window.location.protocol === "file:" ? "http://127.0.0.1:8000/api" : "/api");
const SESSION_TOKEN_KEY = "shop-session-token";
const ADMIN_UI_VERSION_KEY = "shop-admin-ui-version";
const API_REQUEST_TIMEOUT_MS = 12000;
const HEALTHCHECK_TIMEOUT_MS = 5000;
const GET_RETRY_COUNT = 1;
const navItems = [
{ id: "home", label: "Главная", icon: "icon-home-fill" },
{ id: "favorites", label: "Избранное", icon: "icon-heart-fill" },
{ id: "cart", label: "Корзина", icon: "icon-bag-fill" },
{ id: "profile", label: "Профиль", icon: "icon-user-fill" },
];
const sortModes = [
{ id: "featured", label: "По популярности" },
{ id: "priceAsc", label: "Цена: ниже" },
{ id: "priceDesc", label: "Цена: выше" },
];
const emptyPromoForm = {
code: "",
discount_type: "percent",
amount: "10",
description: "",
usage_limit: "",
is_active: true,
};
const emptyProductForm = {
title: "",
price: "2990",
category: "ФУТБОЛКИ",
sort_order: "0",
is_active: true,
images: [],
imageUrlInput: "",
sizesText: "S\nM\nL\nXL",
descriptionText: "",
};
const emptyBroadcastForm = {
text: "",
mediaUrl: "",
mediaType: "",
mediaName: "",
testChatIds: "",
};
const emptyChannelPostForm = {
text: "",
channelUsername: "",
};
function createEmptyReminderStep(delaySeconds = "30") {
return {
id: null,
delay_minutes: delaySeconds,
text: "",
is_active: true,
};
}
const emptyReminderPoolForm = {
title: "",
is_active: true,
is_default: true,
steps: [createEmptyReminderStep("30"), createEmptyReminderStep("90")],
};
function makeReminderPoolForm(pool) {
if (!pool) {
return {
...emptyReminderPoolForm,
steps: emptyReminderPoolForm.steps.map((step) => ({ ...step })),
};
}
return {
title: pool.title || "",
is_active: Boolean(pool.is_active),
is_default: Boolean(pool.is_default),
steps: (pool.steps || []).length
? pool.steps.map((step) => ({
id: step.id || null,
delay_minutes: String(step.delay_minutes || 30),
text: step.text || "",
is_active: step.is_active !== false,
}))
: [createEmptyReminderStep("30")],
};
}
const CDEK_DEFAULT_LOCATION = [37.622513, 55.75322];
let cdekWidgetScriptPromise = null;
function ensureCdekWidgetScript() {
if (window.CDEKWidget) {
return Promise.resolve(window.CDEKWidget);
}
if (cdekWidgetScriptPromise) {
return cdekWidgetScriptPromise;
}
cdekWidgetScriptPromise = new Promise((resolve, reject) => {
const existing = document.querySelector('script[data-cdek-widget="1"]');
if (existing) {
existing.addEventListener("load", () => resolve(window.CDEKWidget));
existing.addEventListener("error", () => reject(new Error("Не удалось загрузить виджет СДЭК")));
return;
}
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/@cdek-it/widget@3.11.1";
script.async = true;
script.dataset.cdekWidget = "1";
script.onload = () => {
if (window.CDEKWidget) {
resolve(window.CDEKWidget);
} else {
reject(new Error("Виджет СДЭК загрузился некорректно"));
}
};
script.onerror = () => reject(new Error("Не удалось загрузить виджет СДЭК"));
document.head.appendChild(script);
});
return cdekWidgetScriptPromise;
}
function rub(value) {
return `${new Intl.NumberFormat("ru-RU").format(value)} \u20bd`;
}
function getStoredToken() {
return localStorage.getItem(SESSION_TOKEN_KEY) || "";
}
function setStoredToken(token) {
localStorage.setItem(SESSION_TOKEN_KEY, token);
}
function clearStoredToken() {
localStorage.removeItem(SESSION_TOKEN_KEY);
}
function getTelegramInitData() {
return window.Telegram?.WebApp?.initData || "";
}
function getTelegramUserFromInitData(initData) {
if (!initData) {
return null;
}
try {
const params = new URLSearchParams(initData);
const rawUser = params.get("user");
if (!rawUser) {
return null;
}
const user = JSON.parse(rawUser);
return user && typeof user === "object" ? user : null;
} catch (err) {
console.warn("Failed to parse Telegram initData user.", err);
return null;
}
}
function setBootStage(stage) {
window.__SHOP_BOOT_STAGE__ = stage;
}
function reportFrontendDiagnostic(source, error, extras = {}) {
try {
const payload = {
source,
message: error?.message || String(error || ""),
url: window.location.href,
stack: error?.stack || "",
userAgent: navigator.userAgent,
bootStage: window.__SHOP_BOOT_STAGE__ || "unknown",
...extras,
};
fetch(`${API_BASE}/frontend/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {});
} catch (reportError) {
console.warn("Failed to send frontend diagnostic.", reportError);
}
}
function reportBootstrapMetric(metric, durationMs, status = "ok", extras = {}) {
try {
fetch(`${API_BASE}/frontend/bootstrap-metrics`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
metric,
status,
durationMs,
bootStage: window.__SHOP_BOOT_STAGE__ || "unknown",
userAgent: navigator.userAgent,
...extras,
}),
keepalive: true,
}).catch(() => {});
} catch (reportError) {
console.warn("Failed to send bootstrap metric.", reportError);
}
}
async function measureBootstrapStep(metric, task) {
const startedAt = performance.now();
try {
const result = await task();
reportBootstrapMetric(metric, performance.now() - startedAt, "ok");
return result;
} catch (err) {
reportBootstrapMetric(metric, performance.now() - startedAt, "error", {
message: err?.message || String(err || ""),
});
throw err;
}
}
async function checkApiHealth() {
try {
const startedAt = performance.now();
await apiRequest("/health", {
skipAuth: true,
retries: 0,
timeoutMs: HEALTHCHECK_TIMEOUT_MS,
});
reportBootstrapMetric("bootstrap.health", performance.now() - startedAt, "ok");
return true;
} catch (err) {
reportBootstrapMetric("bootstrap.health", 0, "error", {
message: err?.message || "health check failed",
});
return false;
}
}
function buildBootstrapErrorMessage(error, apiHealthy) {
if (error?.isTimeout) {
return apiHealthy
? "Сервер отвечает слишком долго. Попробуйте еще раз или временно отключите VPN."
: "Не удается достучаться до сервера. Похоже, сеть или VPN режет соединение.";
}
if (error?.isNetworkError) {
return apiHealthy
? "Сеть нестабильна. Попробуйте еще раз или смените соединение."
: "Сервер сейчас недоступен из вашей сети. Попробуйте без VPN или позже.";
}
return error?.message || "Не удалось загрузить данные";
}
function isLocalPreview() {
return (
window.location.protocol === "file:" ||
window.location.hostname === "127.0.0.1" ||
window.location.hostname === "localhost"
);
}
function parseTimestamp(value) {
if (!value) {
return null;
}
if (typeof value === "number") {
return new Date(value * 1000);
}
return new Date(`${value.replace(" ", "T")}Z`);
}
function formatOrderDate(value) {
const date = parseTimestamp(value);
if (!date || Number.isNaN(date.getTime())) {
return "\u2014";
}
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function getDisplayName(user) {
if (!user) {
return "Покупатель";
}
return (
user.display_name ||
[user.first_name, user.last_name].filter(Boolean).join(" ").trim() ||
(user.username ? `@${user.username}` : "Покупатель")
);
}
function getInitials(user) {
const name = getDisplayName(user).replace("@", "").trim();
const parts = name.split(/\s+/).filter(Boolean);
const letters = parts.slice(0, 2).map((part) => part[0]?.toUpperCase() || "");
return letters.join("") || "TG";
}
async function copyText(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const input = document.createElement("textarea");
input.value = text;
input.setAttribute("readonly", "");
input.style.position = "absolute";
input.style.left = "-9999px";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
}
function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function isRetryableRequest(method, error) {
const normalizedMethod = (method || "GET").toUpperCase();
if (!["GET", "HEAD"].includes(normalizedMethod)) {
return false;
}
if (error?.isTimeout || error?.isNetworkError) {
return true;
}
const status = Number(error?.status || 0);
return status === 408 || status === 429 || status >= 500;
}
async function apiRequest(path, options = {}) {
const headers = { ...(options.headers || {}) };
const isFormData = typeof FormData !== "undefined" && options.body instanceof FormData;
const method = (options.method || "GET").toUpperCase();
const retries = Number.isInteger(options.retries) ? options.retries : GET_RETRY_COUNT;
const timeoutMs = options.timeoutMs || API_REQUEST_TIMEOUT_MS;
if (!isFormData && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
if (!options.skipAuth) {
const token = getStoredToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
}
let attempt = 0;
while (true) {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort("timeout"), timeoutMs);
try {
const response = await fetch(`${API_BASE}${path}`, {
...options,
method,
headers,
signal: controller.signal,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
if (response.status === 401 && !options.skipAuth) {
clearStoredToken();
}
const detail = payload.detail;
const error = new Error(
typeof detail === "string" ? detail : payload.message || `Ошибка API (${response.status})`
);
error.status = response.status;
error.payload = payload;
error.path = path;
throw error;
}
return response.json();
} catch (err) {
const isAbortError = err?.name === "AbortError" || err === "timeout";
if (isAbortError) {
const timeoutError = new Error("Сервер долго не отвечает");
timeoutError.isTimeout = true;
timeoutError.path = path;
err = timeoutError;
} else if (!err?.status) {
err.isNetworkError = true;
err.path = path;
if (!err.message) {
err.message = "Не удалось связаться с сервером";
}
}
if (attempt < retries && isRetryableRequest(method, err)) {
attempt += 1;
await sleep(350 * attempt);
continue;
}
throw err;
} finally {
window.clearTimeout(timeoutId);
}
}
}
function makeCartKey(productId, size = "") {
return `${productId}::${size || ""}`;
}
function buildCartIndex(items) {
return Object.fromEntries(items.map((item) => [item.key, item]));
}
function normalizeState(payload) {
const favorites = payload.favorites || [];
const cartItems = (payload.cart || []).map((item) => ({
...item,
size: item.size || "",
key: item.key || makeCartKey(item.product_id, item.size || ""),
}));
return {
user: payload.user || null,
favorites,
cartItems,
cartIndex: buildCartIndex(cartItems),
orders: payload.orders || [],
supportUrl: payload.support_url || null,
supportUsername: payload.support_username || null,
};
}
function Icon({ id }) {
return (
);
}
function CatalogHeader({
activeCategory,
searchOpen,
searchQuery,
onSearchToggle,
onSearchChange,
onOpenCategories,
onCycleSort,
onOpenProfile,
categoryButtons,
}) {
return (
<>
>
);
}
function ProductCard({ product, isFavorite, onToggleFavorite, onOpen }) {
return (
onToggleFavorite(product.id)}
aria-label="Добавить в избранное"
>
onOpen(product.id)} aria-label={product.title}>
{product.images.map((_, index) => (
))}
onOpen(product.id)}>
{rub(product.price)}
{product.title}
{product.category}
);
}
function FavoritesEmpty({ onBackToShop }) {
return (
В избранном пока пусто
Сохраняйте понравившиеся вещи, чтобы быстро вернуться к ним позже.
Вернуться в каталог
);
}
const checkoutSteps = [
{ id: "contact", label: "Контакты" },
{ id: "delivery", label: "Доставка" },
{ id: "shipping", label: "Способ доставки" },
{ id: "payment", label: "Оплата" },
{ id: "review", label: "Подтверждение" },
];
function formatCdekEta(tariff) {
if (!tariff) {
return "";
}
if (tariff.period_min && tariff.period_max) {
if (tariff.period_min === tariff.period_max) {
return `${tariff.period_max} дн.`;
}
return `${tariff.period_min}-${tariff.period_max} дн.`;
}
if (tariff.period_max) {
return `${tariff.period_max} дн.`;
}
return "";
}
function getOrderStatusLabel(status, paymentProvider) {
const normalized = (status || "").toLowerCase();
if (normalized === "paid") {
return "Оплачен";
}
if (normalized === "pending_payment") {
return "Ожидает оплаты";
}
if (normalized === "new" || normalized === "manual_review") {
return paymentProvider === "manual" ? "Нужно подтвердить" : "Новый заказ";
}
if (normalized === "processing") {
return "В обработке";
}
if (normalized === "completed") {
return "Завершен";
}
return "Заказ создан";
}
function CheckoutFlow({
open,
step,
draft,
options,
cartItems,
productMap,
couponCode,
promoPreview,
applyingPromo,
submitting,
onClose,
onBack,
onAdvance,
onSubmit,
onChange,
onDeliveryPicked,
onCouponChange,
onApplyPromo,
}) {
const widgetRef = useRef(null);
const widgetHostRef = useRef(null);
const deliveryPickedRef = useRef(onDeliveryPicked);
const [widgetLoading, setWidgetLoading] = useState(false);
const [widgetError, setWidgetError] = useState("");
const widgetConfig = options?.cdek_widget || {};
const deliveryChosen = Boolean(draft.delivery_code && draft.delivery_method_id && draft.delivery_address);
const totalItems = cartItems.reduce((sum, item) => sum + item.quantity, 0);
const itemsAmount = cartItems.reduce((sum, item) => {
const product = productMap[item.product_id];
return sum + (product ? product.price * item.quantity : 0);
}, 0);
const discountAmount = promoPreview?.coupon_applied ? promoPreview.discount_amount || 0 : 0;
const deliveryAmount = draft.delivery_amount || 0;
const finalTotal =
(promoPreview?.coupon_applied ? promoPreview.total_amount || Math.max(0, itemsAmount - discountAmount) : itemsAmount) +
deliveryAmount;
const activeStepIndex = checkoutSteps.findIndex((item) => item.id === step);
const reviewDeliveryAddress = draft.delivery_address || "";
useEffect(() => {
deliveryPickedRef.current = onDeliveryPicked;
}, [onDeliveryPicked]);
useEffect(() => {
let cancelled = false;
async function preloadWidgetScript() {
if (!open || !widgetConfig.configured) {
return;
}
setWidgetLoading(true);
try {
await ensureCdekWidgetScript();
} catch (err) {
if (!cancelled) {
setWidgetError(err.message || "Не удалось инициализировать виджет СДЭК");
}
} finally {
if (!cancelled) {
setWidgetLoading(false);
}
}
}
preloadWidgetScript();
return () => {
cancelled = true;
closeDeliveryWidget();
};
}, [
open,
widgetConfig.configured,
]);
if (!open || !options) {
return null;
}
function destroyWidgetInstance() {
if (widgetRef.current?.destroy) {
widgetRef.current.destroy();
}
widgetRef.current = null;
}
function closeDeliveryWidget() {
destroyWidgetInstance();
if (widgetHostRef.current) {
widgetHostRef.current.remove();
widgetHostRef.current = null;
}
document.body.classList.remove("cdek-modal-open");
}
function ensureWidgetHost() {
closeDeliveryWidget();
const host = document.createElement("div");
host.className = "cdek-modal-shell";
host.innerHTML = `
`;
host.querySelector(".cdek-modal-backdrop")?.addEventListener("click", closeDeliveryWidget);
host.querySelector(".cdek-modal-close")?.addEventListener("click", closeDeliveryWidget);
document.body.appendChild(host);
document.body.classList.add("cdek-modal-open");
widgetHostRef.current = host;
return "cdek-modal-root";
}
function handleDeliveryChoose(type, tariff, address) {
const mode = type === "door" ? "courier" : "pickup";
const pointLabel =
address?.name ||
address?.office_name ||
(mode === "courier" ? "Адрес доставки" : "Пункт выдачи СДЭК");
const label =
tariff?.tariff_name ||
tariff?.name ||
(mode === "courier" ? "Курьерская доставка" : "Посылка склад-склад");
const normalizedAddress =
mode === "courier"
? address?.formatted || address?.address || draft.delivery_address || ""
: address?.address || address?.formatted || "";
const normalizedCode =
address?.code ||
address?.office_code ||
address?.city_code ||
normalizedAddress ||
`${mode}-${Date.now()}`;
const cityCode =
address?.city_code ||
address?.location?.city_code ||
address?.city?.code ||
"";
const officeCode = address?.code || address?.office_code || "";
deliveryPickedRef.current?.({
delivery_mode: mode,
delivery_code: String(normalizedCode),
delivery_point_label: pointLabel,
delivery_address: normalizedAddress,
delivery_city_code: cityCode ? String(cityCode) : "",
delivery_office_code: officeCode ? String(officeCode) : "",
delivery_method_id: String(tariff?.tariff_code || tariff?.id || mode),
delivery_label: label,
delivery_eta: formatCdekEta(tariff),
delivery_amount: Number(tariff?.delivery_sum || 0),
});
closeDeliveryWidget();
}
async function openDeliveryWidget() {
if (!widgetConfig.configured) {
setWidgetError(
"Добавьте на сервере YANDEX_MAPS_API_KEY, CDEK_INTEGRATION_ACCOUNT и CDEK_INTEGRATION_PASSWORD, чтобы включить выбор доставки."
);
return;
}
setWidgetError("");
setWidgetLoading(true);
try {
const CDEKWidget = await ensureCdekWidgetScript();
const rootId = ensureWidgetHost();
widgetRef.current = new CDEKWidget({
root: rootId,
apiKey: widgetConfig.api_key,
servicePath: widgetConfig.service_path,
canChoose: true,
defaultLocation: CDEK_DEFAULT_LOCATION,
from: widgetConfig.from,
goods: widgetConfig.goods || [],
lang: "rus",
currency: "RUB",
hideDeliveryOptions:
draft.delivery_mode === "pickup" ? { door: true } : { office: true },
onReady() {
setWidgetLoading(false);
},
onChoose(type, tariff, address) {
handleDeliveryChoose(type, tariff, address);
},
});
} catch (err) {
closeDeliveryWidget();
setWidgetLoading(false);
setWidgetError(err.message || "Не удалось открыть виджет СДЭК");
}
}
return (
{checkoutSteps.map((item, index) => (
))}
{step === "contact" ? (
Контактные данные
Эти данные нужны, чтобы мы могли оформить и доставить ваш заказ.
) : null}
{step === "delivery" ? (
onChange("delivery_mode", "pickup")}
>
Самовывоз
onChange("delivery_mode", "courier")}
>
Курьер
{draft.delivery_mode === "pickup" ? "СДЭК • ПВЗ" : "СДЭК • КУРЬЕР"}
{draft.delivery_mode === "pickup"
? "Выберите удобный пункт выдачи"
: "Выберите адрес и способ доставки"}
Официальный виджет СДЭК откроется поверх экрана и вернет готовый вариант доставки в оформление.
{widgetLoading
? "Загружаем виджет..."
: draft.delivery_mode === "pickup"
? "Выбрать пункт выдачи СДЭК"
: "Выбрать адрес доставки"}
{!deliveryChosen && !widgetError ? (
После выбора мы автоматически подставим адрес, срок и стоимость на следующий шаг.
) : null}
{widgetError ?
{widgetError}
: null}
{deliveryChosen ? (
{draft.delivery_label}
{rub(deliveryAmount)}
{reviewDeliveryAddress}
{draft.delivery_mode === "pickup" ? "Пункт выдачи" : "Курьер"}
{draft.delivery_eta ? (
{draft.delivery_eta}
) : null}
) : null}
) : null}
{step === "shipping" ? (
Способ доставки
Выбранная доставка
{draft.delivery_label || "Способ доставки не выбран"}
{draft.delivery_eta || "Срок появится после выбора в виджете"}
{rub(deliveryAmount)}
{reviewDeliveryAddress || "Откройте предыдущий шаг и выберите ПВЗ или адрес"}
Изменить доставку
) : null}
{step === "payment" ? (
Оплата
Способ оплаты
onChange("payment_method", "online")}>
Онлайн-оплата
После подтверждения заказа мы сразу переведем вас на оплату через Robokassa.
) : null}
{step === "review" ? (
Подтверждение заказа
Контактные данные
{draft.contact_first_name} {draft.contact_last_name}
{draft.contact_phone}
{draft.contact_email}
Доставка
{draft.delivery_label || (draft.delivery_mode === "pickup" ? "СДЭК" : "Курьер")}: {reviewDeliveryAddress}
{draft.delivery_eta || "Доставка"}
{cartItems.map((item) => {
const product = productMap[item.product_id];
if (!product) {
return null;
}
return (
);
})}
{draft.delivery_label || "Доставка"}
{rub(deliveryAmount)}
Способ оплаты
Онлайн-оплата
Комментарий к заказу
{draft.order_note || "Без комментария"}
onCouponChange(event.target.value.toUpperCase())}
/>
{applyingPromo ? "..." : "Применить"}
{promoPreview?.promo_error ? (
{promoPreview.promo_error}
) : null}
{promoPreview?.coupon_applied ? (
Промокод {promoPreview.coupon_code} применен, скидка {rub(discountAmount)}
) : null}
Товары ({totalItems})
{rub(itemsAmount)}
Доставка
{rub(deliveryAmount)}
{discountAmount > 0 ? (
Скидка
-{rub(discountAmount)}
) : null}
Итого
{rub(finalTotal)}
Нажимая кнопку, вы соглашаетесь на обработку персональных данных
) : null}
{step === "contact" ? (
Выбрать способ доставки
) : null}
{step === "delivery" ? (
{draft.delivery_mode === "pickup" ? "Выбрать пункт выдачи" : "Продолжить"}
) : null}
{step === "shipping" ? (
Далее
) : null}
{step === "payment" ? (
Далее
) : null}
{step === "review" ? (
{submitting ? "Оформляем..." : `Оформить заказ • ${rub(finalTotal)}`}
) : null}
);
}
function CartScreen({
cartItems,
productMap,
couponCode,
promoPreview,
applyingPromo,
onCouponChange,
onApplyPromo,
onClearCart,
onIncrement,
onDecrement,
onCheckout,
}) {
if (!cartItems.length) {
return (
Корзина пока пустая
Добавьте товары в корзину, чтобы оформить заказ.
);
}
const totalItems = cartItems.reduce((sum, item) => sum + item.quantity, 0);
const subtotalAmount = cartItems.reduce((sum, item) => {
const product = productMap[item.product_id];
return sum + (product ? product.price * item.quantity : 0);
}, 0);
const discountAmount = promoPreview?.coupon_applied ? promoPreview.discount_amount || 0 : 0;
const totalAmount = promoPreview?.coupon_applied
? promoPreview.total_amount || Math.max(0, subtotalAmount - discountAmount)
: subtotalAmount;
const appliedCouponCode = promoPreview?.coupon_applied ? promoPreview.coupon_code : "";
return (
Корзина
Очистить
{cartItems.map((item) => {
const product = productMap[item.product_id];
if (!product) {
return null;
}
return (
{product.title}
Размер одежды {item.size || "Единый"}
onDecrement(item)} aria-label="Уменьшить">
{item.quantity}
onIncrement(item)} aria-label="Увеличить">
{rub(product.price * item.quantity)}
);
})}
onCouponChange(event.target.value.toUpperCase())}
/>
{applyingPromo ? "..." : "Применить"}
{promoPreview?.promo_error ? (
{promoPreview.promo_error}
) : null}
{promoPreview?.coupon_applied ? (
Промокод {appliedCouponCode} применен, скидка {rub(discountAmount)}
) : null}
Товары ({totalItems})
{rub(subtotalAmount)}
{discountAmount > 0 ? (
Скидка
-{rub(discountAmount)}
) : null}
Итого
{rub(totalAmount)}
Перейти к оформлению • {rub(totalAmount)}
);
}
function ProfileScreen({
user,
orders,
favoritesCount,
cartCount,
productMap,
supportUrl,
supportUsername,
onOpenAdmin,
onResumeOrderPayment,
}) {
const displayName = getDisplayName(user);
const username = user?.username ? `@${user.username}` : "не указан";
const authDate = user?.last_auth_date ? formatOrderDate(user.last_auth_date) : "\u2014";
return (
{user?.photo_url ? (
) : (
{getInitials(user)}
)}
{displayName}
{username}
Заказы
{orders.length}
Избранное
{favoritesCount}
Товары в корзине
{cartCount}
Профиль
Имя пользователя
{username}
Язык
{user?.language_code || "ru"}
Последний вход
{authDate}
{supportUsername ? (
) : null}
{user?.is_admin ? (
Открыть админку
) : null}
История заказов
{orders.length ? (
{orders.map((order) => (
Заказ #{order.id}
{formatOrderDate(order.created_at)}
{rub(order.total_amount)}
{getOrderStatusLabel(order.status, order.payment_provider)}
{order.items.map((item, index) => {
const product = productMap[item.product_id];
return (
{product?.title || `Товар #${item.product_id}`}
{item.quantity} × {rub(item.unit_price)}
);
})}
{order.coupon_code ? Промокод: {order.coupon_code}
: null}
{order.status !== "paid" && order.payment_provider === "robokassa" ? (
onResumeOrderPayment(order.id)}>
Оплатить заказ
) : null}
))}
) : (
У вас пока нет заказов. Когда оформите первую покупку, она появится здесь.
)}
);
}
function AdminOrdersSection({
orders,
selectedOrderIds,
deletingOrders,
onToggleOrderSelection,
onSelectAllOrders,
onClearOrderSelection,
onDeleteSelectedOrders,
}) {
return (
Все заказы
Выбрано: {selectedOrderIds.length} из {orders.length}
Выбрать все
Снять выделение
{deletingOrders ? "Удаляем..." : "Удалить выбранные"}
{orders.length ? (
orders.map((order) => {
const isSelected = selectedOrderIds.includes(order.id);
return (
{order.customer.display_name}
{order.customer.username ? `@${order.customer.username} • ` : ""}
{order.status}
{formatOrderDate(order.created_at)}
{order.coupon_code ? (
Промокод: {order.coupon_code} • скидка {rub(order.discount_amount || 0)}
) : null}
{order.items.map((item, index) => (
{item.product_title || `Товар #${item.product_id}`}
{item.quantity} × {rub(item.unit_price)} {item.size ? `• ${item.size}` : ""}
))}
);
})
) : (
Заказов пока нет.
)}
);
}
function AdminPromoSection({
dashboard,
promoForm,
editingPromoId,
onPromoFormChange,
onSavePromo,
onEditPromo,
onResetPromo,
}) {
return (
);
}
function AdminBotSection({
dashboard,
botStartMessage,
botSaving,
broadcastForm,
broadcastUploading,
broadcastSending,
channelPostForm,
channelPostSending,
reminderPoolForm,
reminderSaving,
editingReminderPoolId,
onBotStartMessageChange,
onSaveBotStartMessage,
onBroadcastFormChange,
onBroadcastMediaUpload,
onClearBroadcastMedia,
onSendTestBroadcast,
onSendBroadcast,
onChannelPostFormChange,
onSendChannelPost,
onReminderPoolFormChange,
onReminderStepChange,
onAddReminderStep,
onRemoveReminderStep,
onSaveReminderPool,
onEditReminderPool,
onResetReminderPool,
}) {
return (
Бот и рассылки
Стартовое сообщение бота
{botSaving ? "Сохраняем..." : "Сохранить стартовое сообщение"}
Рассылка
Аудитория для общей рассылки: {dashboard.bot?.audience_size || 0} пользователей
{broadcastSending === "test" ? "Отправляем тест..." : "Тестовая рассылка"}
{broadcastSending === "all" ? "Рассылаем..." : "Отправить всем"}
{dashboard.bot?.start_message_error ? (
Relay: {dashboard.bot.start_message_error}
) : null}
Пост в канал
Бот отправит обычное сообщение в канал и добавит кнопку{" "}
{dashboard.bot?.channel_post_button_text || "Открыть каталог"} .
{dashboard.bot?.channel_post_button_url ? (
<>
{" "}Текущая ссылка кнопки:{" "}
{dashboard.bot.channel_post_button_url}
>
) : (
<> Ссылка кнопки еще не настроена в env.>
)}
Username канала или chat_id
onChannelPostFormChange("channelUsername", e.target.value)}
placeholder="@my_channel или -1001234567890"
/>
Текст поста
{channelPostSending ? "Отправляем..." : "Отправить в канал"}
Напоминания о неоплаченных заказах
Для новых заказов со статусом ожидания оплаты бот будет отправлять сообщения по таймеру в секундах.
Доступные теги: {(dashboard.bot?.reminder_placeholders || []).join(", ") || "{first_name}, {order_id}, {amount}, {pay_url}"}
Название пула
onReminderPoolFormChange("title", e.target.value)}
placeholder="Например: Базовые напоминания"
/>
onReminderPoolFormChange("is_active", e.target.checked)}
/>
Пул активен
onReminderPoolFormChange("is_default", e.target.checked)}
/>
Использовать для новых заказов
{reminderPoolForm.steps.map((step, index) => (
))}
Добавить сообщение
{reminderSaving ? "Сохраняем..." : editingReminderPoolId ? "Сохранить пул" : "Создать пул"}
{editingReminderPoolId ? (
Новый пул
) : null}
{(dashboard.bot?.reminder_pools || []).map((pool) => (
{pool.title}
{pool.steps?.length || 0} сообщений
{pool.is_default ? " • используется для новых заказов" : ""}
{pool.is_active ? "активен" : "выключен"}
onEditReminderPool(pool)}>
Изменить
))}
);
}
function AdminProductsSection({
dashboard,
productForm,
productUploading,
editingProductId,
onProductFormChange,
onProductImageUpload,
onProductImageUrlChange,
onAddProductImageByUrl,
onRemoveProductImage,
onSaveProduct,
onEditProduct,
onToggleProduct,
onResetProduct,
}) {
return (
Товары
{editingProductId ? "Сохранить товар" : "Создать товар"}
{editingProductId ? (
Отмена
) : null}
{dashboard.products.map((product) => (
{product.images?.[0] ?
:
Нет фото }
{product.title}
{rub(product.price)} • {product.category} • #{product.sort_order}
{product.images?.length || 0} фото • {(product.sizes || []).join(", ") || "без размеров"}
{product.is_active ? "виден" : "скрыт"}
onEditProduct(product)}>
Изменить
onToggleProduct(product)}>
{product.is_active ? "Скрыть" : "Показать"}
))}
);
}
function AdminScreen({
dashboard,
loading,
promoForm,
productForm,
productUploading,
botStartMessage,
botSaving,
broadcastForm,
broadcastUploading,
broadcastSending,
channelPostForm,
channelPostSending,
reminderPoolForm,
reminderSaving,
editingPromoId,
editingProductId,
editingReminderPoolId,
adminVersion,
deletingOrders,
selectedOrderIds,
onAdminVersionChange,
onToggleOrderSelection,
onSelectAllOrders,
onClearOrderSelection,
onDeleteSelectedOrders,
onPromoFormChange,
onProductFormChange,
onProductImageUpload,
onProductImageUrlChange,
onAddProductImageByUrl,
onRemoveProductImage,
onBotStartMessageChange,
onSaveBotStartMessage,
onBroadcastFormChange,
onBroadcastMediaUpload,
onClearBroadcastMedia,
onSendTestBroadcast,
onSendBroadcast,
onChannelPostFormChange,
onSendChannelPost,
onReminderPoolFormChange,
onReminderStepChange,
onAddReminderStep,
onRemoveReminderStep,
onSaveReminderPool,
onEditReminderPool,
onResetReminderPool,
onSavePromo,
onEditPromo,
onResetPromo,
onSaveProduct,
onEditProduct,
onToggleProduct,
onResetProduct,
onRefresh,
}) {
const [adminView, setAdminView] = useState("orders");
if (loading) {
return (
Загружаем данные
Еще немного, и все будет готово.
);
}
const viewTabs = [
{ id: "orders", label: "Заказы" },
{ id: "promo", label: "Промокоды" },
{ id: "bot", label: "Бот" },
{ id: "products", label: "Товары" },
];
const ordersSection = (
);
const promoSection = (
);
const botSection = (
);
const productsSection = (
);
return (
Админка
Заказы, промокоды и управление товарами
Обновить
onAdminVersionChange("v1")}
>
V1
onAdminVersionChange("v2")}
>
V2
{adminVersion === "v1" ? (
<>
{ordersSection}
{promoSection}
{botSection}
{productsSection}
>
) : (
<>
{viewTabs.map((tab) => (
setAdminView(tab.id)}
>
{tab.label}
))}
{adminView === "orders" ? ordersSection : null}
{adminView === "promo" ? promoSection : null}
{adminView === "bot" ? botSection : null}
{adminView === "products" ? productsSection : null}
>
)}
);
}
function CategorySheet({ categories, activeCategory, onSelect, onClose }) {
return (
<>
Категории
{categories.map((category) => (
onSelect(category)}
>
{category === "ALL" ? "ВСЕ" : category}
{activeCategory === category ? Выбрано : null}
))}
>
);
}
function ProductModal({
product,
slideIndex,
selectedSize,
isFavorite,
quantity,
hasSupport,
onClose,
onPrevSlide,
onNextSlide,
onToggleFavorite,
onSelectSize,
onQtyMinus,
onQtyPlus,
onGoToCart,
onShare,
onShowSizeChart,
onContactSeller,
}) {
if (!product) {
return null;
}
const imageSrc = product.images[slideIndex];
const imageFitClass = slideIndex === product.images.length - 1 ? "contain" : "";
return (
<>
{product.images.map((_, index) => (
))}
onToggleFavorite(product.id)}
aria-label="В избранное"
>
Размер одежды: {selectedSize}
{product.sizes.map((size) => (
onSelectSize(size)}
>
{size}
))}
{product.title}
{rub(product.price)}
Описание
{product.description.map((line) => (
{line}
))}
Размерная сетка
Остались вопросы?
Связаться с нами
>
);
}
function Toast({ message }) {
if (!message) {
return null;
}
return {message}
;
}
function App() {
const [viewer, setViewer] = useState(null);
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [activeTab, setActiveTab] = useState("home");
const [activeCategory, setActiveCategory] = useState("ALL");
const [favorites, setFavorites] = useState([]);
const [cartItems, setCartItems] = useState([]);
const [cartIndex, setCartIndex] = useState({});
const [orders, setOrders] = useState([]);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortMode, setSortMode] = useState("featured");
const [activeProductId, setActiveProductId] = useState(null);
const [selectedSize, setSelectedSize] = useState({});
const [draftQuantities, setDraftQuantities] = useState({});
const [couponCode, setCouponCode] = useState("");
const [promoPreview, setPromoPreview] = useState(null);
const [applyingPromo, setApplyingPromo] = useState(false);
const [productSlides, setProductSlides] = useState({});
const [categorySheetOpen, setCategorySheetOpen] = useState(false);
const [toast, setToast] = useState("");
const [supportUrl, setSupportUrl] = useState(null);
const [supportUsername, setSupportUsername] = useState(null);
const [checkoutOpen, setCheckoutOpen] = useState(false);
const [checkoutStep, setCheckoutStep] = useState("contact");
const [checkoutOptions, setCheckoutOptions] = useState(null);
const [checkoutLoading, setCheckoutLoading] = useState(false);
const [checkoutSubmitting, setCheckoutSubmitting] = useState(false);
const [checkoutDraft, setCheckoutDraft] = useState({
contact_first_name: "",
contact_last_name: "",
contact_phone: "",
contact_email: "",
contact_city: "Москва",
order_note: "",
delivery_mode: "pickup",
delivery_code: "",
delivery_point_label: "",
delivery_method_id: "",
delivery_label: "",
delivery_address: "",
delivery_city_code: "",
delivery_office_code: "",
delivery_eta: "",
delivery_amount: 0,
payment_method: "online",
});
const [adminDashboard, setAdminDashboard] = useState({
orders: [],
promoCodes: [],
products: [],
bot: {
relay_configured: false,
default_test_chat_ids: [],
audience_size: 0,
start_message: "",
reminder_pools: [],
active_reminder_pool_id: null,
reminder_placeholders: [],
},
});
const [adminLoading, setAdminLoading] = useState(false);
const [adminVersion, setAdminVersion] = useState(() => localStorage.getItem(ADMIN_UI_VERSION_KEY) || "v1");
const [selectedAdminOrderIds, setSelectedAdminOrderIds] = useState([]);
const [deletingAdminOrders, setDeletingAdminOrders] = useState(false);
const [promoForm, setPromoForm] = useState(emptyPromoForm);
const [productForm, setProductForm] = useState(emptyProductForm);
const [productUploading, setProductUploading] = useState(false);
const [botStartMessage, setBotStartMessage] = useState("");
const [botSaving, setBotSaving] = useState(false);
const [broadcastForm, setBroadcastForm] = useState(emptyBroadcastForm);
const [broadcastUploading, setBroadcastUploading] = useState(false);
const [broadcastSending, setBroadcastSending] = useState("");
const [channelPostForm, setChannelPostForm] = useState(emptyChannelPostForm);
const [channelPostSending, setChannelPostSending] = useState(false);
const [reminderPoolForm, setReminderPoolForm] = useState(makeReminderPoolForm());
const [reminderSaving, setReminderSaving] = useState(false);
const [editingPromoId, setEditingPromoId] = useState(null);
const [editingProductId, setEditingProductId] = useState(null);
const [editingReminderPoolId, setEditingReminderPoolId] = useState(null);
const favoritesSet = useMemo(() => new Set(favorites), [favorites]);
const productMap = useMemo(
() => Object.fromEntries(products.map((product) => [product.id, product])),
[products]
);
const cartCount = useMemo(
() => cartItems.reduce((sum, entry) => sum + entry.quantity, 0),
[cartItems]
);
const categories = useMemo(
() => ["ALL", ...new Set(products.map((product) => product.category))],
[products]
);
const activeProduct = useMemo(
() => products.find((product) => product.id === activeProductId) || null,
[activeProductId, products]
);
function showToast(message) {
setToast(message);
}
function applySessionPayload(payload) {
setViewer(payload?.user || null);
setSupportUrl(payload?.support_url || null);
setSupportUsername(payload?.support_username || null);
}
function applyState(payload) {
const next = normalizeState(payload);
setViewer((prev) => next.user || prev);
setFavorites(next.favorites);
setCartItems(next.cartItems);
setCartIndex(next.cartIndex);
setOrders(next.orders);
setSupportUrl(next.supportUrl);
setSupportUsername(next.supportUsername);
}
function resetPromoPreview() {
setPromoPreview(null);
}
function handleCouponChange(value) {
setCouponCode(value);
resetPromoPreview();
}
function getCartItem(productId, size = "") {
return cartIndex[makeCartKey(productId, size || "")] || null;
}
function getDefaultSize(productId) {
const product = productMap[productId];
const existingSize = cartItems.find((item) => item.product_id === productId)?.size;
return (
selectedSize[productId] ||
existingSize ||
product?.sizes[1] ||
product?.sizes[0] ||
"M"
);
}
function getDraftQuantity(productId, size) {
const key = makeCartKey(productId, size || "");
return draftQuantities[key] || getCartItem(productId, size)?.quantity || 1;
}
function buildCheckoutDraft(payload) {
const contactDefaults = payload?.contact_defaults || {};
return {
contact_first_name: contactDefaults.first_name || viewer?.first_name || "",
contact_last_name: contactDefaults.last_name || viewer?.last_name || "",
contact_phone: contactDefaults.phone || "",
contact_email: contactDefaults.email || "",
contact_city: contactDefaults.city || "Москва",
order_note: contactDefaults.note || "",
delivery_mode: "pickup",
delivery_code: "",
delivery_point_label: "",
delivery_method_id: "",
delivery_label: "",
delivery_address: "",
delivery_city_code: "",
delivery_office_code: "",
delivery_eta: "",
delivery_amount: 0,
payment_method: "online",
};
}
async function loadAdminDashboard() {
setAdminLoading(true);
try {
const payload = await apiRequest("/admin/dashboard");
const botPayload = payload.bot || {};
setAdminDashboard({
orders: payload.orders || [],
promoCodes: payload.promo_codes || [],
products: payload.products || [],
bot: botPayload,
});
setSelectedAdminOrderIds((prev) =>
prev.filter((orderId) => (payload.orders || []).some((order) => order.id === orderId))
);
setBotStartMessage(botPayload.start_message || "");
setBroadcastForm((prev) => ({
...prev,
testChatIds: prev.testChatIds || (botPayload.default_test_chat_ids || []).join(", "),
}));
if (!editingReminderPoolId) {
const activePool =
(botPayload.reminder_pools || []).find((pool) => pool.id === botPayload.active_reminder_pool_id) ||
(botPayload.reminder_pools || [])[0] ||
null;
setReminderPoolForm(makeReminderPoolForm(activePool));
}
} catch (err) {
showToast(err.message || "Не удалось загрузить админку");
} finally {
setAdminLoading(false);
}
}
async function authenticate() {
setBootStage("auth-start");
const initData = getTelegramInitData();
const existingToken = getStoredToken();
if (initData) {
try {
const authPayload = await apiRequest("/auth/telegram", {
method: "POST",
body: JSON.stringify({ init_data: initData }),
skipAuth: true,
});
setStoredToken(authPayload.token);
applySessionPayload(authPayload);
setBootStage("auth-complete");
return;
} catch (err) {
const message = err?.message || "";
reportFrontendDiagnostic("auth.telegram", err);
if (!isLocalPreview()) {
throw err;
}
console.warn("Telegram auth failed in local preview, falling back to dev auth.", message);
clearStoredToken();
}
}
if (existingToken) {
try {
const session = await apiRequest("/me");
applySessionPayload(session);
setBootStage("session-restored");
return;
} catch (err) {
reportFrontendDiagnostic("auth.session", err);
clearStoredToken();
}
}
if (!isLocalPreview()) {
throw new Error("Откройте миниапп внутри Telegram, чтобы пройти авторизацию");
}
const devPayload = await apiRequest("/auth/dev", {
method: "POST",
body: JSON.stringify({ display_name: "Локальный тест" }),
skipAuth: true,
});
setStoredToken(devPayload.token);
applySessionPayload(devPayload);
setBootStage("dev-auth-complete");
}
async function syncState() {
const payload = await apiRequest("/state");
applyState(payload);
}
async function bootstrapApp({ cancelled = () => false } = {}) {
const bootstrapStartedAt = performance.now();
setBootStage("bootstrap-loading");
setLoading(true);
setError("");
try {
await measureBootstrapStep("bootstrap.auth", () => authenticate());
if (cancelled()) {
return;
}
setBootStage("bootstrap-fetching");
const productsPromise = measureBootstrapStep("bootstrap.products", () => apiRequest("/products"));
const statePromise = measureBootstrapStep("bootstrap.state", () => apiRequest("/state"));
const productsPayload = await productsPromise;
if (cancelled()) {
return;
}
setProducts(productsPayload.products || []);
setLoading(false);
setBootStage("bootstrap-catalog-ready");
try {
const statePayload = await statePromise;
if (cancelled()) {
return;
}
applyState(statePayload);
setBootStage("bootstrap-ready");
reportBootstrapMetric("bootstrap.total", performance.now() - bootstrapStartedAt, "ok");
} catch (err) {
if (cancelled()) {
return;
}
reportFrontendDiagnostic("bootstrap.state", err);
setBootStage("bootstrap-state-error");
reportBootstrapMetric("bootstrap.total", performance.now() - bootstrapStartedAt, "partial", {
message: err?.message || "state load failed",
});
showToast("Каталог открыт, но профиль загрузился не полностью");
}
} catch (err) {
if (cancelled()) {
return;
}
reportFrontendDiagnostic("bootstrap", err);
const apiHealthy = await checkApiHealth();
setError(buildBootstrapErrorMessage(err, apiHealthy));
setBootStage("bootstrap-error");
setLoading(false);
reportBootstrapMetric("bootstrap.total", performance.now() - bootstrapStartedAt, "error", {
message: err?.message || "bootstrap failed",
});
}
}
async function openCheckoutFlow() {
try {
setCheckoutLoading(true);
const payload = await apiRequest("/checkout/options");
setCheckoutOptions(payload);
setCheckoutDraft((prev) => {
const next = buildCheckoutDraft(payload);
return prev.contact_first_name ? { ...next, ...prev } : next;
});
setCheckoutStep("contact");
setCheckoutOpen(true);
} catch (err) {
showToast(err.message || "Не удалось открыть оформление заказа");
} finally {
setCheckoutLoading(false);
}
}
useEffect(() => {
let cancelled = false;
async function bootstrap() {
await bootstrapApp({ cancelled: () => cancelled });
}
bootstrap();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const webApp = window.Telegram?.WebApp;
if (!webApp) {
return;
}
webApp.ready();
webApp.expand();
try {
webApp.setHeaderColor("#eceef3");
webApp.setBackgroundColor("#eceef3");
} catch (err) {
console.warn("Telegram WebApp styling is not available in this client.", err);
}
}, []);
useEffect(() => {
if (!toast) {
return undefined;
}
const id = window.setTimeout(() => setToast(""), 1800);
return () => window.clearTimeout(id);
}, [toast]);
useEffect(() => {
localStorage.setItem(ADMIN_UI_VERSION_KEY, adminVersion);
}, [adminVersion]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const paymentStatus = params.get("payment");
if (!paymentStatus) {
return;
}
const syncAfterPayment = async () => {
try {
await syncState();
} catch (err) {
console.error("Failed to sync state after payment redirect", err);
}
if (paymentStatus === "success") {
showToast("Оплата прошла успешно");
setActiveTab("profile");
} else if (paymentStatus === "failed") {
showToast("Оплата не была завершена");
setActiveTab("profile");
}
};
void syncAfterPayment();
const nextUrl = `${window.location.pathname}${window.location.hash || ""}`;
window.history.replaceState({}, "", nextUrl);
}, []);
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === "Escape") {
setActiveProductId(null);
setCategorySheetOpen(false);
setCheckoutOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const visibleProducts = useMemo(() => {
let list = [...products];
if (activeTab === "favorites") {
list = list.filter((product) => favoritesSet.has(product.id));
}
if (activeTab === "home" && activeCategory !== "ALL") {
list = list.filter((product) => product.category === activeCategory);
}
if (searchQuery.trim()) {
const query = searchQuery.trim().toLowerCase();
list = list.filter(
(product) =>
product.title.toLowerCase().includes(query) ||
product.category.toLowerCase().includes(query)
);
}
if (sortMode === "priceAsc") {
list.sort((a, b) => a.price - b.price);
}
if (sortMode === "priceDesc") {
list.sort((a, b) => b.price - a.price);
}
return list;
}, [activeTab, activeCategory, favoritesSet, products, searchQuery, sortMode]);
function cycleSort() {
const currentIndex = sortModes.findIndex((mode) => mode.id === sortMode);
const nextMode = sortModes[(currentIndex + 1) % sortModes.length];
setSortMode(nextMode.id);
showToast(nextMode.label);
}
async function toggleFavorite(productId) {
const favorite = !favoritesSet.has(productId);
try {
const payload = await apiRequest("/favorites", {
method: "PUT",
body: JSON.stringify({ product_id: productId, favorite }),
});
applyState(payload);
showToast(favorite ? "Добавили в избранное" : "Убрали из избранного");
} catch (err) {
showToast(err.message || "Не удалось обновить избранное");
}
}
async function saveCartItem(productId, quantity, size) {
try {
const payload = await apiRequest("/cart/items", {
method: "PUT",
body: JSON.stringify({
product_id: productId,
quantity,
size,
}),
});
applyState(payload);
resetPromoPreview();
return true;
} catch (err) {
showToast(err.message || "Не удалось обновить корзину");
return false;
}
}
async function goToCartFromProduct(productId, quantity, size) {
const updated = await saveCartItem(productId, quantity, size || getDefaultSize(productId));
if (!updated) {
return;
}
setActiveProductId(null);
setActiveTab("cart");
}
function openProduct(productId) {
setActiveProductId(productId);
setCategorySheetOpen(false);
}
function closeProduct() {
setActiveProductId(null);
}
function closeCheckout() {
setCheckoutOpen(false);
setCheckoutStep("contact");
}
function nextSlide(productId, direction) {
const product = productMap[productId];
if (!product) {
return;
}
setProductSlides((prev) => ({
...prev,
[productId]: ((prev[productId] || 0) + direction + product.images.length) % product.images.length,
}));
}
function switchTab(tab) {
setActiveProductId(null);
setCategorySheetOpen(false);
setCheckoutOpen(false);
setActiveTab(tab);
if (tab !== "home") {
setActiveCategory("ALL");
setSearchOpen(false);
}
if (tab === "admin" && viewer?.is_admin) {
loadAdminDashboard();
}
}
async function clearCart() {
try {
const payload = await apiRequest("/cart", {
method: "DELETE",
});
applyState(payload);
setCouponCode("");
resetPromoPreview();
showToast("Корзина очищена");
} catch (err) {
showToast(err.message || "Не удалось очистить корзину");
}
}
async function applyPromoCode() {
const normalizedCode = couponCode.trim().toUpperCase();
setCouponCode(normalizedCode);
if (!normalizedCode) {
resetPromoPreview();
showToast("Введите промокод");
return;
}
try {
setApplyingPromo(true);
const payload = await apiRequest("/cart/promo-preview", {
method: "POST",
body: JSON.stringify({
coupon_code: normalizedCode,
}),
});
setPromoPreview(payload);
if (payload.coupon_applied) {
showToast("Промокод применен");
} else if (payload.promo_error) {
showToast(payload.promo_error);
}
} catch (err) {
setPromoPreview({
coupon_code: normalizedCode,
coupon_applied: false,
promo_error: err.message || "Не удалось проверить промокод",
});
showToast(err.message || "Не удалось проверить промокод");
} finally {
setApplyingPromo(false);
}
}
async function checkout() {
await openCheckoutFlow();
}
function updateCheckoutDraft(field, value) {
setCheckoutDraft((prev) => {
const next = { ...prev, [field]: value };
if (field === "delivery_mode") {
next.delivery_code = "";
next.delivery_point_label = "";
next.delivery_method_id = "";
next.delivery_label = "";
next.delivery_address = "";
next.delivery_city_code = "";
next.delivery_office_code = "";
next.delivery_eta = "";
next.delivery_amount = 0;
}
return next;
});
}
function applyWidgetDeliverySelection(selection) {
setCheckoutDraft((prev) => ({
...prev,
...selection,
}));
}
function validateCheckoutStep(stepId) {
if (stepId === "contact") {
if (
!checkoutDraft.contact_first_name.trim() ||
!checkoutDraft.contact_last_name.trim() ||
!checkoutDraft.contact_phone.trim() ||
!checkoutDraft.contact_email.trim() ||
!checkoutDraft.contact_city.trim()
) {
showToast("Заполните контактные данные");
return false;
}
}
if (stepId === "delivery") {
if (!checkoutDraft.delivery_code || !checkoutDraft.delivery_method_id || !checkoutDraft.delivery_address) {
showToast("Выберите доставку через виджет СДЭК");
return false;
}
}
return true;
}
function advanceCheckoutStep() {
if (!validateCheckoutStep(checkoutStep)) {
return;
}
const currentIndex = checkoutSteps.findIndex((item) => item.id === checkoutStep);
const nextStep = checkoutSteps[currentIndex + 1];
if (nextStep) {
setCheckoutStep(nextStep.id);
}
}
function backCheckoutStep() {
if (checkoutStep === "contact") {
closeCheckout();
return;
}
const currentIndex = checkoutSteps.findIndex((item) => item.id === checkoutStep);
const prevStep = checkoutSteps[currentIndex - 1];
if (prevStep) {
setCheckoutStep(prevStep.id);
}
}
async function submitCheckout() {
const normalizedCoupon = couponCode.trim().toUpperCase();
if (normalizedCoupon && !promoPreview?.coupon_applied) {
showToast("Сначала примените промокод");
return;
}
try {
setCheckoutSubmitting(true);
const payload = await apiRequest("/orders/checkout", {
method: "POST",
body: JSON.stringify({
coupon_code: promoPreview?.coupon_applied ? normalizedCoupon : null,
contact_first_name: checkoutDraft.contact_first_name.trim(),
contact_last_name: checkoutDraft.contact_last_name.trim(),
contact_phone: checkoutDraft.contact_phone.trim(),
contact_email: checkoutDraft.contact_email.trim(),
contact_city: checkoutDraft.contact_city.trim(),
order_note: checkoutDraft.order_note.trim(),
delivery_mode: checkoutDraft.delivery_mode,
delivery_code: checkoutDraft.delivery_code,
delivery_method_id: checkoutDraft.delivery_method_id,
delivery_label: checkoutDraft.delivery_label,
delivery_address: checkoutDraft.delivery_address,
delivery_eta: checkoutDraft.delivery_eta || null,
delivery_amount: checkoutDraft.delivery_amount || 0,
payment_method: checkoutDraft.payment_method || "online",
}),
});
applyState(payload.state);
setCouponCode("");
resetPromoPreview();
setCheckoutOpen(false);
setActiveTab("profile");
if (payload.manual_checkout) {
showToast("Заказ оформлен. Мы уже получили уведомление и скоро свяжемся с вами.");
return;
}
const redirectUrl = payload.payment?.redirect_url;
if (!redirectUrl) {
throw new Error("Не удалось подготовить следующий шаг оформления");
}
window.location.href = redirectUrl;
} catch (err) {
showToast(err.message || "Не удалось оформить заказ");
} finally {
setCheckoutSubmitting(false);
}
}
async function resumeOrderPayment(orderId) {
try {
const payload = await apiRequest(`/orders/${orderId}/pay`, {
method: "POST",
});
const redirectUrl = payload.payment?.redirect_url;
if (!redirectUrl) {
throw new Error("Не удалось подготовить ссылку на оплату");
}
window.location.href = redirectUrl;
} catch (err) {
if ((err.message || "").includes("already paid")) {
await syncState();
setActiveTab("profile");
showToast("Заказ уже оплачен");
return;
}
showToast(err.message || "Не удалось открыть оплату");
}
}
async function handleShare() {
if (!activeProduct) {
return;
}
const shareText = `${activeProduct.title} • ${rub(activeProduct.price)}`;
try {
if (navigator.share) {
await navigator.share({
title: activeProduct.title,
text: shareText,
});
} else {
await copyText(shareText);
showToast("Название товара скопировано");
return;
}
} catch (err) {
return;
}
showToast("Поделились товаром");
}
function openSupport() {
if (!supportUrl) {
showToast("Добавьте SHOP_SUPPORT_USERNAME на сервере");
return;
}
const webApp = window.Telegram?.WebApp;
if (webApp?.openTelegramLink) {
webApp.openTelegramLink(supportUrl);
return;
}
window.open(supportUrl, "_blank", "noopener,noreferrer");
}
function toggleAdminOrderSelection(orderId) {
setSelectedAdminOrderIds((prev) =>
prev.includes(orderId) ? prev.filter((item) => item !== orderId) : [...prev, orderId]
);
}
function selectAllAdminOrders() {
setSelectedAdminOrderIds((adminDashboard.orders || []).map((order) => order.id));
}
function clearAdminOrderSelection() {
setSelectedAdminOrderIds([]);
}
async function deleteSelectedAdminOrders() {
if (!selectedAdminOrderIds.length) {
showToast("Выберите заказы для удаления");
return;
}
if (!window.confirm(`Удалить выбранные заказы (${selectedAdminOrderIds.length})?`)) {
return;
}
try {
setDeletingAdminOrders(true);
const response = await apiRequest("/admin/orders/delete", {
method: "POST",
body: JSON.stringify({ order_ids: selectedAdminOrderIds }),
});
setAdminDashboard((prev) => ({ ...prev, orders: response.orders || [] }));
setSelectedAdminOrderIds([]);
showToast(`Удалили заказов: ${(response.deleted_order_ids || []).length}`);
} catch (err) {
showToast(err.message || "Не удалось удалить заказы");
} finally {
setDeletingAdminOrders(false);
}
}
function updatePromoForm(field, value) {
setPromoForm((prev) => ({ ...prev, [field]: value }));
}
function resetPromoForm() {
setPromoForm(emptyPromoForm);
setEditingPromoId(null);
}
function editPromo(promo) {
setEditingPromoId(promo.id);
setPromoForm({
code: promo.code,
discount_type: promo.discount_type,
amount: String(promo.amount),
description: promo.description || "",
usage_limit: promo.usage_limit ? String(promo.usage_limit) : "",
is_active: promo.is_active,
});
setActiveTab("admin");
}
async function savePromo() {
try {
const payload = {
code: promoForm.code.trim(),
discount_type: promoForm.discount_type,
amount: Number(promoForm.amount),
description: promoForm.description.trim() || null,
usage_limit: promoForm.usage_limit ? Number(promoForm.usage_limit) : null,
is_active: promoForm.is_active,
};
if (!payload.code || !payload.amount) {
showToast("Заполните промокод и размер скидки");
return;
}
const path = editingPromoId ? `/admin/promo-codes/${editingPromoId}` : "/admin/promo-codes";
const method = editingPromoId ? "PUT" : "POST";
const response = await apiRequest(path, {
method,
body: JSON.stringify(payload),
});
setAdminDashboard((prev) => ({ ...prev, promoCodes: response.promo_codes || [] }));
resetPromoForm();
showToast(editingPromoId ? "Промокод обновлен" : "Промокод создан");
} catch (err) {
showToast(err.message || "Не удалось сохранить промокод");
}
}
function updateProductForm(field, value) {
setProductForm((prev) => ({ ...prev, [field]: value }));
}
function updateBroadcastForm(field, value) {
setBroadcastForm((prev) => ({ ...prev, [field]: value }));
}
function resetProductForm() {
setProductForm(emptyProductForm);
setEditingProductId(null);
}
function addProductImages(nextImages) {
setProductForm((prev) => ({
...prev,
images: [...prev.images, ...nextImages].filter(Boolean),
}));
}
function removeProductImage(index) {
setProductForm((prev) => ({
...prev,
images: prev.images.filter((_, imageIndex) => imageIndex !== index),
}));
}
function updateProductImageUrl(value) {
setProductForm((prev) => ({ ...prev, imageUrlInput: value }));
}
function addProductImageByUrl() {
const nextUrl = productForm.imageUrlInput.trim();
if (!nextUrl) {
showToast("Вставьте ссылку на изображение");
return;
}
addProductImages([nextUrl]);
setProductForm((prev) => ({ ...prev, imageUrlInput: "" }));
}
async function uploadProductImages(event) {
const files = Array.from(event.target.files || []);
event.target.value = "";
if (!files.length) {
return;
}
try {
setProductUploading(true);
const formData = new FormData();
files.forEach((file) => formData.append("files", file));
const response = await apiRequest("/admin/uploads/product-images", {
method: "POST",
body: formData,
});
addProductImages(response.urls || []);
showToast(files.length > 1 ? "Картинки загружены" : "Картинка загружена");
} catch (err) {
showToast(err.message || "Не удалось загрузить изображения");
} finally {
setProductUploading(false);
}
}
async function saveBotStartMessage() {
const text = botStartMessage.trim();
if (!text) {
showToast("Введите стартовое сообщение");
return;
}
try {
setBotSaving(true);
await apiRequest("/admin/bot/start-message", {
method: "PUT",
body: JSON.stringify({ text }),
});
showToast("Стартовое сообщение сохранено");
await loadAdminDashboard();
} catch (err) {
showToast(err.message || "Не удалось сохранить стартовое сообщение");
} finally {
setBotSaving(false);
}
}
async function uploadBroadcastMedia(event) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
try {
setBroadcastUploading(true);
const formData = new FormData();
formData.append("file", file);
const response = await apiRequest("/admin/uploads/bot-media", {
method: "POST",
body: formData,
});
setBroadcastForm((prev) => ({
...prev,
mediaUrl: response.url || "",
mediaType: response.media_type || "",
mediaName: response.filename || file.name,
}));
showToast("Вложение загружено");
} catch (err) {
showToast(err.message || "Не удалось загрузить вложение");
} finally {
setBroadcastUploading(false);
}
}
function clearBroadcastMedia() {
setBroadcastForm((prev) => ({
...prev,
mediaUrl: "",
mediaType: "",
mediaName: "",
}));
}
function parseTestChatIds() {
return broadcastForm.testChatIds
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => Number(item))
.filter((item) => Number.isInteger(item) && item > 0);
}
async function sendTestBroadcast() {
const chatIds = parseTestChatIds();
if (!chatIds.length) {
showToast("Укажите тестовые chat_id");
return;
}
if (!broadcastForm.text.trim() && !broadcastForm.mediaUrl) {
showToast("Добавьте текст или вложение для тестовой рассылки");
return;
}
try {
setBroadcastSending("test");
const response = await apiRequest("/admin/broadcasts/test", {
method: "POST",
body: JSON.stringify({
text: broadcastForm.text.trim(),
media_url: broadcastForm.mediaUrl || null,
media_type: broadcastForm.mediaType || null,
chat_ids: chatIds,
}),
});
showToast(`Тест отправлен: ${response.delivered_to?.length || 0}`);
} catch (err) {
showToast(err.message || "Не удалось отправить тестовую рассылку");
} finally {
setBroadcastSending("");
}
}
async function sendBroadcast() {
if (!broadcastForm.text.trim() && !broadcastForm.mediaUrl) {
showToast("Добавьте текст или вложение для рассылки");
return;
}
try {
setBroadcastSending("all");
const response = await apiRequest("/admin/broadcasts/send", {
method: "POST",
body: JSON.stringify({
text: broadcastForm.text.trim(),
media_url: broadcastForm.mediaUrl || null,
media_type: broadcastForm.mediaType || null,
}),
});
showToast(`Рассылка отправлена: ${response.delivered_to?.length || 0}`);
} catch (err) {
showToast(err.message || "Не удалось отправить рассылку");
} finally {
setBroadcastSending("");
}
}
function updateChannelPostForm(field, value) {
setChannelPostForm((prev) => ({ ...prev, [field]: value }));
}
async function sendChannelPost() {
const text = channelPostForm.text.trim();
const channelUsername = channelPostForm.channelUsername.trim();
if (!channelUsername) {
showToast("Укажите username канала");
return;
}
if (!text) {
showToast("Добавьте текст поста");
return;
}
try {
setChannelPostSending(true);
const response = await apiRequest("/admin/channel-posts/send", {
method: "POST",
body: JSON.stringify({
text,
channel_username: channelUsername,
}),
});
const destination = response.channel_username || channelUsername;
const messageMeta = response.message_id ? `, message_id ${response.message_id}` : "";
showToast(`Пост отправлен в ${destination}${messageMeta}`);
} catch (err) {
showToast(err.message || "Не удалось отправить пост в канал");
} finally {
setChannelPostSending(false);
}
}
function updateReminderPoolForm(field, value) {
setReminderPoolForm((prev) => ({ ...prev, [field]: value }));
}
function updateReminderStep(index, field, value) {
setReminderPoolForm((prev) => ({
...prev,
steps: prev.steps.map((step, stepIndex) =>
stepIndex === index
? {
...step,
[field]: value,
}
: step
),
}));
}
function addReminderStep() {
setReminderPoolForm((prev) => {
const lastDelay = Number(prev.steps[prev.steps.length - 1]?.delay_minutes || 0);
return {
...prev,
steps: [...prev.steps, createEmptyReminderStep(String(Math.max(1, lastDelay + 60)))],
};
});
}
function removeReminderStep(index) {
setReminderPoolForm((prev) => ({
...prev,
steps: prev.steps.filter((_, stepIndex) => stepIndex !== index),
}));
}
function resetReminderPoolForm() {
setEditingReminderPoolId(null);
setReminderPoolForm(makeReminderPoolForm());
}
function editReminderPool(pool) {
setEditingReminderPoolId(pool.id);
setReminderPoolForm(makeReminderPoolForm(pool));
setActiveTab("admin");
}
async function saveReminderPool() {
const steps = reminderPoolForm.steps
.map((step) => ({
id: step.id || null,
delay_minutes: Number(step.delay_minutes),
text: step.text.trim(),
is_active: step.is_active,
}))
.filter((step) => step.text);
if (!reminderPoolForm.title.trim()) {
showToast("Укажите название пула");
return;
}
if (!steps.length) {
showToast("Добавьте хотя бы одно сообщение в пул");
return;
}
if (steps.some((step) => !Number.isFinite(step.delay_minutes) || step.delay_minutes < 1)) {
showToast("Укажите корректную задержку в секундах для каждого сообщения");
return;
}
try {
setReminderSaving(true);
const path = editingReminderPoolId
? `/admin/reminder-pools/${editingReminderPoolId}`
: "/admin/reminder-pools";
const method = editingReminderPoolId ? "PUT" : "POST";
const response = await apiRequest(path, {
method,
body: JSON.stringify({
title: reminderPoolForm.title.trim(),
is_active: reminderPoolForm.is_active,
is_default: reminderPoolForm.is_default,
steps,
}),
});
const reminderPools = response.reminder_pools || [];
const savedPool =
reminderPools.find((pool) => pool.id === (response.pool_id || editingReminderPoolId)) ||
reminderPools[0] ||
null;
setAdminDashboard((prev) => ({
...prev,
bot: {
...(prev.bot || {}),
reminder_pools: reminderPools,
active_reminder_pool_id: response.active_reminder_pool_id ?? prev.bot?.active_reminder_pool_id ?? null,
},
}));
setEditingReminderPoolId(response.pool_id || editingReminderPoolId);
setReminderPoolForm(makeReminderPoolForm(savedPool));
showToast(editingReminderPoolId ? "Пул напоминаний обновлен" : "Пул напоминаний создан");
} catch (err) {
showToast(err.message || "Не удалось сохранить пул напоминаний");
} finally {
setReminderSaving(false);
}
}
function editProduct(product) {
setEditingProductId(product.id);
setProductForm({
title: product.title,
price: String(product.price),
category: product.category,
sort_order: String(product.sort_order || 0),
is_active: Boolean(product.is_active),
images: [...(product.images || [])],
imageUrlInput: "",
sizesText: (product.sizes || []).join("\n"),
descriptionText: (product.description || []).join("\n"),
});
setActiveTab("admin");
}
async function saveProduct() {
try {
const payload = {
title: productForm.title.trim(),
price: Number(productForm.price),
category: productForm.category.trim(),
sort_order: Number(productForm.sort_order || 0),
is_active: productForm.is_active,
images: productForm.images.map((item) => item.trim()).filter(Boolean),
sizes: productForm.sizesText.split("\n").map((item) => item.trim()).filter(Boolean),
description: productForm.descriptionText.split("\n").map((item) => item.trim()).filter(Boolean),
};
if (!payload.title || !payload.category || !payload.price) {
showToast("Заполните название, цену и категорию");
return;
}
if (!payload.images.length) {
showToast("Добавьте хотя бы одно изображение товара");
return;
}
const path = editingProductId ? `/admin/products/${editingProductId}` : "/admin/products";
const method = editingProductId ? "PUT" : "POST";
const response = await apiRequest(path, {
method,
body: JSON.stringify(payload),
});
const nextProducts = response.products || [];
setAdminDashboard((prev) => ({ ...prev, products: nextProducts }));
setProducts(nextProducts.filter((item) => item.is_active));
resetProductForm();
showToast(editingProductId ? "Товар обновлен" : "Товар создан");
} catch (err) {
showToast(err.message || "Не удалось сохранить товар");
}
}
async function toggleProduct(product) {
try {
const response = await apiRequest(`/admin/products/${product.id}`, {
method: "PUT",
body: JSON.stringify({
title: product.title,
price: product.price,
category: product.category,
sort_order: product.sort_order || 0,
is_active: !product.is_active,
images: product.images || [],
sizes: product.sizes || [],
description: product.description || [],
}),
});
const nextProducts = response.products || [];
setAdminDashboard((prev) => ({ ...prev, products: nextProducts }));
setProducts(nextProducts.filter((item) => item.is_active));
showToast(product.is_active ? "Товар скрыт" : "Товар снова видим");
} catch (err) {
showToast(err.message || "Не удалось изменить видимость товара");
}
}
const categoryButtons = categories
.filter((category) => category !== "ALL")
.map((category) => (
{
setActiveCategory(category);
setActiveTab("home");
}}
>
{category}
));
const currentProductSize = activeProduct ? getDefaultSize(activeProduct.id) : "";
const currentProductQty = activeProduct ? Math.max(1, getDraftQuantity(activeProduct.id, currentProductSize)) : 1;
return (
{activeTab === "home" ? (
setSearchOpen((prev) => !prev)}
onSearchChange={setSearchQuery}
onOpenCategories={() => setCategorySheetOpen(true)}
onCycleSort={cycleSort}
onOpenProfile={() => switchTab("profile")}
categoryButtons={categoryButtons}
/>
) : null}
{loading ? (
Открываем магазин
Еще чуть-чуть, загружаем каталог и ваш профиль.
) : null}
{!loading && error ? (
Не удалось подключиться
{error}
{
await bootstrapApp();
}}
>
Попробовать снова
) : null}
{!loading && !error && activeTab === "favorites" && !visibleProducts.length ? (
switchTab("home")} />
) : null}
{!loading && !error && activeTab === "cart" ? (
saveCartItem(item.product_id, item.quantity + 1, item.size)}
onDecrement={(item) => saveCartItem(item.product_id, Math.max(0, item.quantity - 1), item.size)}
onCheckout={checkout}
/>
) : null}
{!loading && !error && activeTab === "profile" ? (
switchTab("admin")}
onResumeOrderPayment={resumeOrderPayment}
/>
) : null}
{!loading && !error && activeTab === "admin" && viewer?.is_admin ? (
) : null}
{!loading &&
!error &&
(activeTab === "home" || (activeTab === "favorites" && visibleProducts.length))
? visibleProducts.map((product) => (
))
: null}
{navItems.map((item) => (
switchTab(item.id)}
>
{item.id === "cart" && cartCount ? {cartCount} : null}
{item.label}
))}
{categorySheetOpen ? (
setCategorySheetOpen(false)}
onSelect={(category) => {
setActiveCategory(category);
setActiveTab("home");
setCategorySheetOpen(false);
}}
/>
) : null}
activeProduct && nextSlide(activeProduct.id, -1)}
onNextSlide={() => activeProduct && nextSlide(activeProduct.id, 1)}
onToggleFavorite={toggleFavorite}
onSelectSize={(size) => {
if (!activeProduct) return;
setSelectedSize((prev) => ({
...prev,
[activeProduct.id]: size,
}));
}}
onQtyMinus={() => {
if (!activeProduct) return;
setDraftQuantities((prev) => ({
...prev,
[makeCartKey(activeProduct.id, currentProductSize)]: Math.max(1, currentProductQty - 1),
}));
}}
onQtyPlus={() => {
if (!activeProduct) return;
setDraftQuantities((prev) => ({
...prev,
[makeCartKey(activeProduct.id, currentProductSize)]: currentProductQty + 1,
}));
}}
onGoToCart={async () => {
if (!activeProduct) return;
await goToCartFromProduct(activeProduct.id, currentProductQty, currentProductSize);
}}
onShare={handleShare}
onShowSizeChart={() => {
if (!activeProduct) return;
setProductSlides((prev) => ({
...prev,
[activeProduct.id]: activeProduct.images.length - 1,
}));
}}
onContactSeller={openSupport}
/>
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );