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)}
${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();