const { useEffect, useMemo, useRef, useState } = React;
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 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,
imagesText: "",
sizesText: "S\nM\nL\nXL",
descriptionText: "",
};
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";
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 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);
}
async function apiRequest(path, options = {}) {
const headers = {
"Content-Type": "application/json",
...(options.headers || {}),
};
if (!options.skipAuth) {
const token = getStoredToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
}
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers,
});
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();
}
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 (
{product.images.map((_, index) => (
))}
);
}
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 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 initWidget() {
if (!open || !widgetConfig.configured) {
return;
}
setWidgetLoading(true);
setWidgetError("");
try {
const CDEKWidget = await ensureCdekWidgetScript();
if (cancelled) {
return;
}
if (widgetRef.current?.destroy) {
widgetRef.current.destroy();
}
widgetRef.current = new CDEKWidget({
apiKey: widgetConfig.api_key,
servicePath: widgetConfig.service_path,
popup: true,
canChoose: true,
defaultLocation: draft.contact_city || widgetConfig.default_location || "Чебоксары",
from: widgetConfig.from,
goods: widgetConfig.goods || [],
lang: "rus",
currency: "RUB",
hideDeliveryOptions:
draft.delivery_mode === "pickup" ? { door: true } : { office: true },
onChoose(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),
});
try {
this.close();
} catch (err) {
console.warn("Не удалось закрыть виджет СДЭК автоматически.", err);
}
},
});
} catch (err) {
if (!cancelled) {
setWidgetError(err.message || "Не удалось инициализировать виджет СДЭК");
}
} finally {
if (!cancelled) {
setWidgetLoading(false);
}
}
}
initWidget();
return () => {
cancelled = true;
if (widgetRef.current?.destroy) {
widgetRef.current.destroy();
}
widgetRef.current = null;
};
}, [
open,
widgetConfig.configured,
widgetConfig.api_key,
widgetConfig.service_path,
widgetConfig.from,
widgetConfig.goods,
widgetConfig.default_location,
draft.contact_city,
draft.delivery_mode,
]);
if (!open || !options) {
return null;
}
function openDeliveryWidget() {
if (!widgetConfig.configured) {
setWidgetError(
"Добавьте на сервере YANDEX_MAPS_API_KEY, CDEK_INTEGRATION_ACCOUNT и CDEK_INTEGRATION_PASSWORD, чтобы включить выбор доставки."
);
return;
}
if (!widgetRef.current?.open) {
setWidgetError("Виджет СДЭК еще не успел загрузиться. Попробуйте еще раз через секунду.");
return;
}
setWidgetError("");
widgetRef.current.open();
}
return (
{checkoutSteps.map((item, index) => (
))}
{step === "contact" ? (
Контактные данные
Эти данные нужны, чтобы мы могли оформить и доставить ваш заказ.
) : null}
{step === "delivery" ? (
{draft.delivery_mode === "pickup" ? "СДЭК • ПВЗ" : "СДЭК • КУРЬЕР"}
{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" ? (
Оплата
Способ оплаты
) : 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())}
/>
{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" ? (
) : null}
{step === "shipping" ? (
) : null}
{step === "payment" ? (
) : null}
{step === "review" ? (
) : 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 || "Единый"}
{item.quantity}
{rub(product.price * item.quantity)}
);
})}
onCouponChange(event.target.value.toUpperCase())}
/>
{promoPreview?.promo_error ? (
{promoPreview.promo_error}
) : null}
{promoPreview?.coupon_applied ? (
Промокод {appliedCouponCode} применен, скидка {rub(discountAmount)}
) : null}
Товары ({totalItems})
{rub(subtotalAmount)}
{discountAmount > 0 ? (
Скидка
-{rub(discountAmount)}
) : null}
Итого
{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" ? (
) : null}
))}
) : (
У вас пока нет заказов. Когда оформите первую покупку, она появится здесь.
)}
);
}
function AdminScreen({
dashboard,
loading,
promoForm,
productForm,
editingPromoId,
editingProductId,
onPromoFormChange,
onProductFormChange,
onSavePromo,
onEditPromo,
onResetPromo,
onSaveProduct,
onEditProduct,
onToggleProduct,
onResetProduct,
onRefresh,
}) {
if (loading) {
return (
Загружаем данные
Еще немного, и все будет готово.
);
}
return (
Админка
Заказы, промокоды и управление товарами
Все заказы
{dashboard.orders.length ? (
dashboard.orders.map((order) => (
Заказ #{order.id}
{order.customer.display_name}
{order.customer.username ? ` • @${order.customer.username}` : ""}
{rub(order.total_amount)}
{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 CategorySheet({ categories, activeCategory, onSelect, onClose }) {
return (
<>
Категории
{categories.map((category) => (
))}
>
);
}
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) => (
))}
Размер одежды: {selectedSize}
{product.sizes.map((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: "manual",
});
const [adminDashboard, setAdminDashboard] = useState({
orders: [],
promoCodes: [],
products: [],
});
const [adminLoading, setAdminLoading] = useState(false);
const [promoForm, setPromoForm] = useState(emptyPromoForm);
const [productForm, setProductForm] = useState(emptyProductForm);
const [editingPromoId, setEditingPromoId] = useState(null);
const [editingProductId, setEditingProductId] = 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 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: "manual",
};
}
async function loadAdminDashboard() {
setAdminLoading(true);
try {
const payload = await apiRequest("/admin/dashboard");
setAdminDashboard({
orders: payload.orders || [],
promoCodes: payload.promo_codes || [],
products: payload.products || [],
});
} catch (err) {
showToast(err.message || "Не удалось загрузить админку");
} finally {
setAdminLoading(false);
}
}
async function authenticate() {
const existingToken = getStoredToken();
if (existingToken) {
try {
const session = await apiRequest("/me");
setViewer(session.user || null);
setSupportUrl(session.support_url || null);
setSupportUsername(session.support_username || null);
return;
} catch (err) {
clearStoredToken();
}
}
const initData = getTelegramInitData();
if (initData) {
try {
const authPayload = await apiRequest("/auth/telegram", {
method: "POST",
body: JSON.stringify({ init_data: initData }),
skipAuth: true,
});
setStoredToken(authPayload.token);
setViewer(authPayload.user || null);
setSupportUrl(authPayload.support_url || null);
setSupportUsername(authPayload.support_username || null);
return;
} catch (err) {
const message = err?.message || "";
if (!isLocalPreview()) {
throw err;
}
console.warn("Telegram auth failed in local preview, falling back to dev auth.", message);
}
}
if (!isLocalPreview()) {
throw new Error("Откройте миниапп внутри Telegram, чтобы пройти авторизацию");
}
const devPayload = await apiRequest("/auth/dev", {
method: "POST",
body: JSON.stringify({ display_name: "Локальный тест" }),
skipAuth: true,
});
setStoredToken(devPayload.token);
setViewer(devPayload.user || null);
setSupportUrl(devPayload.support_url || null);
setSupportUsername(devPayload.support_username || null);
}
async function syncState() {
const payload = await apiRequest("/state");
applyState(payload);
}
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() {
try {
setLoading(true);
await authenticate();
const [productsPayload, statePayload] = await Promise.all([
apiRequest("/products"),
apiRequest("/state"),
]);
if (cancelled) {
return;
}
setProducts(productsPayload.products || []);
applyState(statePayload);
setError("");
} catch (err) {
if (!cancelled) {
setError(err.message || "Не удалось загрузить данные");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
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(() => {
const params = new URLSearchParams(window.location.search);
const paymentStatus = params.get("payment");
if (!paymentStatus) {
return;
}
if (paymentStatus === "success") {
showToast("Оплата прошла успешно");
setActiveTab("profile");
} else if (paymentStatus === "failed") {
showToast("Оплата не была завершена");
setActiveTab("profile");
}
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: "manual",
}),
});
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) {
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 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 resetProductForm() {
setProductForm(emptyProductForm);
setEditingProductId(null);
}
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),
imagesText: (product.images || []).join("\n"),
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.imagesText.split("\n").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("Заполните title, price и category");
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) => (
));
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}
) : 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}
{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();