feat: интерактивный установщик install.sh (Docker / Ubuntu, админ, БД)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-17 13:57:54 +03:00
parent dedef454c8
commit db4bc9bfe1
28 changed files with 1069 additions and 22 deletions
+1 -1
View File
@@ -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];
+58
View File
@@ -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;
+86
View File
@@ -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
View File
@@ -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');