Files
secure-online-shop/app/static/app.js
T

631 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 = `<div class="empty-state">Товары не найдены</div>`;
return;
}
elements.productGrid.innerHTML = products
.map((product) => {
const description = product.description || "Товар доступен для заказа";
const image = artwork[product.id % artwork.length];
return `
<article class="product-card">
<img src="${image}" alt="${escapeHtml(product.name)}" loading="lazy" />
<div class="product-body">
<div>
<div class="product-title-row">
<h3>${escapeHtml(product.name)}</h3>
<span class="price">${money(product.price)}</span>
</div>
<span class="stock">${product.stock} шт.</span>
</div>
<p>${escapeHtml(description)}</p>
<div class="card-actions">
<input aria-label="Количество ${escapeHtml(product.name)}" type="number" min="1" max="${product.stock}" value="1" data-qty="${product.id}" />
<button class="primary-action" type="button" data-add="${product.id}">В корзину</button>
</div>
</div>
</article>
`;
})
.join("");
}
function renderAuthPanel() {
if (state.user) {
elements.authPanel.innerHTML = `
<div class="panel-title">
<h2>Аккаунт</h2>
<span class="role-badge">${escapeHtml(state.user.role)}</span>
</div>
<div class="account-card">
<div class="account-name">
<strong>${escapeHtml(state.user.username)}</strong>
<small>ID ${state.user.id}</small>
</div>
<button class="ghost-action" type="button" data-logout>Выйти</button>
</div>
`;
return;
}
const isRegister = state.authMode === "register";
elements.authPanel.innerHTML = `
<div class="auth-tabs">
<button type="button" class="${state.authMode === "login" ? "is-active" : ""}" data-auth-mode="login">Вход</button>
<button type="button" class="${isRegister ? "is-active" : ""}" data-auth-mode="register">Регистрация</button>
</div>
<form class="auth-form" id="authForm">
<label>
Логин
<input name="username" required minlength="3" maxlength="50" autocomplete="username" placeholder="client01" />
</label>
<label>
Пароль
<input name="password" required minlength="${isRegister ? 12 : 8}" maxlength="72" type="password" autocomplete="${isRegister ? "new-password" : "current-password"}" placeholder="${isRegister ? "StrongPass1!" : "password"}" />
</label>
<button class="primary-action" type="submit">${isRegister ? "Создать аккаунт" : "Войти"}</button>
</form>
`;
}
function renderCart() {
const items = Object.values(state.cart);
if (!items.length) {
elements.cartList.innerHTML = `<div class="empty-state">Корзина пуста</div>`;
elements.cartTotal.textContent = money(0);
elements.checkoutButton.disabled = true;
return;
}
elements.cartList.innerHTML = items
.map(({ product, quantity }) => `
<div class="cart-row">
<div>
<strong>${escapeHtml(product.name)}</strong>
<small>${quantity} × ${money(product.price)}</small>
</div>
<div class="cart-controls">
<button class="icon-button" type="button" aria-label="Уменьшить" data-decrease="${product.id}"></button>
<span>${quantity}</span>
<button class="icon-button" type="button" aria-label="Увеличить" data-increase="${product.id}">+</button>
</div>
</div>
`)
.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 = `<div class="empty-state">Войдите, чтобы увидеть свои заказы</div>`;
return;
}
if (!state.orders.length) {
elements.ordersList.innerHTML = `<div class="empty-state">Заказов пока нет</div>`;
return;
}
elements.ordersList.innerHTML = state.orders
.map((order) => `
<article class="order-card">
<div class="order-head">
<div>
<h3>Заказ #${order.id}</h3>
<small>${dateTime(order.created_at)}</small>
</div>
<span class="status-badge ${order.status}">${order.status}</span>
</div>
<div class="order-items">
${order.items
.map((item) => `
<div class="order-item">
<span>${escapeHtml(item.product_name)} × ${item.quantity}</span>
<strong>${money(item.subtotal)}</strong>
</div>
`)
.join("")}
</div>
<div class="order-footer">
<strong>${money(order.total_amount)}</strong>
${
order.status === "pending"
? `<button class="secondary-action" type="button" data-pay="${order.id}">Оплатить</button>`
: `<span class="role-badge">Оплачен</span>`
}
</div>
</article>
`)
.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 = `
<div class="empty-state">
${state.user ? "Доступ к созданию товаров открыт только роли shop" : "Войдите как shop, чтобы управлять каталогом"}
</div>
`;
} 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();