release: v1.2.0 — каталог, email заказа, SEO, админ CSV

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-17 14:58:11 +03:00
parent e81bd79607
commit 980b31df06
20 changed files with 553 additions and 18 deletions
+35
View File
@@ -12,6 +12,7 @@
<a href="/account?tab=password" class="account-tabs__link <%= activeTab === 'password' ? 'account-tabs__link--active' : '' %>">Смена пароля</a>
<a href="/account?tab=passkey" class="account-tabs__link <%= activeTab === 'passkey' ? 'account-tabs__link--active' : '' %>">Passkey</a>
<a href="/account?tab=reservations" class="account-tabs__link <%= activeTab === 'reservations' ? 'account-tabs__link--active' : '' %>">Бронирования</a>
<a href="/account?tab=orders" class="account-tabs__link <%= activeTab === 'orders' ? 'account-tabs__link--active' : '' %>">Заказы</a>
</nav>
<% if (activeTab === 'profile') { %>
@@ -71,6 +72,40 @@
</section>
<% } %>
<% if (activeTab === 'orders') { %>
<section class="card account-section">
<h2>Последние заказы</h2>
<% if (!recentOrders.length) { %>
<p class="muted">Заказов пока нет. <a href="/">Перейти в каталог</a></p>
<% } else { %>
<table class="cart-table">
<thead>
<tr>
<th>№</th>
<th>Дата</th>
<th>Статус</th>
<th>Сумма</th>
<th></th>
</tr>
</thead>
<tbody>
<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
<% recentOrders.forEach(o => { %>
<tr>
<td>#<%= o.id %></td>
<td><%= new Date(o.created_at).toLocaleString('ru-RU') %></td>
<td><span class="status status--<%= o.status %>"><%= statusLabels[o.status] || o.status %></span></td>
<td><%= formatPrice(o.total_cents) %></td>
<td><a href="/orders/<%= o.id %>">Подробнее</a></td>
</tr>
<% }) %>
</tbody>
</table>
<p class="account-actions"><a href="/orders" class="btn btn--ghost">Все заказы</a></p>
<% } %>
</section>
<% } %>
<% if (activeTab === 'reservations') { %>
<section class="card account-section">
<h2>Мои бронирования</h2>
+16 -2
View File
@@ -1,11 +1,25 @@
<%- include('../partials/layout-start') %>
<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
<div class="admin-header">
<h1>Заказы</h1>
<%- include('../partials/admin-nav', { adminNav: 'orders' }) %>
<div class="admin-header__actions">
<a href="/admin/orders/export.csv" class="btn btn--ghost btn--sm">Экспорт CSV</a>
<%- include('../partials/admin-nav', { adminNav: 'orders' }) %>
</div>
</div>
<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
<form class="catalog-toolbar" method="get" action="/admin/orders">
<label class="catalog-toolbar__field">
<span class="catalog-toolbar__label">Статус</span>
<select name="status" class="input input--sm" onchange="this.form.submit()">
<option value="">Все</option>
<% ['pending','paid','shipped','cancelled'].forEach(s => { %>
<option value="<%= s %>" <%= statusFilter === s ? 'selected' : '' %>><%= statusLabels[s] %></option>
<% }) %>
</select>
</label>
</form>
<table class="cart-table">
<thead>
+65 -3
View File
@@ -1,4 +1,16 @@
<%- include('partials/layout-start') %>
<%
function catalogHref(extra) {
const p = new URLSearchParams();
if (searchQuery) p.set('q', searchQuery);
if (saleOnly) p.set('sale', '1');
if (showAll) p.set('all', '1');
if (sort && sort !== 'name') p.set('sort', sort);
if (extra && extra.category) p.set('category', extra.category);
const s = p.toString();
return s ? '/?' + s : '/';
}
%>
<section class="hero">
<h1>Каталог товаров</h1>
@@ -7,20 +19,60 @@
<% if (categories.length) { %>
<nav class="categories" aria-label="Категории">
<a href="/" class="chip <%= !activeCategory ? 'chip--active' : '' %>">Все</a>
<a href="<%= catalogHref() %>" class="chip <%= !activeCategory ? 'chip--active' : '' %>">Все</a>
<% categories.forEach(c => { %>
<a href="/?category=<%= c.slug %>" class="chip <%= activeCategory === c.slug ? 'chip--active' : '' %>"><%= c.name %></a>
<a href="<%= catalogHref({ category: c.slug }) %>" class="chip <%= activeCategory === c.slug ? 'chip--active' : '' %>"><%= c.name %></a>
<% }) %>
</nav>
<% } %>
<form class="catalog-toolbar" method="get" action="/">
<% if (searchQuery) { %><input type="hidden" name="q" value="<%= searchQuery %>"><% } %>
<% if (activeCategory) { %><input type="hidden" name="category" value="<%= activeCategory %>"><% } %>
<label class="catalog-toolbar__field">
<span class="catalog-toolbar__label">Сортировка</span>
<select name="sort" class="input input--sm" onchange="this.form.submit()">
<option value="name" <%= sort === 'name' ? 'selected' : '' %>>По названию</option>
<option value="price_asc" <%= sort === 'price_asc' ? 'selected' : '' %>>Цена ↑</option>
<option value="price_desc" <%= sort === 'price_desc' ? 'selected' : '' %>>Цена ↓</option>
<option value="newest" <%= sort === 'newest' ? 'selected' : '' %>>Сначала новые</option>
</select>
</label>
<label class="catalog-toolbar__check">
<input type="checkbox" name="sale" value="1" <%= saleOnly ? 'checked' : '' %> onchange="this.form.submit()">
Только со скидкой
</label>
<label class="catalog-toolbar__check">
<input type="checkbox" name="all" value="1" <%= showAll ? 'checked' : '' %> onchange="this.form.submit()">
Показать нет в наличии
</label>
</form>
<% if (recentProducts && recentProducts.length) { %>
<section class="recently-viewed">
<h2 class="recently-viewed__title">Вы недавно смотрели</h2>
<div class="recently-viewed__grid">
<% recentProducts.forEach(p => { %>
<a href="/product/<%= p.slug %>" class="recently-viewed__card card">
<% if (p.image_url) { %>
<img src="<%= p.image_url %>" alt="" class="recently-viewed__img" loading="lazy">
<% } %>
<span class="recently-viewed__name"><%= p.name %></span>
</a>
<% }) %>
</div>
</section>
<% } %>
<% if (!products.length) { %>
<p class="empty">Товары не найдены. Попробуйте другой запрос.</p>
<% } else { %>
<div class="grid">
<% products.forEach(p => { %>
<% const onSale = isSaleActive(p); %>
<article class="card<%= onSale ? ' card--sale' : '' %>">
<% const outOfStock = p.stock <= 0; %>
<% const lowStock = !outOfStock && p.stock <= 5; %>
<article class="card<%= onSale ? ' card--sale' : '' %><%= outOfStock ? ' card--out-of-stock' : '' %>">
<a href="/product/<%= p.slug %>" class="card__image-wrap">
<% if (onSale) { %>
<span class="card__sale-ribbon" aria-hidden="true">
@@ -28,6 +80,12 @@
<%= salePercent(p) %>%
</span>
<% } %>
<% if (lowStock) { %>
<span class="card__stock-badge">Осталось <%= p.stock %></span>
<% } %>
<% if (outOfStock) { %>
<span class="card__stock-badge card__stock-badge--out">Нет в наличии</span>
<% } %>
<% if (p.image_url) { %>
<img src="<%= p.image_url %>" alt="<%= p.name %>" class="card__image" loading="lazy">
<% } else { %>
@@ -40,6 +98,7 @@
<% } %>
<h2 class="card__title"><a href="/product/<%= p.slug %>"><%= p.name %></a></h2>
<%- include('partials/product-price', { product: p, priceSize: 'md' }) %>
<% if (!outOfStock) { %>
<form action="/cart/add" method="post" class="card__form">
<input type="hidden" name="product_id" value="<%= p.id %>">
<input type="hidden" name="redirect" value="/cart">
@@ -48,6 +107,9 @@
В корзину
</button>
</form>
<% } else { %>
<a href="/product/<%= p.slug %>" class="btn btn--ghost btn--block">Подробнее</a>
<% } %>
</div>
</article>
<% }) %>
+5 -1
View File
@@ -1,7 +1,11 @@
</main>
<footer class="footer">
<div class="container">
<p>&copy; <%= new Date().getFullYear() %> Shop · <a href="/cookies/policy">Cookies</a></p>
<p>&copy; <%= new Date().getFullYear() %> Shop ·
<a href="/orders">Заказы</a> ·
<a href="/sitemap.xml">Карта сайта</a> ·
<a href="/cookies/policy">Cookies</a>
</p>
</div>
</footer>
<%- include('cookie-banner') %>
+3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= title %> — Shop</title>
<% if (typeof metaDescription !== 'undefined' && metaDescription) { %>
<meta name="description" content="<%= metaDescription %>">
<% } %>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap" rel="stylesheet">