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
+118
View File
@@ -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
View File
@@ -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,