feat: скидки на товары и редактирование промокодов в админке
Цена со скидкой и срок акции на товаре; отображение в каталоге и корзине. Улучшенный UI промокодов с редактированием. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
-- Цена со скидкой на товар (акция)
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS sale_price_cents INTEGER
|
||||
CHECK (sale_price_cents IS NULL OR sale_price_cents >= 0);
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS sale_ends_at TIMESTAMPTZ;
|
||||
+11
-4
@@ -1,4 +1,5 @@
|
||||
const { query } = require('./db');
|
||||
const { getEffectivePriceCents, isSaleActive } = require('./utils/productPrice');
|
||||
|
||||
function getCart(req) {
|
||||
if (!req.session.cart) {
|
||||
@@ -22,11 +23,17 @@ async function cartItems(cart) {
|
||||
);
|
||||
|
||||
return products
|
||||
.map((p) => ({
|
||||
.map((p) => {
|
||||
const effective = getEffectivePriceCents(p);
|
||||
const qty = cart[p.id] || 0;
|
||||
return {
|
||||
...p,
|
||||
quantity: cart[p.id] || 0,
|
||||
line_total: (cart[p.id] || 0) * p.price_cents,
|
||||
}))
|
||||
quantity: qty,
|
||||
effective_price_cents: effective,
|
||||
on_sale: isSaleActive(p),
|
||||
line_total: qty * effective,
|
||||
};
|
||||
})
|
||||
.filter((p) => p.quantity > 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -1006,3 +1006,70 @@ a:hover {
|
||||
body:has(.cookie-banner) .main {
|
||||
padding-bottom: 7rem;
|
||||
}
|
||||
|
||||
.price-block {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem 0.6rem;
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.price-block__old {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.price-block--sale .price-block__current {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge--sale {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.admin-pricing-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-pricing-form--ends {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.label--inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-hint {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-promo-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form--grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.promo-countdown--sm {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -99,12 +99,20 @@ router.get(
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
ORDER BY p.id`
|
||||
);
|
||||
const productPrice = require('../utils/productPrice');
|
||||
res.render('admin/products', {
|
||||
title: 'Товары',
|
||||
products,
|
||||
formatPrice,
|
||||
isSaleActive: productPrice.isSaleActive,
|
||||
effectivePrice: productPrice.getEffectivePriceCents,
|
||||
salePercent: productPrice.salePercent,
|
||||
stockUpdated: req.query.stock_updated === '1',
|
||||
notified: req.query.notified ? parseInt(req.query.notified, 10) : 0,
|
||||
pricingUpdated: req.query.pricing_updated === '1',
|
||||
pricingError: req.query.pricing_error
|
||||
? decodeURIComponent(String(req.query.pricing_error))
|
||||
: null,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -135,6 +143,114 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/products/:id/pricing',
|
||||
asyncHandler(async (req, res) => {
|
||||
const productId = parseInt(req.params.id, 10);
|
||||
const priceRub = parseFloat(String(req.body.price_rub || '').replace(',', '.'));
|
||||
const saleRubRaw = String(req.body.sale_price_rub ?? '').trim();
|
||||
const clearSale = req.body.clear_sale === '1';
|
||||
|
||||
if (clearSale) {
|
||||
const price_cents = Number.isFinite(priceRub) ? Math.round(priceRub * 100) : null;
|
||||
if (!Number.isFinite(productId) || price_cents == null || price_cents < 0) {
|
||||
return res.redirect('/admin/products?pricing_error=' + encodeURIComponent('Некорректная цена'));
|
||||
}
|
||||
await query(
|
||||
`UPDATE products SET price_cents = $1, sale_price_cents = NULL, sale_ends_at = NULL WHERE id = $2`,
|
||||
[price_cents, productId]
|
||||
);
|
||||
return res.redirect('/admin/products?pricing_updated=1');
|
||||
}
|
||||
|
||||
if (!Number.isFinite(productId) || !Number.isFinite(priceRub) || priceRub < 0) {
|
||||
return res.redirect('/admin/products?pricing_error=' + encodeURIComponent('Некорректная цена'));
|
||||
}
|
||||
|
||||
const { rows: existingRows } = await query(
|
||||
'SELECT sale_price_cents, sale_ends_at FROM products WHERE id = $1',
|
||||
[productId]
|
||||
);
|
||||
const existing = existingRows[0] || {};
|
||||
|
||||
const price_cents = Math.round(priceRub * 100);
|
||||
let sale_price_cents = existing.sale_price_cents ?? null;
|
||||
let sale_ends_at = existing.sale_ends_at ?? null;
|
||||
|
||||
if (saleRubRaw !== '') {
|
||||
const saleRub = parseFloat(saleRubRaw.replace(',', '.'));
|
||||
if (!Number.isFinite(saleRub) || saleRub < 0) {
|
||||
return res.redirect(
|
||||
'/admin/products?pricing_error=' + encodeURIComponent('Некорректная цена со скидкой')
|
||||
);
|
||||
}
|
||||
sale_price_cents = Math.round(saleRub * 100);
|
||||
if (sale_price_cents >= price_cents) {
|
||||
return res.redirect(
|
||||
'/admin/products?pricing_error=' +
|
||||
encodeURIComponent('Цена со скидкой должна быть ниже обычной')
|
||||
);
|
||||
}
|
||||
} else if (!('sale_ends_at' in req.body)) {
|
||||
sale_price_cents = null;
|
||||
sale_ends_at = null;
|
||||
}
|
||||
|
||||
if ('sale_ends_at' in req.body) {
|
||||
sale_ends_at = req.body.sale_ends_at
|
||||
? new Date(req.body.sale_ends_at).toISOString()
|
||||
: null;
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE products SET price_cents = $1, sale_price_cents = $2, sale_ends_at = $3 WHERE id = $4`,
|
||||
[price_cents, sale_price_cents, sale_ends_at, productId]
|
||||
);
|
||||
|
||||
res.redirect('/admin/products?pricing_updated=1');
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/promo-codes/:id/update',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const description = (req.body.description || '').trim();
|
||||
const discount_type = req.body.discount_type === 'fixed' ? 'fixed' : 'percent';
|
||||
const discount_value = parseInt(req.body.discount_value, 10);
|
||||
const min_order_cents = Math.max(0, parseInt(req.body.min_order_rub, 10) || 0) * 100;
|
||||
const max_uses =
|
||||
req.body.max_uses === '' || req.body.max_uses == null
|
||||
? null
|
||||
: parseInt(req.body.max_uses, 10);
|
||||
|
||||
const { rows: promoRows } = await query('SELECT expires_at FROM promo_codes WHERE id = $1', [
|
||||
id,
|
||||
]);
|
||||
let expires_at = promoRows[0]?.expires_at;
|
||||
if (req.body.valid_days) {
|
||||
const days = Math.max(1, parseInt(req.body.valid_days, 10) || 7);
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + days);
|
||||
expires_at = expires.toISOString();
|
||||
}
|
||||
|
||||
const value =
|
||||
discount_type === 'percent'
|
||||
? Math.min(100, discount_value)
|
||||
: discount_value * 100;
|
||||
|
||||
await query(
|
||||
`UPDATE promo_codes SET
|
||||
description = $1, discount_type = $2, discount_value = $3,
|
||||
expires_at = $4, min_order_cents = $5, max_uses = $6
|
||||
WHERE id = $7`,
|
||||
[description, discount_type, value, expires_at, min_order_cents, max_uses, id]
|
||||
);
|
||||
res.redirect('/admin/promo-codes?updated=1');
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/reservations',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -182,7 +298,9 @@ router.get(
|
||||
res.render('admin/promo-codes', {
|
||||
title: 'Промокоды',
|
||||
promos,
|
||||
formatPrice,
|
||||
created: req.query.created === '1',
|
||||
updated: req.query.updated === '1',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
+5
-1
@@ -5,6 +5,7 @@ const { requireAuth } = require('../middleware/auth');
|
||||
const { requireCookieConsent } = require('../middleware/cookieConsent');
|
||||
const { asyncHandler } = require('../utils/asyncHandler');
|
||||
const { buildCartPricing } = require('../services/pricing');
|
||||
const productPrice = require('../utils/productPrice');
|
||||
const promoService = require('../services/promo');
|
||||
const loyaltyService = require('../services/loyalty');
|
||||
|
||||
@@ -18,6 +19,9 @@ function enrichLocals(req, res) {
|
||||
|
||||
router.use((req, res, next) => {
|
||||
enrichLocals(req, res);
|
||||
res.locals.isSaleActive = productPrice.isSaleActive;
|
||||
res.locals.effectivePrice = productPrice.getEffectivePriceCents;
|
||||
res.locals.salePercent = productPrice.salePercent;
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -327,7 +331,7 @@ router.post(
|
||||
await client.query(
|
||||
`INSERT INTO order_items (order_id, product_id, quantity, price_cents)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[orderId, item.id, item.quantity, item.price_cents]
|
||||
[orderId, item.id, item.quantity, item.effective_price_cents ?? item.price_cents]
|
||||
);
|
||||
await client.query('UPDATE products SET stock = stock - $1 WHERE id = $2', [
|
||||
item.quantity,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
function isSaleActive(product) {
|
||||
if (product.sale_price_cents == null) return false;
|
||||
if (product.sale_price_cents >= product.price_cents) return false;
|
||||
if (product.sale_ends_at && new Date(product.sale_ends_at) <= new Date()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getEffectivePriceCents(product) {
|
||||
return isSaleActive(product) ? product.sale_price_cents : product.price_cents;
|
||||
}
|
||||
|
||||
function salePercent(product) {
|
||||
if (!isSaleActive(product) || !product.price_cents) return 0;
|
||||
return Math.round(
|
||||
((product.price_cents - product.sale_price_cents) / product.price_cents) * 100
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { isSaleActive, getEffectivePriceCents, salePercent };
|
||||
@@ -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') %>
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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') %>
|
||||
|
||||
Reference in New Issue
Block a user