diff --git a/postgres/init/07_product_sale.sql b/postgres/init/07_product_sale.sql new file mode 100644 index 0000000..79328fd --- /dev/null +++ b/postgres/init/07_product_sale.sql @@ -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; diff --git a/src/cart.js b/src/cart.js index 6ec28df..5d3ae87 100644 --- a/src/cart.js +++ b/src/cart.js @@ -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) => ({ - ...p, - quantity: cart[p.id] || 0, - line_total: (cart[p.id] || 0) * p.price_cents, - })) + .map((p) => { + const effective = getEffectivePriceCents(p); + const qty = cart[p.id] || 0; + return { + ...p, + quantity: qty, + effective_price_cents: effective, + on_sale: isSaleActive(p), + line_total: qty * effective, + }; + }) .filter((p) => p.quantity > 0); } diff --git a/src/public/css/style.css b/src/public/css/style.css index 6743076..6f0422c 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -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); +} diff --git a/src/routes/admin.js b/src/routes/admin.js index 24febf3..6f8d347 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -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', }); }) ); diff --git a/src/routes/shop.js b/src/routes/shop.js index 025b9d7..5fa6c75 100644 --- a/src/routes/shop.js +++ b/src/routes/shop.js @@ -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, diff --git a/src/utils/productPrice.js b/src/utils/productPrice.js new file mode 100644 index 0000000..3fc1b6d --- /dev/null +++ b/src/utils/productPrice.js @@ -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 }; diff --git a/src/views/admin/products.ejs b/src/views/admin/products.ejs index ad585bf..cdcd298 100644 --- a/src/views/admin/products.ejs +++ b/src/views/admin/products.ejs @@ -1,7 +1,7 @@ <%- include('../partials/layout-start') %>