const API_BASE = "/api/v1"; const TOKEN_KEY = "secureshop_token"; const CART_KEY = "secureshop_cart"; const artwork = [ "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=640&q=80", "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=640&q=80", "https://images.unsplash.com/photo-1523275335684-37898b6baf30?auto=format&fit=crop&w=640&q=80", "https://images.unsplash.com/photo-1609091839311-d5365f9ff1c5?auto=format&fit=crop&w=640&q=80", "https://images.unsplash.com/photo-1587829741301-dc798b83add3?auto=format&fit=crop&w=640&q=80", "https://images.unsplash.com/photo-1553062407-98eeb64c6a62?auto=format&fit=crop&w=640&q=80", "https://images.unsplash.com/photo-1517668808822-9ebb02f2a0e6?auto=format&fit=crop&w=640&q=80", "https://images.unsplash.com/photo-1507473885765-e6ed057f782c?auto=format&fit=crop&w=640&q=80", "https://images.unsplash.com/photo-1587614295999-6c1c1367514e?auto=format&fit=crop&w=640&q=80", "https://images.unsplash.com/photo-1545454675-3531b543be5d?auto=format&fit=crop&w=640&q=80", "https://images.unsplash.com/photo-1542291026-7eec264c27ff?auto=format&fit=crop&w=640&q=80", "https://images.unsplash.com/photo-1544244015-0df4b3ffc6b0?auto=format&fit=crop&w=640&q=80", ]; const state = { activeView: "catalog", authMode: "login", token: localStorage.getItem(TOKEN_KEY), user: null, products: [], orders: [], cart: loadCart(), search: "", sort: "new", }; const elements = { tabs: document.querySelectorAll(".tab"), viewTitle: document.querySelector("#viewTitle"), sessionPill: document.querySelector("#sessionPill"), catalogMetric: document.querySelector("#catalogMetric"), cartMetric: document.querySelector("#cartMetric"), healthMetric: document.querySelector("#healthMetric"), searchInput: document.querySelector("#searchInput"), sortSelect: document.querySelector("#sortSelect"), productGrid: document.querySelector("#productGrid"), ordersList: document.querySelector("#ordersList"), productForm: document.querySelector("#productForm"), shopGate: document.querySelector("#shopGate"), authPanel: document.querySelector("#authPanel"), cartList: document.querySelector("#cartList"), cartTotal: document.querySelector("#cartTotal"), checkoutButton: document.querySelector("#checkoutButton"), clearCartButton: document.querySelector("#clearCartButton"), toast: document.querySelector("#toast"), }; function loadCart() { try { const parsed = JSON.parse(localStorage.getItem(CART_KEY) || "{}"); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } } function saveCart() { localStorage.setItem(CART_KEY, JSON.stringify(state.cart)); } function money(value) { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(Number(value || 0)); } function dateTime(value) { return new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "short", }).format(new Date(value)); } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function showToast(message) { elements.toast.textContent = message; elements.toast.classList.add("is-visible"); window.clearTimeout(showToast.timeoutId); showToast.timeoutId = window.setTimeout(() => { elements.toast.classList.remove("is-visible"); }, 3600); } async function apiRequest(path, options = {}) { const headers = { Accept: "application/json", ...(options.headers || {}), }; if (state.token) { headers.Authorization = `Bearer ${state.token}`; } if (options.body && !headers["Content-Type"]) { headers["Content-Type"] = "application/json"; } const response = await fetch(`${API_BASE}${path}`, { ...options, headers, }); const raw = await response.text(); const data = raw ? JSON.parse(raw) : null; if (!response.ok) { throw new Error(data?.detail || "Запрос не выполнен"); } return data; } async function checkHealth() { try { const response = await fetch("/health", { headers: { Accept: "application/json" } }); elements.healthMetric.textContent = response.ok ? "API online" : "API error"; } catch { elements.healthMetric.textContent = "API offline"; } } async function loadProducts() { state.products = await apiRequest("/products"); syncCartProducts(); } async function loadOrders() { if (!state.token) { state.orders = []; return; } state.orders = await apiRequest("/orders"); } async function loadProfile() { if (!state.token) { state.user = null; return; } state.user = await apiRequest("/auth/me"); } function syncCartProducts() { const productsById = new Map(state.products.map((product) => [String(product.id), product])); Object.keys(state.cart).forEach((id) => { const product = productsById.get(id); if (!product) { delete state.cart[id]; return; } state.cart[id].product = product; state.cart[id].quantity = Math.min(state.cart[id].quantity, product.stock); if (state.cart[id].quantity < 1) { delete state.cart[id]; } }); saveCart(); } function filteredProducts() { const query = state.search.trim().toLowerCase(); const products = state.products.filter((product) => { const haystack = `${product.name} ${product.description || ""}`.toLowerCase(); return !query || haystack.includes(query); }); products.sort((a, b) => { if (state.sort === "price-asc") return Number(a.price) - Number(b.price); if (state.sort === "price-desc") return Number(b.price) - Number(a.price); if (state.sort === "stock") return Number(b.stock) - Number(a.stock); return new Date(b.created_at) - new Date(a.created_at); }); return products; } function setView(viewName) { state.activeView = viewName; document.querySelectorAll(".view").forEach((view) => view.classList.remove("is-active")); document.querySelector(`#${viewName}View`).classList.add("is-active"); elements.tabs.forEach((tab) => { tab.classList.toggle("is-active", tab.dataset.view === viewName); }); const titles = { catalog: "Каталог товаров", orders: "Мои заказы", shop: "Панель магазина", }; elements.viewTitle.textContent = titles[viewName]; if (viewName === "orders") { refreshOrders(); } render(); } function render() { renderSession(); renderMetrics(); renderCatalog(); renderAuthPanel(); renderCart(); renderOrders(); renderShopGate(); } function renderSession() { if (!state.user) { elements.sessionPill.textContent = "Гость"; return; } elements.sessionPill.textContent = `${state.user.username} · ${state.user.role}`; } function renderMetrics() { const cartQuantity = Object.values(state.cart).reduce((sum, item) => sum + item.quantity, 0); elements.catalogMetric.textContent = `${state.products.length} товаров`; elements.cartMetric.textContent = `${cartQuantity} в корзине`; } function renderCatalog() { const products = filteredProducts(); if (!products.length) { elements.productGrid.innerHTML = `
Товары не найдены
`; return; } elements.productGrid.innerHTML = products .map((product) => { const description = product.description || "Товар доступен для заказа"; const image = artwork[product.id % artwork.length]; return `
${escapeHtml(product.name)}

${escapeHtml(product.name)}

${money(product.price)}
${product.stock} шт.

${escapeHtml(description)}

`; }) .join(""); } function renderAuthPanel() { if (state.user) { elements.authPanel.innerHTML = `

Аккаунт

${escapeHtml(state.user.role)}
${escapeHtml(state.user.username)} ID ${state.user.id}
`; return; } const isRegister = state.authMode === "register"; elements.authPanel.innerHTML = `
`; } function renderCart() { const items = Object.values(state.cart); if (!items.length) { elements.cartList.innerHTML = `
Корзина пуста
`; elements.cartTotal.textContent = money(0); elements.checkoutButton.disabled = true; return; } elements.cartList.innerHTML = items .map(({ product, quantity }) => `
${escapeHtml(product.name)} ${quantity} × ${money(product.price)}
${quantity}
`) .join(""); const total = items.reduce((sum, item) => sum + Number(item.product.price) * item.quantity, 0); elements.cartTotal.textContent = money(total); elements.checkoutButton.disabled = false; } function renderOrders() { if (!state.user) { elements.ordersList.innerHTML = `
Войдите, чтобы увидеть свои заказы
`; return; } if (!state.orders.length) { elements.ordersList.innerHTML = `
Заказов пока нет
`; return; } elements.ordersList.innerHTML = state.orders .map((order) => `

Заказ #${order.id}

${dateTime(order.created_at)}
${order.status}
${order.items .map((item) => `
${escapeHtml(item.product_name)} × ${item.quantity} ${money(item.subtotal)}
`) .join("")}
`) .join(""); } function renderShopGate() { const isShop = state.user?.role === "shop"; elements.productForm.style.display = isShop ? "block" : "none"; elements.shopGate.classList.toggle("is-visible", !isShop); if (!isShop) { elements.shopGate.innerHTML = `
${state.user ? "Доступ к созданию товаров открыт только роли shop" : "Войдите как shop, чтобы управлять каталогом"}
`; } else { elements.shopGate.innerHTML = ""; } } function addToCart(productId, quantity) { const product = state.products.find((item) => item.id === Number(productId)); if (!product) return; const id = String(product.id); const current = state.cart[id]?.quantity || 0; const nextQuantity = Math.min(product.stock, current + quantity); state.cart[id] = { product, quantity: nextQuantity }; saveCart(); render(); showToast(`${product.name} добавлен в корзину`); } function changeCartQuantity(productId, delta) { const item = state.cart[String(productId)]; if (!item) return; const nextQuantity = item.quantity + delta; if (nextQuantity < 1) { delete state.cart[String(productId)]; } else { item.quantity = Math.min(nextQuantity, item.product.stock); } saveCart(); render(); } async function refreshOrders() { try { await loadOrders(); renderOrders(); } catch (error) { showToast(error.message); } } async function submitAuth(form) { const formData = new FormData(form); const username = String(formData.get("username") || "").trim().toLowerCase(); const password = String(formData.get("password") || ""); if (state.authMode === "register" && !isStrongPassword(password)) { showToast("Пароль должен содержать верхний и нижний регистр, цифру и спецсимвол"); return; } try { if (state.authMode === "register") { await apiRequest("/auth/register", { method: "POST", body: JSON.stringify({ username, password }), }); } const tokenResponse = await apiRequest("/auth/login", { method: "POST", body: JSON.stringify({ username, password }), }); state.token = tokenResponse.access_token; localStorage.setItem(TOKEN_KEY, state.token); await loadProfile(); await loadOrders(); render(); showToast(`Добро пожаловать, ${state.user.username}`); } catch (error) { showToast(error.message); } } function isStrongPassword(value) { return ( value.length >= 12 && /[A-Z]/.test(value) && /[a-z]/.test(value) && /\d/.test(value) && /[^A-Za-z0-9]/.test(value) ); } async function logout() { state.token = null; state.user = null; state.orders = []; localStorage.removeItem(TOKEN_KEY); render(); showToast("Сессия завершена"); } async function checkout() { if (!state.user) { showToast("Сначала войдите в аккаунт"); return; } const items = Object.values(state.cart).map(({ product, quantity }) => ({ product_id: product.id, quantity, })); if (!items.length) return; try { await apiRequest("/orders", { method: "POST", body: JSON.stringify({ items }), }); state.cart = {}; saveCart(); await loadProducts(); await loadOrders(); setView("orders"); showToast("Заказ создан"); } catch (error) { showToast(error.message); } } async function payOrder(orderId) { try { await apiRequest(`/payments/orders/${orderId}/confirm`, { method: "POST" }); await loadOrders(); renderOrders(); showToast("Оплата подтверждена"); } catch (error) { showToast(error.message); } } async function createProduct(form) { if (state.user?.role !== "shop") { showToast("Недостаточно прав"); return; } const formData = new FormData(form); const description = String(formData.get("description") || "").trim(); const payload = { name: String(formData.get("name") || "").trim(), description: description || null, price: String(formData.get("price") || "0"), stock: Number(formData.get("stock") || 0), is_active: true, }; try { await apiRequest("/products", { method: "POST", body: JSON.stringify(payload), }); form.reset(); await loadProducts(); render(); showToast("Товар добавлен"); } catch (error) { showToast(error.message); } } function bindEvents() { elements.tabs.forEach((tab) => { tab.addEventListener("click", () => setView(tab.dataset.view)); }); elements.searchInput.addEventListener("input", (event) => { state.search = event.target.value; renderCatalog(); }); elements.sortSelect.addEventListener("change", (event) => { state.sort = event.target.value; renderCatalog(); }); elements.productGrid.addEventListener("click", (event) => { const button = event.target.closest("[data-add]"); if (!button) return; const productId = button.dataset.add; const qtyInput = elements.productGrid.querySelector(`[data-qty="${productId}"]`); const quantity = Math.max(1, Number(qtyInput?.value || 1)); addToCart(productId, quantity); }); elements.authPanel.addEventListener("click", (event) => { const authModeButton = event.target.closest("[data-auth-mode]"); if (authModeButton) { state.authMode = authModeButton.dataset.authMode; renderAuthPanel(); return; } if (event.target.closest("[data-logout]")) { logout(); } }); elements.authPanel.addEventListener("submit", (event) => { if (event.target.id !== "authForm") return; event.preventDefault(); submitAuth(event.target); }); elements.cartList.addEventListener("click", (event) => { const increase = event.target.closest("[data-increase]"); const decrease = event.target.closest("[data-decrease]"); if (increase) changeCartQuantity(increase.dataset.increase, 1); if (decrease) changeCartQuantity(decrease.dataset.decrease, -1); }); elements.checkoutButton.addEventListener("click", checkout); elements.clearCartButton.addEventListener("click", () => { state.cart = {}; saveCart(); render(); }); elements.ordersList.addEventListener("click", (event) => { const payButton = event.target.closest("[data-pay]"); if (payButton) payOrder(payButton.dataset.pay); }); elements.productForm.addEventListener("submit", (event) => { event.preventDefault(); createProduct(event.target); }); } async function init() { bindEvents(); render(); await checkHealth(); try { if (state.token) { await loadProfile(); await loadOrders(); } } catch { localStorage.removeItem(TOKEN_KEY); state.token = null; state.user = null; } try { await loadProducts(); } catch (error) { showToast(error.message); } render(); } init();