feat: интерактивный установщик install.sh (Docker / Ubuntu, админ, БД)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,7 +22,7 @@ router.use((req, res, next) => {
|
||||
|
||||
async function loadAccountUser(userId) {
|
||||
const { rows } = await query(
|
||||
'SELECT id, email, name, role, created_at, passkey_enabled FROM users WHERE id = $1',
|
||||
'SELECT id, email, name, role, created_at, passkey_enabled, loyalty_points FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
return rows[0];
|
||||
|
||||
@@ -173,4 +173,62 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/promo-codes',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { rows: promos } = await query(
|
||||
`SELECT * FROM promo_codes ORDER BY created_at DESC`
|
||||
);
|
||||
res.render('admin/promo-codes', {
|
||||
title: 'Промокоды',
|
||||
promos,
|
||||
created: req.query.created === '1',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/promo-codes',
|
||||
asyncHandler(async (req, res) => {
|
||||
const code = (req.body.code || '').trim().toUpperCase();
|
||||
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 days = Math.max(1, parseInt(req.body.valid_days, 10) || 30);
|
||||
const min_order_cents = Math.max(0, parseInt(req.body.min_order_rub, 10) || 0) * 100;
|
||||
const max_uses = req.body.max_uses ? parseInt(req.body.max_uses, 10) : null;
|
||||
|
||||
if (!code || !discount_value) {
|
||||
return res.redirect('/admin/promo-codes?error=1');
|
||||
}
|
||||
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + days);
|
||||
|
||||
const value =
|
||||
discount_type === 'percent'
|
||||
? Math.min(100, discount_value)
|
||||
: discount_value * 100;
|
||||
|
||||
await query(
|
||||
`INSERT INTO promo_codes (code, description, discount_type, discount_value, expires_at, min_order_cents, max_uses)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[code, description, discount_type, value, expires.toISOString(), min_order_cents, max_uses]
|
||||
);
|
||||
|
||||
res.redirect('/admin/promo-codes?created=1');
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/promo-codes/:id/toggle',
|
||||
asyncHandler(async (req, res) => {
|
||||
await query(
|
||||
`UPDATE promo_codes SET active = NOT active WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.redirect('/admin/promo-codes');
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
const express = require('express');
|
||||
const { formatPrice } = require('../db');
|
||||
const { getCart, cartCount, cartItems } = require('../cart');
|
||||
const { requireCookieConsent } = require('../middleware/cookieConsent');
|
||||
const { asyncHandler } = require('../utils/asyncHandler');
|
||||
const promoService = require('../services/promo');
|
||||
const loyaltyService = require('../services/loyalty');
|
||||
const { buildCartPricing } = require('../services/pricing');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireCookieConsent);
|
||||
router.use((req, res, next) => {
|
||||
res.locals.cartCount = cartCount(getCart(req));
|
||||
res.locals.formatPrice = formatPrice;
|
||||
next();
|
||||
});
|
||||
|
||||
function cartRedirect(msg, type = 'error') {
|
||||
const param = type === 'success' ? 'promo_ok' : 'promo_error';
|
||||
return `/cart?${param}=${encodeURIComponent(msg)}`;
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/cart/promo',
|
||||
asyncHandler(async (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const items = await cartItems(cart);
|
||||
if (!items.length) {
|
||||
return res.redirect(cartRedirect('Корзина пуста'));
|
||||
}
|
||||
|
||||
const subtotal = items.reduce((s, i) => s + i.line_total, 0);
|
||||
const promo = await promoService.findPromoByCode(req.body.code);
|
||||
const check = promoService.validatePromo(promo, subtotal);
|
||||
if (!check.ok) {
|
||||
delete req.session.appliedPromoCode;
|
||||
return res.redirect(cartRedirect(check.error));
|
||||
}
|
||||
|
||||
req.session.appliedPromoCode = promo.code;
|
||||
res.redirect(cartRedirect(`Промокод ${promo.code} применён`, 'success'));
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/cart/promo/remove', (req, res) => {
|
||||
delete req.session.appliedPromoCode;
|
||||
res.redirect(cartRedirect('Промокод удалён', 'success'));
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/cart/loyalty',
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.session.userId) {
|
||||
return res.redirect('/login?next=/cart');
|
||||
}
|
||||
|
||||
const cart = getCart(req);
|
||||
const items = await cartItems(cart);
|
||||
if (!items.length) {
|
||||
return res.redirect(cartRedirect('Корзина пуста'));
|
||||
}
|
||||
|
||||
const pricing = await buildCartPricing(items, req.session, req.session.userId);
|
||||
const maxPoints = loyaltyService.pointsForDiscount(
|
||||
Math.max(0, pricing.subtotal - pricing.promoDiscount)
|
||||
);
|
||||
const balance = pricing.loyaltyBalance;
|
||||
|
||||
if (req.body.use_all === '1') {
|
||||
req.session.loyaltyPointsToUse = Math.min(balance, maxPoints);
|
||||
} else {
|
||||
const pts = Math.max(0, parseInt(req.body.points, 10) || 0);
|
||||
req.session.loyaltyPointsToUse = Math.min(pts, balance, maxPoints);
|
||||
}
|
||||
|
||||
res.redirect(cartRedirect('Баллы лояльности применены', 'success'));
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/cart/loyalty/remove', (req, res) => {
|
||||
delete req.session.loyaltyPointsToUse;
|
||||
res.redirect(cartRedirect('Списание баллов отменено', 'success'));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+65
-8
@@ -4,6 +4,9 @@ const { getCart, cartCount, cartItems, cartTotal } = require('../cart');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { requireCookieConsent } = require('../middleware/cookieConsent');
|
||||
const { asyncHandler } = require('../utils/asyncHandler');
|
||||
const { buildCartPricing } = require('../services/pricing');
|
||||
const promoService = require('../services/promo');
|
||||
const loyaltyService = require('../services/loyalty');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -128,14 +131,21 @@ router.get(
|
||||
asyncHandler(async (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const items = await cartItems(cart);
|
||||
const total = cartTotal(items);
|
||||
const pricing = await buildCartPricing(items, req.session, req.session.userId);
|
||||
const errorMsg = req.query.error ? decodeURIComponent(String(req.query.error)) : null;
|
||||
const promoOk = req.query.promo_ok ? decodeURIComponent(String(req.query.promo_ok)) : null;
|
||||
const promoErr = req.query.promo_error
|
||||
? decodeURIComponent(String(req.query.promo_error))
|
||||
: null;
|
||||
|
||||
res.render('cart', {
|
||||
title: 'Корзина',
|
||||
items,
|
||||
total,
|
||||
pricing,
|
||||
total: pricing.total,
|
||||
error: errorMsg,
|
||||
promoOk,
|
||||
promoErr,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -207,10 +217,13 @@ router.get(
|
||||
return res.redirect('/cart');
|
||||
}
|
||||
|
||||
const pricing = await buildCartPricing(items, req.session, req.session.userId);
|
||||
|
||||
res.render('checkout', {
|
||||
title: 'Оформление заказа',
|
||||
items,
|
||||
total: cartTotal(items),
|
||||
pricing,
|
||||
total: pricing.total,
|
||||
error: null,
|
||||
});
|
||||
})
|
||||
@@ -227,17 +240,19 @@ router.post(
|
||||
return res.redirect('/cart');
|
||||
}
|
||||
|
||||
const pricing = await buildCartPricing(items, req.session, req.session.userId);
|
||||
const { name, email, phone, address } = req.body;
|
||||
|
||||
if (!name?.trim() || !email?.trim() || !address?.trim()) {
|
||||
return res.status(400).render('checkout', {
|
||||
title: 'Оформление заказа',
|
||||
items,
|
||||
total: cartTotal(items),
|
||||
pricing,
|
||||
total: pricing.total,
|
||||
error: 'Заполните имя, email и адрес доставки',
|
||||
});
|
||||
}
|
||||
|
||||
const total = cartTotal(items);
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
@@ -253,13 +268,40 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
let promoId = null;
|
||||
if (pricing.promo) {
|
||||
const promoRow = await promoService.findPromoByCode(pricing.promo.code);
|
||||
const check = promoService.validatePromo(
|
||||
promoRow,
|
||||
pricing.subtotal
|
||||
);
|
||||
if (!check.ok) throw new Error(check.error);
|
||||
promoId = promoRow.id;
|
||||
}
|
||||
|
||||
if (pricing.loyaltyPointsUsed > 0) {
|
||||
const bal = await loyaltyService.getBalance(req.session.userId);
|
||||
if (bal < pricing.loyaltyPointsUsed) {
|
||||
throw new Error('Недостаточно баллов лояльности');
|
||||
}
|
||||
}
|
||||
|
||||
const orderResult = await client.query(
|
||||
`INSERT INTO orders (user_id, status, total_cents, customer_name, customer_email, customer_phone, address)
|
||||
VALUES ($1, 'pending', $2, $3, $4, $5, $6)
|
||||
`INSERT INTO orders (
|
||||
user_id, status, subtotal_cents, discount_cents, total_cents,
|
||||
promo_code_id, loyalty_points_used, loyalty_points_earned,
|
||||
customer_name, customer_email, customer_phone, address
|
||||
)
|
||||
VALUES ($1, 'pending', $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`,
|
||||
[
|
||||
req.session.userId,
|
||||
total,
|
||||
pricing.subtotal,
|
||||
pricing.promoDiscount + pricing.loyaltyDiscount,
|
||||
pricing.total,
|
||||
promoId,
|
||||
pricing.loyaltyPointsUsed,
|
||||
pricing.pointsEarned,
|
||||
name.trim(),
|
||||
email.trim(),
|
||||
(phone || '').trim(),
|
||||
@@ -268,6 +310,19 @@ router.post(
|
||||
);
|
||||
const orderId = orderResult.rows[0].id;
|
||||
|
||||
if (promoId) {
|
||||
await promoService.incrementPromoUse(promoId, client);
|
||||
}
|
||||
|
||||
if (pricing.loyaltyPointsUsed > 0 || pricing.pointsEarned > 0) {
|
||||
await loyaltyService.applyLoyaltyOnOrder(
|
||||
client,
|
||||
req.session.userId,
|
||||
pricing.loyaltyPointsUsed,
|
||||
pricing.pointsEarned
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
await client.query(
|
||||
`INSERT INTO order_items (order_id, product_id, quantity, price_cents)
|
||||
@@ -282,6 +337,8 @@ router.post(
|
||||
|
||||
await client.query('COMMIT');
|
||||
req.session.cart = {};
|
||||
delete req.session.appliedPromoCode;
|
||||
delete req.session.loyaltyPointsToUse;
|
||||
res.redirect(`/orders/${orderId}?success=1`);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
|
||||
Reference in New Issue
Block a user