sync: migrate secure-online-shop to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,630 @@
|
||||
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 = `<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();
|
||||
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>SecureShop MVP</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="SecureShop">
|
||||
<span class="brand-mark">S</span>
|
||||
<span>
|
||||
<strong>SecureShop</strong>
|
||||
<small>защищенный online-shop MVP</small>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="tabs" aria-label="Разделы">
|
||||
<button class="tab is-active" type="button" data-view="catalog">Каталог</button>
|
||||
<button class="tab" type="button" data-view="orders">Заказы</button>
|
||||
<button class="tab" type="button" data-view="shop">Магазин</button>
|
||||
</nav>
|
||||
|
||||
<div class="session-pill" id="sessionPill">Гость</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="workspace" aria-live="polite">
|
||||
<div class="workspace-head">
|
||||
<div>
|
||||
<p class="eyebrow">Retail security flow</p>
|
||||
<h1 id="viewTitle">Каталог товаров</h1>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<span id="catalogMetric">0 товаров</span>
|
||||
<span id="cartMetric">0 в корзине</span>
|
||||
<span id="healthMetric">API</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="view is-active" id="catalogView" aria-label="Каталог">
|
||||
<div class="toolbar">
|
||||
<label class="search-field">
|
||||
<span>Поиск</span>
|
||||
<input id="searchInput" type="search" autocomplete="off" placeholder="ноутбук, камера, рюкзак" />
|
||||
</label>
|
||||
<label class="select-field">
|
||||
<span>Сортировка</span>
|
||||
<select id="sortSelect">
|
||||
<option value="new">Сначала новые</option>
|
||||
<option value="price-asc">Цена по возрастанию</option>
|
||||
<option value="price-desc">Цена по убыванию</option>
|
||||
<option value="stock">Больше остатков</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="product-grid" id="productGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="ordersView" aria-label="Заказы">
|
||||
<div class="orders-list" id="ordersList"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="shopView" aria-label="Панель магазина">
|
||||
<form class="shop-form" id="productForm">
|
||||
<div class="form-head">
|
||||
<p class="eyebrow">Shop role</p>
|
||||
<h2>Новый товар</h2>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Название
|
||||
<input name="name" required minlength="3" maxlength="120" placeholder="Secure Router" />
|
||||
</label>
|
||||
<label>
|
||||
Цена
|
||||
<input name="price" required type="number" min="0.01" step="0.01" placeholder="149.99" />
|
||||
</label>
|
||||
<label>
|
||||
Остаток
|
||||
<input name="stock" required type="number" min="0" max="1000000" step="1" placeholder="24" />
|
||||
</label>
|
||||
<label class="wide">
|
||||
Описание
|
||||
<textarea name="description" maxlength="1000" placeholder="Краткое описание товара"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<button class="primary-action" type="submit">Добавить товар</button>
|
||||
</form>
|
||||
<div class="shop-empty" id="shopGate"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="side-panel" aria-label="Покупка и аккаунт">
|
||||
<section class="panel-block" id="authPanel"></section>
|
||||
<section class="panel-block">
|
||||
<div class="panel-title">
|
||||
<h2>Корзина</h2>
|
||||
<button class="ghost-action" type="button" id="clearCartButton">Очистить</button>
|
||||
</div>
|
||||
<div class="cart-list" id="cartList"></div>
|
||||
<div class="cart-total">
|
||||
<span>Итого</span>
|
||||
<strong id="cartTotal">$0.00</strong>
|
||||
</div>
|
||||
<button class="primary-action" type="button" id="checkoutButton">Оформить заказ</button>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,712 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f4ef;
|
||||
--surface: #ffffff;
|
||||
--surface-strong: #111827;
|
||||
--text: #17202a;
|
||||
--muted: #667085;
|
||||
--line: #dedbd2;
|
||||
--green: #13795b;
|
||||
--green-dark: #0c5f47;
|
||||
--coral: #c84a31;
|
||||
--amber: #b7791f;
|
||||
--blue: #2563eb;
|
||||
--shadow: 0 18px 55px rgba(23, 32, 42, 0.11);
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(246, 244, 239, 0.92)),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1460px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) auto minmax(130px, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(222, 219, 210, 0.86);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.93);
|
||||
box-shadow: 0 10px 32px rgba(23, 32, 42, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
background: #16251e;
|
||||
color: #f7d56c;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
.brand small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #f0eee8;
|
||||
}
|
||||
|
||||
.tab {
|
||||
min-width: 90px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #475467;
|
||||
padding: 9px 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tab.is-active {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 4px 16px rgba(23, 32, 42, 0.09);
|
||||
}
|
||||
|
||||
.session-pill {
|
||||
justify-self: end;
|
||||
max-width: 100%;
|
||||
padding: 9px 12px;
|
||||
border-radius: 999px;
|
||||
background: #eef8f4;
|
||||
color: var(--green-dark);
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 370px;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.workspace,
|
||||
.side-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
align-items: end;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: var(--green);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: clamp(2rem, 4vw, 4.25rem);
|
||||
line-height: 0.98;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metrics span {
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
color: #344054;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(170px, 230px);
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: #344054;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 12px 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 104px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 0 4px rgba(19, 121, 91, 0.13);
|
||||
}
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view.is-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(235px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
display: grid;
|
||||
min-height: 402px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(222, 219, 210, 0.92);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 28px rgba(23, 32, 42, 0.07);
|
||||
}
|
||||
|
||||
.product-card img {
|
||||
width: 100%;
|
||||
height: 168px;
|
||||
object-fit: cover;
|
||||
background: #ece8de;
|
||||
}
|
||||
|
||||
.product-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.product-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.product-title-row h3 {
|
||||
margin: 0;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.price {
|
||||
flex: 0 0 auto;
|
||||
color: var(--green-dark);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.product-card p {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.stock {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f6f0df;
|
||||
color: #6b4e16;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 84px 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-actions input {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action,
|
||||
.ghost-action,
|
||||
.danger-action {
|
||||
display: inline-flex;
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 13px;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
border: 1px solid var(--green);
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.primary-action:hover {
|
||||
background: var(--green-dark);
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
border: 1px solid #c9d8ff;
|
||||
background: #eef4ff;
|
||||
color: #1e4ab5;
|
||||
}
|
||||
|
||||
.ghost-action {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.danger-action {
|
||||
border: 1px solid #f0b8ab;
|
||||
background: #fff3f0;
|
||||
color: var(--coral);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
position: sticky;
|
||||
top: 90px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.panel-block,
|
||||
.shop-form,
|
||||
.empty-state,
|
||||
.order-card {
|
||||
border: 1px solid rgba(222, 219, 210, 0.92);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel-block,
|
||||
.shop-form {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel-title,
|
||||
.form-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius);
|
||||
background: #f0eee8;
|
||||
}
|
||||
|
||||
.auth-tabs button {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
padding: 9px;
|
||||
color: #475467;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.auth-tabs button.is-active {
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
box-shadow: 0 4px 16px rgba(23, 32, 42, 0.08);
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.role-badge,
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 6px 9px;
|
||||
background: #eef8f4;
|
||||
color: var(--green-dark);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: #fff7e8;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.status-badge.paid {
|
||||
background: #e9f8f1;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.cart-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.cart-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.cart-row strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cart-row small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cart-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.cart-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 14px 0;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.cart-total strong {
|
||||
font-size: 1.35rem;
|
||||
color: var(--green-dark);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
min-height: 220px;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.orders-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.order-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.order-head h3 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.order-head small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.order-items {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid #eeeae1;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.order-item span:first-child {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-grid .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.shop-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shop-empty.is-visible {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 50;
|
||||
max-width: min(380px, calc(100vw - 40px));
|
||||
transform: translateY(18px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border-radius: var(--radius);
|
||||
background: #17202a;
|
||||
color: #fff;
|
||||
padding: 13px 15px;
|
||||
box-shadow: 0 18px 55px rgba(23, 32, 42, 0.26);
|
||||
transition: transform 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.toast.is-visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
position: static;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.app-shell {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: static;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tabs,
|
||||
.session-pill {
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tab {
|
||||
min-width: 0;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.workspace-head {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.side-panel,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.product-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
grid-template-columns: 74px 1fr;
|
||||
}
|
||||
|
||||
.order-footer,
|
||||
.order-head {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user