feat: скидки на товары и редактирование промокодов в админке

Цена со скидкой и срок акции на товаре; отображение в каталоге и корзине. Улучшенный UI промокодов с редактированием.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-17 14:08:03 +03:00
parent db4bc9bfe1
commit 9b688b2af4
12 changed files with 378 additions and 47 deletions
+57 -20
View File
@@ -1,7 +1,7 @@
<%- include('../partials/layout-start') %>
<div class="admin-header">
<h1>Товары</h1>
<h1>Товары — цены и скидки</h1>
<nav class="admin-nav">
<a href="/admin" class="admin-nav__link">Обзор</a>
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
@@ -15,46 +15,83 @@
<% if (stockUpdated) { %>
<p class="alert alert--success">
Остаток обновлён.<% if (notified > 0) { %> Отправлено уведомлений подписчикам: <%= notified %>.<% } %>
Остаток обновлён.<% if (notified > 0) { %> Уведомления о поступлении: <%= notified %>.<% } %>
</p>
<% } %>
<% if (pricingUpdated) { %><p class="alert alert--success">Цены обновлены</p><% } %>
<% if (pricingError) { %><p class="alert alert--error"><%= pricingError %></p><% } %>
<table class="cart-table">
<p class="muted admin-hint">Укажите обычную цену и цену со скидкой (₽). Пустая скидка или «Сбросить» — без акции. Для акции можно задать дату окончания.</p>
<table class="cart-table admin-products-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Категория</th>
<th>Цена</th>
<th>Товар</th>
<th>Цена / скидка (₽)</th>
<th>Акция до</th>
<th>Остаток</th>
<th>Подписки</th>
<th></th>
</tr>
</thead>
<tbody>
<% products.forEach(p => { %>
<% const onSale = isSaleActive(p); %>
<% const eff = effectivePrice(p); %>
<tr>
<td><%= p.id %></td>
<td><%= p.name %></td>
<td><%= p.category_name || '—' %></td>
<td><%= formatPrice(p.price_cents) %></td>
<td>
<form action="/admin/products/<%= p.id %>/stock" method="post" class="inline-form admin-stock-form">
<input type="number" name="stock" class="input input--sm" min="0" value="<%= p.stock %>" aria-label="Остаток">
<button type="submit" class="btn btn--ghost btn--sm">OK</button>
</form>
<strong><%= p.name %></strong><br>
<span class="muted"><%= p.category_name || '—' %></span>
<% if (onSale) { %>
<br><span class="badge badge--sale"><%= salePercent(p) %>% на сайте</span>
<% } %>
</td>
<td>
<% if (p.alert_count > 0) { %>
<span class="badge" title="Ждут уведомления"><%= p.alert_count %></span>
<% } else { %>
<form action="/admin/products/<%= p.id %>/pricing" method="post" class="admin-pricing-form">
<label class="label label--inline">
Цена
<input type="number" name="price_rub" class="input input--sm" min="0" step="0.01" required
value="<%= (p.price_cents / 100).toFixed(2) %>">
</label>
<label class="label label--inline">
Со скидкой
<input type="number" name="sale_price_rub" class="input input--sm" min="0" step="0.01" placeholder="—"
value="<%= p.sale_price_cents != null ? (p.sale_price_cents / 100).toFixed(2) : '' %>">
</label>
<button type="submit" class="btn btn--primary btn--sm">Сохранить</button>
</form>
<% if (onSale) { %>
<p class="muted" style="margin:0.35rem 0 0">На сайте: <strong><%= formatPrice(eff) %></strong> вместо <%= formatPrice(p.price_cents) %></p>
<% } %>
</td>
<td>
<form action="/admin/products/<%= p.id %>/pricing" method="post" class="admin-pricing-form admin-pricing-form--ends">
<input type="hidden" name="price_rub" value="<%= (p.price_cents / 100).toFixed(2) %>">
<input type="hidden" name="sale_price_rub" value="<%= p.sale_price_cents != null ? (p.sale_price_cents / 100).toFixed(2) : '' %>">
<input type="datetime-local" name="sale_ends_at" class="input input--sm"
value="<%= p.sale_ends_at ? new Date(p.sale_ends_at).toISOString().slice(0, 16) : '' %>">
<button type="submit" class="btn btn--ghost btn--sm">OK</button>
<% if (p.sale_price_cents != null) { %>
<button type="submit" formaction="/admin/products/<%= p.id %>/pricing" name="clear_sale" value="1" class="btn btn--ghost btn--sm">Сбросить скидку</button>
<% } %>
</form>
<% if (onSale && p.sale_ends_at) { %>
<div class="promo-countdown" data-expires="<%= p.sale_ends_at %>" style="margin-top:0.35rem">
<span class="promo-countdown__timer">—</span>
</div>
<% } %>
</td>
<td>
<form action="/admin/products/<%= p.id %>/stock" method="post" class="inline-form admin-stock-form">
<input type="number" name="stock" class="input input--sm" min="0" value="<%= p.stock %>">
<button type="submit" class="btn btn--ghost btn--sm">OK</button>
</form>
</td>
<td><a href="/product/<%= p.slug %>">На сайте</a></td>
</tr>
<% }) %>
</tbody>
</table>
<script src="/js/promo-countdown.js"></script>
<%- include('../partials/layout-end') %>
+64 -18
View File
@@ -14,22 +14,26 @@
</div>
<% if (created) { %><p class="alert alert--success">Промокод создан</p><% } %>
<% if (updated) { %><p class="alert alert--success">Промокод обновлён</p><% } %>
<section class="card account-section--narrow" style="margin-bottom:1.5rem">
<h2>Новый промокод</h2>
<form action="/admin/promo-codes" method="post" class="form">
<form action="/admin/promo-codes" method="post" class="form form--grid">
<label class="label">Код <input type="text" name="code" class="input" required placeholder="SUMMER20"></label>
<label class="label">Описание <input type="text" name="description" class="input" placeholder="Летняя скидка"></label>
<label class="label">Тип скидки
<select name="discount_type" class="input">
<option value="percent">Процент %</option>
<option value="fixed">Фиксированная (₽)</option>
<select name="discount_type" class="input" id="promo-type-new">
<option value="percent">Процент (%)</option>
<option value="fixed">Фиксированная сумма (₽)</option>
</select>
</label>
<label class="label">Значение (10 = 10% или 500 ₽) <input type="number" name="discount_value" class="input" min="1" required></label>
<label class="label">Действует дней <input type="number" name="valid_days" class="input" value="30" min="1"></label>
<label class="label">Мин. сумма заказа (₽) <input type="number" name="min_order_rub" class="input" value="0" min="0"></label>
<label class="label">Лимит использований (пусто = без лимита) <input type="number" name="max_uses" class="input" min="1"></label>
<label class="label">
<span id="promo-value-label-new">Размер скидки (%)</span>
<input type="number" name="discount_value" class="input" min="1" required placeholder="10">
</label>
<label class="label">Действует (дней с сегодня) <input type="number" name="valid_days" class="input" value="30" min="1"></label>
<label class="label">Мин. сумма заказа (₽) <input type="number" name="min_order_rub" class="input" value="0" min="0" step="1"></label>
<label class="label">Лимит использований <input type="number" name="max_uses" class="input" min="1" placeholder="без лимита"></label>
<button type="submit" class="btn btn--primary">Создать</button>
</form>
</section>
@@ -38,26 +42,54 @@
<thead>
<tr>
<th>Код</th>
<th>Скидка</th>
<th>До</th>
<th>Настройки скидки</th>
<th>Срок / лимит</th>
<th>Использовано</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody>
<% promos.forEach(p => { %>
<tr>
<td><strong><%= p.code %></strong><br><span class="muted"><%= p.description %></span></td>
<td><strong><%= p.code %></strong></td>
<td>
<% if (p.discount_type === 'percent') { %><%= p.discount_value %>%<% } else { %><%= formatPrice(p.discount_value) %><% } %>
<% if (p.min_order_cents > 0) { %><br><span class="muted">от <%= formatPrice(p.min_order_cents) %></span><% } %>
<form action="/admin/promo-codes/<%= p.id %>/update" method="post" class="admin-promo-form">
<input type="text" name="description" class="input input--sm" value="<%= p.description || '' %>" placeholder="Описание">
<select name="discount_type" class="input input--sm promo-type-select">
<option value="percent"<%= p.discount_type === 'percent' ? ' selected' : '' %>>%</option>
<option value="fixed"<%= p.discount_type === 'fixed' ? ' selected' : '' %>>₽</option>
</select>
<input type="number" name="discount_value" class="input input--sm" min="1" required
value="<%= p.discount_type === 'percent' ? p.discount_value : (p.discount_value / 100) %>"
title="<%= p.discount_type === 'percent' ? 'Процент' : 'Рубли' %>">
<label class="label label--inline muted">мин. заказ ₽
<input type="number" name="min_order_rub" class="input input--sm" min="0"
value="<%= Math.round(p.min_order_cents / 100) %>">
</label>
<button type="submit" class="btn btn--ghost btn--sm">Сохранить</button>
</form>
</td>
<td><%= new Date(p.expires_at).toLocaleString('ru-RU') %></td>
<td><%= p.use_count %><% if (p.max_uses) { %> / <%= p.max_uses %><% } %></td>
<td><%= p.active ? 'Активен' : 'Выкл.' %></td>
<td>
<form action="/admin/promo-codes/<%= p.id %>/toggle" method="post">
<form action="/admin/promo-codes/<%= p.id %>/update" method="post" class="admin-promo-form">
<input type="hidden" name="description" value="<%= p.description || '' %>">
<input type="hidden" name="discount_type" value="<%= p.discount_type %>">
<input type="hidden" name="discount_value" value="<%= p.discount_type === 'percent' ? p.discount_value : (p.discount_value / 100) %>">
<input type="hidden" name="min_order_rub" value="<%= Math.round(p.min_order_cents / 100) %>">
<label class="label label--inline">ещё дней
<input type="number" name="valid_days" class="input input--sm" value="7" min="1">
</label>
<label class="label label--inline">лимит
<input type="number" name="max_uses" class="input input--sm" min="1"
value="<%= p.max_uses || '' %>" placeholder="∞">
</label>
<button type="submit" class="btn btn--ghost btn--sm">Продлить</button>
<p class="muted" style="margin:0.25rem 0 0;font-size:0.85rem">до <%= new Date(p.expires_at).toLocaleString('ru-RU') %></p>
</form>
</td>
<td><%= p.use_count %><% if (p.max_uses) { %> / <%= p.max_uses %><% } %></td>
<td>
<span class="badge<%= p.active ? '' : ' badge--muted' %>"><%= p.active ? 'Активен' : 'Выкл.' %></span>
<form action="/admin/promo-codes/<%= p.id %>/toggle" method="post" style="margin-top:0.35rem">
<button type="submit" class="btn btn--ghost btn--sm"><%= p.active ? 'Выключить' : 'Включить' %></button>
</form>
</td>
@@ -66,4 +98,18 @@
</tbody>
</table>
<script>
function bindPromoType(selectId, labelId) {
const sel = document.getElementById(selectId);
const lab = document.getElementById(labelId);
if (!sel || !lab) return;
const sync = () => {
lab.textContent = sel.value === 'fixed' ? 'Скидка (₽)' : 'Размер скидки (%)';
};
sel.addEventListener('change', sync);
sync();
}
bindPromoType('promo-type-new', 'promo-value-label-new');
</script>
<%- include('../partials/layout-end') %>
+8 -1
View File
@@ -29,7 +29,14 @@
<% } %>
<a href="/product/<%= item.slug %>"><%= item.name %></a>
</td>
<td><%= formatPrice(item.price_cents) %></td>
<td>
<% if (item.on_sale) { %>
<span class="price-block__old"><%= formatPrice(item.price_cents) %></span>
<%= formatPrice(item.effective_price_cents) %>
<% } else { %>
<%= formatPrice(item.effective_price_cents) %>
<% } %>
</td>
<td>
<input type="number" name="items[<%= item.id %>]" value="<%= item.quantity %>" min="0" max="<%= item.stock %>" class="input input--qty">
</td>
+1 -1
View File
@@ -32,7 +32,7 @@
<span class="card__category"><%= p.category_name %></span>
<% } %>
<h2 class="card__title"><a href="/product/<%= p.slug %>"><%= p.name %></a></h2>
<p class="card__price"><%= formatPrice(p.price_cents) %></p>
<%- include('partials/product-price', { product: p }) %>
<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">
+11
View File
@@ -0,0 +1,11 @@
<% const onSale = typeof isSaleActive === 'function' && isSaleActive(product); %>
<% const eff = typeof effectivePrice === 'function' ? effectivePrice(product) : product.price_cents; %>
<p class="price-block<%= onSale ? ' price-block--sale' : '' %>">
<% if (onSale) { %>
<span class="price-block__old"><%= formatPrice(product.price_cents) %></span>
<span class="price-block__current"><%= formatPrice(eff) %></span>
<span class="badge badge--sale"><%= salePercent(product) %>%</span>
<% } else { %>
<span class="price-block__current"><%= formatPrice(product.price_cents) %></span>
<% } %>
</p>
+12 -1
View File
@@ -13,7 +13,14 @@
<a href="/?category=<%= product.category_slug %>" class="card__category"><%= product.category_name %></a>
<% } %>
<h1><%= product.name %></h1>
<p class="product-detail__price"><%= formatPrice(product.price_cents) %></p>
<div class="product-detail__price">
<%- include('partials/product-price', { product }) %>
<% if (isSaleActive(product) && product.sale_ends_at) { %>
<div class="promo-countdown" data-expires="<%= product.sale_ends_at %>">
Акция заканчивается: <span class="promo-countdown__timer">—</span>
</div>
<% } %>
</div>
<p class="product-detail__desc"><%= product.description %></p>
<p class="product-detail__stock">В наличии: <strong><%= product.stock %></strong> шт.</p>
@@ -84,4 +91,8 @@
</div>
</article>
<% if (isSaleActive(product) && product.sale_ends_at) { %>
<script src="/js/promo-countdown.js"></script>
<% } %>
<%- include('partials/layout-end') %>