From 9b688b2af4c6dcfefad223e2d66347aab777ffe6 Mon Sep 17 00:00:00 2001 From: shop Date: Sun, 17 May 2026 14:08:03 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D0=BA=D0=B8=D0=B4=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=B2=20=D0=B2=20=D0=B0=D0=B4=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Цена со скидкой и срок акции на товаре; отображение в каталоге и корзине. Улучшенный UI промокодов с редактированием. Co-authored-by: Cursor --- postgres/init/07_product_sale.sql | 4 + src/cart.js | 17 ++-- src/public/css/style.css | 67 +++++++++++++++ src/routes/admin.js | 118 +++++++++++++++++++++++++++ src/routes/shop.js | 6 +- src/utils/productPrice.js | 19 +++++ src/views/admin/products.ejs | 77 ++++++++++++----- src/views/admin/promo-codes.ejs | 82 +++++++++++++++---- src/views/cart.ejs | 9 +- src/views/home.ejs | 2 +- src/views/partials/product-price.ejs | 11 +++ src/views/product.ejs | 13 ++- 12 files changed, 378 insertions(+), 47 deletions(-) create mode 100644 postgres/init/07_product_sale.sql create mode 100644 src/utils/productPrice.js create mode 100644 src/views/partials/product-price.ejs 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') %>
-

Товары

+

Товары — цены и скидки

<% if (created) { %>

Промокод создан

<% } %> +<% if (updated) { %>

Промокод обновлён

<% } %> @@ -38,26 +42,54 @@ Код - Скидка - До + Настройки скидки + Срок / лимит Использовано - Статус <% promos.forEach(p => { %> - <%= p.code %>
<%= p.description %> + <%= p.code %> - <% if (p.discount_type === 'percent') { %><%= p.discount_value %>%<% } else { %><%= formatPrice(p.discount_value) %><% } %> - <% if (p.min_order_cents > 0) { %>
от <%= formatPrice(p.min_order_cents) %><% } %> +
+ + + + + +
- <%= new Date(p.expires_at).toLocaleString('ru-RU') %> - <%= p.use_count %><% if (p.max_uses) { %> / <%= p.max_uses %><% } %> - <%= p.active ? 'Активен' : 'Выкл.' %> -
+ + + + + + + + +

до <%= new Date(p.expires_at).toLocaleString('ru-RU') %>

+
+ + <%= p.use_count %><% if (p.max_uses) { %> / <%= p.max_uses %><% } %> + + <%= p.active ? 'Активен' : 'Выкл.' %> +
@@ -66,4 +98,18 @@ + + <%- include('../partials/layout-end') %> diff --git a/src/views/cart.ejs b/src/views/cart.ejs index a4bd627..d7eaa39 100644 --- a/src/views/cart.ejs +++ b/src/views/cart.ejs @@ -29,7 +29,14 @@ <% } %> <%= item.name %> - <%= formatPrice(item.price_cents) %> + + <% if (item.on_sale) { %> + <%= formatPrice(item.price_cents) %> + <%= formatPrice(item.effective_price_cents) %> + <% } else { %> + <%= formatPrice(item.effective_price_cents) %> + <% } %> + diff --git a/src/views/home.ejs b/src/views/home.ejs index 7234e8a..1664f9a 100644 --- a/src/views/home.ejs +++ b/src/views/home.ejs @@ -32,7 +32,7 @@ <%= p.category_name %> <% } %>

<%= p.name %>

-

<%= formatPrice(p.price_cents) %>

+ <%- include('partials/product-price', { product: p }) %>
diff --git a/src/views/partials/product-price.ejs b/src/views/partials/product-price.ejs new file mode 100644 index 0000000..4ae7089 --- /dev/null +++ b/src/views/partials/product-price.ejs @@ -0,0 +1,11 @@ +<% const onSale = typeof isSaleActive === 'function' && isSaleActive(product); %> +<% const eff = typeof effectivePrice === 'function' ? effectivePrice(product) : product.price_cents; %> +

+ <% if (onSale) { %> + <%= formatPrice(product.price_cents) %> + <%= formatPrice(eff) %> + −<%= salePercent(product) %>% + <% } else { %> + <%= formatPrice(product.price_cents) %> + <% } %> +

diff --git a/src/views/product.ejs b/src/views/product.ejs index 82e7301..06c14e6 100644 --- a/src/views/product.ejs +++ b/src/views/product.ejs @@ -13,7 +13,14 @@ <%= product.category_name %> <% } %>

<%= product.name %>

-

<%= formatPrice(product.price_cents) %>

+
+ <%- include('partials/product-price', { product }) %> + <% if (isSaleActive(product) && product.sale_ends_at) { %> +
+ Акция заканчивается: +
+ <% } %> +

<%= product.description %>

В наличии: <%= product.stock %> шт.

@@ -84,4 +91,8 @@ +<% if (isSaleActive(product) && product.sale_ends_at) { %> + +<% } %> + <%- include('partials/layout-end') %>