diff --git a/.env.docker.example b/.env.docker.example index 8af8c16..2237fb1 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -1,5 +1,6 @@ -# Скопируйте: cp .env.docker.example .env -# Используется docker compose (переменные подставляются в compose) +# Docker: лучше запустить интерактивный установщик: +# bash scripts/install.sh +# Или вручную: cp .env.docker.example .env POSTGRES_USER=shop POSTGRES_PASSWORD=shop @@ -9,4 +10,7 @@ APP_PORT=3000 SESSION_SECRET=change-me-to-a-long-random-string TRUST_PROXY=0 -# С профилем proxy (Caddy): TRUST_PROXY=1 +ADMIN_EMAIL=admin@site.com +ADMIN_PASSWORD=admin +ADMIN_NAME=Администратор +SITE_URL=http://localhost:3000 diff --git a/README.md b/README.md index 27cb719..ed156f9 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - Роли: клиент (`customer`) и **один** администратор (`admin`) — аккаунт из `ADMIN_EMAIL` в `.env` - Согласие на cookies - Подписка «сообщить о поступлении», если товара нет в наличии +- Лояльность (баллы), промокоды со скидкой и таймером до конца акции ## Требования @@ -111,6 +112,19 @@ bash scripts/setup-postgres-ubuntu.sh --- +## Интерактивный установщик + +Задаёт вопросы: **Docker или Ubuntu**, данные **администратора**, **PostgreSQL**, URL сайта, опционально SMTP. + +```bash +cd /path/to/shop +bash scripts/install.sh +``` + +Нативная установка на сервере — от root: `sudo bash scripts/install.sh`. + +--- + ## Быстрый развёртывание на Ubuntu Подставьте **URL своего репозитория** и каталог клона `SHOP_ROOT` (часто `/opt/shop`): @@ -338,6 +352,7 @@ caddy/Caddyfile.docker.example deploy/shop.service scripts/ setup-postgres-ubuntu.sh + install.sh install-postgresql-ubuntu.sh quick-deploy-ubuntu.sh fix-db-connection.sh diff --git a/docker-compose.yml b/docker-compose.yml index f3cc5a5..541f5af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,8 @@ services: build: . container_name: shop-app restart: unless-stopped + env_file: + - .env depends_on: postgres: condition: service_healthy @@ -31,7 +33,6 @@ services: HOST: 0.0.0.0 PORT: 3000 TRUST_PROXY: ${TRUST_PROXY:-0} - SESSION_SECRET: ${SESSION_SECRET:-change-me-in-docker-compose-env} DATABASE_URL: postgresql://${POSTGRES_USER:-shop}:${POSTGRES_PASSWORD:-shop}@postgres:5432/${POSTGRES_DB:-shop} ports: - '${APP_PORT:-3000}:3000' diff --git a/postgres/init/06_loyalty_promo.sql b/postgres/init/06_loyalty_promo.sql new file mode 100644 index 0000000..889a895 --- /dev/null +++ b/postgres/init/06_loyalty_promo.sql @@ -0,0 +1,28 @@ +-- Лояльность и промокоды +ALTER TABLE users ADD COLUMN IF NOT EXISTS loyalty_points INTEGER NOT NULL DEFAULT 0 + CHECK (loyalty_points >= 0); + +CREATE TABLE IF NOT EXISTS promo_codes ( + id SERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + discount_type TEXT NOT NULL CHECK (discount_type IN ('percent', 'fixed')), + discount_value INTEGER NOT NULL CHECK (discount_value > 0), + starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + min_order_cents INTEGER NOT NULL DEFAULT 0 CHECK (min_order_cents >= 0), + max_uses INTEGER CHECK (max_uses IS NULL OR max_uses > 0), + use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count >= 0), + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_promo_codes_active ON promo_codes (active, expires_at); + +ALTER TABLE orders ADD COLUMN IF NOT EXISTS subtotal_cents INTEGER; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS discount_cents INTEGER NOT NULL DEFAULT 0; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS promo_code_id INTEGER REFERENCES promo_codes(id); +ALTER TABLE orders ADD COLUMN IF NOT EXISTS loyalty_points_used INTEGER NOT NULL DEFAULT 0; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS loyalty_points_earned INTEGER NOT NULL DEFAULT 0; + +UPDATE orders SET subtotal_cents = total_cents WHERE subtotal_cents IS NULL; diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..ebd0ce2 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,257 @@ +#!/bin/bash +# Интерактивный установщик Shop +# bash scripts/install.sh +# sudo bash scripts/install.sh (нативная установка на Ubuntu) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# --- ввод --- +read_default() { + local prompt="$1" + local default="$2" + local value + if [ -n "$default" ]; then + read -rp "$prompt [$default]: " value + echo "${value:-$default}" + else + read -rp "$prompt: " value + echo "$value" + fi +} + +read_secret() { + local prompt="$1" + local value + read -rsp "$prompt" value + echo "" + echo "$value" +} + +read_secret_confirm() { + local prompt="$1" + local a b + while true; do + a=$(read_secret "$prompt") + b=$(read_secret "Повторите: ") + if [ "$a" = "$b" ]; then + echo "$a" + return + fi + echo "Пароли не совпадают. Попробуйте снова." + done +} + +gen_secret() { + if command -v openssl >/dev/null; then + openssl rand -hex 32 + else + head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' + fi +} + +# Безопасная запись значения в .env (одинарные кавычки) +env_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +email_ok() { + [[ "$1" =~ ^[^\s@]+@[^\s@]+\.[^\s@]+$ ]] +} + +# --- главная --- +clear 2>/dev/null || true +echo "============================================" +echo " Shop — интерактивная установка" +echo "============================================" +echo "" + +# Каталог установки +if [ -f "$REPO_ROOT/package.json" ]; then + INSTALL_DIR=$(read_default "Каталог установки" "$REPO_ROOT") +else + INSTALL_DIR=$(read_default "Каталог установки" "/opt/shop") + if [ ! -f "$INSTALL_DIR/package.json" ]; then + GIT_URL=$(read_default "URL git-репозитория" "") + if [ -z "$GIT_URL" ]; then + echo "Ошибка: укажите URL репозитория или запустите установщик из клона." + exit 1 + fi + echo "Клонирование $GIT_URL -> $INSTALL_DIR ..." + mkdir -p "$(dirname "$INSTALL_DIR")" + git clone "$GIT_URL" "$INSTALL_DIR" + fi +fi + +cd "$INSTALL_DIR" +export SHOP_ROOT="$INSTALL_DIR" + +# Режим +echo "" +echo "Способ установки:" +echo " 1) Docker Compose (PostgreSQL + приложение в контейнерах)" +echo " 2) Без Docker (Ubuntu: Node.js + PostgreSQL + systemd)" +echo "" +MODE=$(read_default "Выберите [1/2]" "1") + +# Администратор +echo "" +echo "--- Администратор магазина (единственный admin) ---" +ADMIN_EMAIL=$(read_default "Email администратора" "admin@site.com") +while ! email_ok "$ADMIN_EMAIL"; do + echo "Некорректный email." + ADMIN_EMAIL=$(read_default "Email администратора" "admin@site.com") +done +ADMIN_NAME=$(read_default "Имя администратора" "Администратор") +ADMIN_PASSWORD=$(read_secret_confirm "Пароль администратора: ") + +# База данных +echo "" +echo "--- PostgreSQL ---" +PG_USER=$(read_default "Пользователь БД" "shop") +PG_PASS=$(read_secret_confirm "Пароль БД: ") +PG_DB=$(read_default "Имя базы данных" "shop") + +if [ "$MODE" = "1" ]; then + PG_HOST="postgres" + PG_PORT="5432" + APP_PORT=$(read_default "Порт сайта на хосте" "3000") + TRUST_PROXY="0" + echo "" + read -rp "Включить Caddy (HTTPS, порты 80/443)? [y/N]: " USE_CADDY + if [[ "${USE_CADDY,,}" == "y" || "${USE_CADDY,,}" == "yes" ]]; then + TRUST_PROXY="1" + USE_CADDY=1 + else + USE_CADDY=0 + fi +else + PG_HOST=$(read_default "Хост PostgreSQL" "127.0.0.1") + PG_PORT=$(read_default "Порт PostgreSQL" "5432") + APP_PORT="3000" + TRUST_PROXY=$(read_default "За reverse proxy (Caddy)? TRUST_PROXY [1/0]" "1") + USE_CADDY=0 +fi + +# Сайт и секрет +echo "" +echo "--- Прочие настройки ---" +if [ "$MODE" = "1" ] && [ "$USE_CADDY" = "1" ]; then + SITE_DEFAULT="https://shop.example.com" +else + SITE_DEFAULT="http://localhost:${APP_PORT}" +fi +SITE_URL=$(read_default "URL сайта (SITE_URL)" "$SITE_DEFAULT") +SESSION_SECRET=$(read_default "SESSION_SECRET (Enter = сгенерировать)" "") +SESSION_SECRET=${SESSION_SECRET:-$(gen_secret)} + +echo "" +read -rp "Настроить SMTP для писем? [y/N]: " SET_SMTP +SMTP_BLOCK="" +if [[ "${SET_SMTP,,}" == "y" || "${SET_SMTP,,}" == "yes" ]]; then + SMTP_HOST=$(read_default "SMTP_HOST" "smtp.example.com") + SMTP_PORT=$(read_default "SMTP_PORT" "587") + SMTP_USER=$(read_default "SMTP_USER" "") + SMTP_PASS=$(read_secret "SMTP_PASS: ") + SMTP_FROM=$(read_default "SMTP_FROM" "shop@example.com") + SMTP_BLOCK="# SMTP +SMTP_HOST=${SMTP_HOST} +SMTP_PORT=${SMTP_PORT} +SMTP_SECURE=false +SMTP_USER=${SMTP_USER} +SMTP_PASS=${SMTP_PASS} +SMTP_FROM=${SMTP_FROM} +" +fi + +DATABASE_URL="postgresql://${PG_USER}:${PG_PASS}@${PG_HOST}:${PG_PORT}/${PG_DB}" + +# --- запись .env --- +ENV_FILE="$INSTALL_DIR/.env" +APP_HOST=$([ "$MODE" = "1" ] && echo "0.0.0.0" || echo "127.0.0.1") +{ + echo "# Создано scripts/install.sh $(date -Iseconds)" + echo "" + echo "PORT=${APP_PORT}" + echo "HOST=${APP_HOST}" + echo "NODE_ENV=production" + echo "TRUST_PROXY=${TRUST_PROXY}" + echo "SESSION_SECRET=$(env_quote "$SESSION_SECRET")" + echo "" + echo "ADMIN_EMAIL=$(env_quote "$ADMIN_EMAIL")" + echo "ADMIN_PASSWORD=$(env_quote "$ADMIN_PASSWORD")" + echo "ADMIN_NAME=$(env_quote "$ADMIN_NAME")" + echo "" + echo "SITE_URL=$(env_quote "$SITE_URL")" + echo "" + if [ -n "$SMTP_BLOCK" ]; then + echo "$SMTP_BLOCK" + fi + echo "# PostgreSQL" + echo "POSTGRES_USER=$(env_quote "$PG_USER")" + echo "POSTGRES_PASSWORD=$(env_quote "$PG_PASS")" + echo "POSTGRES_DB=$(env_quote "$PG_DB")" + echo "DATABASE_URL=$(env_quote "$DATABASE_URL")" + echo "PGHOST=$(env_quote "$PG_HOST")" + echo "PGPORT=${PG_PORT}" + echo "PGUSER=$(env_quote "$PG_USER")" + echo "PGPASSWORD=$(env_quote "$PG_PASS")" + echo "PGDATABASE=$(env_quote "$PG_DB")" +} > "$ENV_FILE" +chmod 600 "$ENV_FILE" 2>/dev/null || true +echo "" +echo "Сохранено: $ENV_FILE" + +# --- установка --- +echo "" +if [ "$MODE" = "1" ]; then + echo "=== Установка через Docker ===" + if ! command -v docker >/dev/null; then + echo "Ошибка: Docker не установлен. Установите Docker и повторите." + exit 1 + fi + if ! docker compose version >/dev/null 2>&1; then + echo "Ошибка: нужен Docker Compose v2 (docker compose)." + exit 1 + fi + COMPOSE_CMD=(docker compose) + if [ "$USE_CADDY" = "1" ]; then + echo "Запуск: postgres + app + caddy ..." + "${COMPOSE_CMD[@]}" --profile proxy up -d --build + else + echo "Запуск: postgres + app ..." + "${COMPOSE_CMD[@]}" up -d --build + fi + echo "Ожидание health..." + sleep 5 + curl -sf "http://127.0.0.1:${APP_PORT}/health" && echo "" || echo "Проверьте: docker compose logs app" +else + echo "=== Установка без Docker (Ubuntu) ===" + if [ "$(id -u)" -ne 0 ]; then + echo "Запустите с root: sudo bash scripts/install.sh" + exit 1 + fi + bash "$SCRIPT_DIR/install-postgresql-ubuntu.sh" + export DB_USER="$PG_USER" DB_PASS="$PG_PASS" DB_NAME="$PG_DB" + bash "$SCRIPT_DIR/setup-postgres-ubuntu.sh" + npm install --omit=dev + bash "$SCRIPT_DIR/install-shop-service.sh" +fi + +echo "" +echo "============================================" +echo " Установка завершена" +echo "============================================" +echo " Каталог: $INSTALL_DIR" +echo " Сайт: $SITE_URL" +echo " Админ: $ADMIN_EMAIL" +if [ "$MODE" = "1" ]; then + echo " Порт: $APP_PORT" + echo " Логи: docker compose -f $INSTALL_DIR/docker-compose.yml logs -f" +else + echo " Служба: systemctl status shop" + echo " Health: curl http://127.0.0.1:3000/health" +fi +echo " Обновление: bash $INSTALL_DIR/scripts/server-update.sh" +echo "============================================" diff --git a/src/public/css/style.css b/src/public/css/style.css index 2cc78a6..6743076 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -547,6 +547,106 @@ a:hover { border-radius: 6px; } +.cart-sidebar { + display: grid; + gap: 1rem; + margin-top: 1.25rem; + max-width: 420px; +} + +@media (min-width: 900px) { + .cart-table-wrap { + display: grid; + grid-template-columns: 1fr minmax(280px, 360px); + gap: 1.5rem; + align-items: start; + } + + .cart-sidebar { + margin-top: 0; + } +} + +.promo-box__title { + margin: 0 0 0.75rem; + font-size: 1rem; +} + +.promo-box__form { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.promo-box__form .input { + flex: 1; + min-width: 120px; +} + +.promo-box__applied { + margin: 0 0 0.5rem; +} + +.promo-box__discount { + color: var(--success); + margin: 0 0 0.5rem; +} + +.promo-countdown { + font-size: 0.9rem; + margin: 0.5rem 0; + padding: 0.5rem 0.75rem; + background: rgba(253, 203, 110, 0.12); + border-radius: 8px; + border: 1px solid rgba(253, 203, 110, 0.35); +} + +.promo-countdown__timer { + font-weight: 600; + color: var(--warn); + font-variant-numeric: tabular-nums; +} + +.promo-countdown__timer--ended { + color: var(--error); +} + +.cart-summary__dl { + margin: 0 0 1rem; +} + +.cart-summary__dl dt { + color: var(--muted); + font-size: 0.9rem; +} + +.cart-summary__dl dd { + margin: 0 0 0.5rem; + text-align: right; +} + +.cart-summary__dl dt, +.cart-summary__dl dd { + display: inline-block; + width: 48%; + vertical-align: top; +} + +.cart-summary__discount { + color: var(--success); +} + +.cart-summary__total-label, +.cart-summary__total { + font-size: 1.1rem; + font-weight: 600; +} + +.checkout-promo { + font-size: 0.9rem; + margin: 0.75rem 0; +} + .cart-actions { display: flex; flex-wrap: wrap; diff --git a/src/public/js/promo-countdown.js b/src/public/js/promo-countdown.js new file mode 100644 index 0000000..95bcc58 --- /dev/null +++ b/src/public/js/promo-countdown.js @@ -0,0 +1,34 @@ +(function () { + function pad(n) { + return String(n).padStart(2, '0'); + } + + function formatRemaining(ms) { + if (ms <= 0) return 'акция завершена'; + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (d > 0) return `${d} д ${pad(h)}:${pad(m)}:${pad(sec)}`; + return `${pad(h)}:${pad(m)}:${pad(sec)}`; + } + + document.querySelectorAll('.promo-countdown[data-expires]').forEach((el) => { + const expires = new Date(el.dataset.expires).getTime(); + const timer = el.querySelector('.promo-countdown__timer'); + if (!timer || Number.isNaN(expires)) return; + + function tick() { + const left = expires - Date.now(); + timer.textContent = formatRemaining(left); + if (left <= 0) timer.classList.add('promo-countdown__timer--ended'); + } + + tick(); + const id = setInterval(tick, 1000); + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => clearInterval(id)); + } + }); +})(); diff --git a/src/routes/account.js b/src/routes/account.js index d205d3b..8e225da 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -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]; diff --git a/src/routes/admin.js b/src/routes/admin.js index b7f2e20..24febf3 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -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; diff --git a/src/routes/promo.js b/src/routes/promo.js new file mode 100644 index 0000000..4e2111e --- /dev/null +++ b/src/routes/promo.js @@ -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; diff --git a/src/routes/shop.js b/src/routes/shop.js index 3aa97e6..025b9d7 100644 --- a/src/routes/shop.js +++ b/src/routes/shop.js @@ -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'); diff --git a/src/seed-promo.js b/src/seed-promo.js new file mode 100644 index 0000000..f181fae --- /dev/null +++ b/src/seed-promo.js @@ -0,0 +1,20 @@ +const { query } = require('./db'); + +async function seedPromoCodes() { + const { rows } = await query('SELECT COUNT(*)::int AS n FROM promo_codes'); + if (rows[0].n > 0) return; + + const expires = new Date(); + expires.setDate(expires.getDate() + 30); + + await query( + `INSERT INTO promo_codes (code, description, discount_type, discount_value, expires_at, min_order_cents) + VALUES + ('WELCOME10', 'Скидка 10% новым покупателям', 'percent', 10, $1, 0), + ('SALE500', 'Скидка 500 ₽ от 3000 ₽', 'fixed', 50000, $1, 300000)`, + [expires.toISOString()] + ); + console.log('Демо-промокоды: WELCOME10, SALE500'); +} + +module.exports = { seedPromoCodes }; diff --git a/src/server.js b/src/server.js index a9bbb6d..db35875 100644 --- a/src/server.js +++ b/src/server.js @@ -7,6 +7,7 @@ const pgSession = require('connect-pg-simple')(session); const { pool, initSchema, checkConnection } = require('./db'); const { runSeed } = require('./seed'); const { seedAdmin } = require('./seed-admin'); +const { seedPromoCodes } = require('./seed-promo'); const { loadUser } = require('./middleware/auth'); const { loadCookieConsent } = require('./middleware/cookieConsent'); const healthRoutes = require('./routes/health'); @@ -19,6 +20,7 @@ const passwordResetRoutes = require('./routes/password-reset'); const reservationsRoutes = require('./routes/reservations'); const passkeyRoutes = require('./routes/passkey'); const stockAlertsRoutes = require('./routes/stock-alerts'); +const promoRoutes = require('./routes/promo'); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || '0.0.0.0'; @@ -29,6 +31,7 @@ async function start() { await initSchema(); await runSeed(); await seedAdmin(); + await seedPromoCodes(); const app = express(); @@ -70,6 +73,7 @@ async function start() { app.use('/', passwordResetRoutes); app.use('/reservations', reservationsRoutes); app.use('/', stockAlertsRoutes); + app.use('/', promoRoutes); app.use('/', shopRoutes); app.use('/', authRoutes); app.use('/webauthn', passkeyRoutes); diff --git a/src/services/loyalty.js b/src/services/loyalty.js new file mode 100644 index 0000000..33ee909 --- /dev/null +++ b/src/services/loyalty.js @@ -0,0 +1,48 @@ +const { query } = require('../db'); + +/** Баллов за каждые 100 ₽ subtotal после скидок */ +const EARN_PER_100_RUB = 10; +/** 1 балл = 1 копейка скидки */ +const POINT_VALUE_CENTS = 1; + +async function getBalance(userId) { + const { rows } = await query('SELECT loyalty_points FROM users WHERE id = $1', [ + userId, + ]); + return rows[0]?.loyalty_points ?? 0; +} + +function calcEarnedPoints(payableCents) { + if (payableCents <= 0) return 0; + return Math.floor((payableCents / 10000) * EARN_PER_100_RUB); +} + +function calcLoyaltyDiscountCents(pointsToUse, balance, maxCents) { + const use = Math.min( + Math.max(0, parseInt(pointsToUse, 10) || 0), + balance, + maxCents + ); + return use * POINT_VALUE_CENTS; +} + +function pointsForDiscount(discountCents) { + return Math.floor(discountCents / POINT_VALUE_CENTS); +} + +async function applyLoyaltyOnOrder(client, userId, pointsUsed, pointsEarned) { + await client.query( + `UPDATE users SET loyalty_points = loyalty_points - $1 + $2 WHERE id = $3`, + [pointsUsed, pointsEarned, userId] + ); +} + +module.exports = { + EARN_PER_100_RUB, + POINT_VALUE_CENTS, + getBalance, + calcEarnedPoints, + calcLoyaltyDiscountCents, + pointsForDiscount, + applyLoyaltyOnOrder, +}; diff --git a/src/services/pricing.js b/src/services/pricing.js new file mode 100644 index 0000000..2079f20 --- /dev/null +++ b/src/services/pricing.js @@ -0,0 +1,66 @@ +const promoService = require('./promo'); +const loyaltyService = require('./loyalty'); + +async function buildCartPricing(items, session, userId) { + const subtotal = items.reduce((s, i) => s + i.line_total, 0); + + let promo = null; + let promoDiscount = 0; + let promoError = null; + + if (session.appliedPromoCode) { + promo = await promoService.findPromoByCode(session.appliedPromoCode); + const check = promoService.validatePromo(promo, subtotal); + if (!check.ok) { + promoError = check.error; + promo = null; + delete session.appliedPromoCode; + } else { + promoDiscount = promoService.calcPromoDiscountCents(promo, subtotal); + } + } + + const afterPromo = Math.max(0, subtotal - promoDiscount); + + let loyaltyBalance = 0; + let loyaltyDiscount = 0; + let loyaltyPointsUsed = 0; + + if (userId) { + loyaltyBalance = await loyaltyService.getBalance(userId); + const requested = session.loyaltyPointsToUse ?? 0; + loyaltyDiscount = loyaltyService.calcLoyaltyDiscountCents( + requested, + loyaltyBalance, + afterPromo + ); + loyaltyPointsUsed = loyaltyService.pointsForDiscount(loyaltyDiscount); + } + + const total = Math.max(0, afterPromo - loyaltyDiscount); + const pointsEarned = + userId && total > 0 ? loyaltyService.calcEarnedPoints(total) : 0; + + return { + subtotal, + promoDiscount, + loyaltyDiscount, + loyaltyPointsUsed, + loyaltyPointsUsedDisplay: loyaltyPointsUsed, + loyaltyBalance, + pointsEarned, + total, + promo: promo + ? { + code: promo.code, + description: promo.description, + discount_type: promo.discount_type, + discount_value: promo.discount_value, + expires_at: promo.expires_at, + } + : null, + promoError, + }; +} + +module.exports = { buildCartPricing }; diff --git a/src/services/promo.js b/src/services/promo.js new file mode 100644 index 0000000..70fa8a9 --- /dev/null +++ b/src/services/promo.js @@ -0,0 +1,62 @@ +const { query } = require('../db'); + +function normalizeCode(code) { + return String(code || '') + .trim() + .toUpperCase() + .replace(/\s+/g, ''); +} + +async function findPromoByCode(code) { + const normalized = normalizeCode(code); + if (!normalized) return null; + + const { rows } = await query( + `SELECT * FROM promo_codes WHERE UPPER(code) = $1 AND active = true`, + [normalized] + ); + return rows[0] || null; +} + +function validatePromo(promo, subtotalCents) { + if (!promo) return { ok: false, error: 'Промокод не найден' }; + + const now = new Date(); + if (new Date(promo.starts_at) > now) { + return { ok: false, error: 'Промокод ещё не действует' }; + } + if (new Date(promo.expires_at) <= now) { + return { ok: false, error: 'Срок действия промокода истёк' }; + } + if (promo.max_uses != null && promo.use_count >= promo.max_uses) { + return { ok: false, error: 'Лимит использований промокода исчерпан' }; + } + if (subtotalCents < promo.min_order_cents) { + const min = (promo.min_order_cents / 100).toFixed(0); + return { ok: false, error: `Минимальная сумма заказа ${min} ₽` }; + } + + return { ok: true }; +} + +function calcPromoDiscountCents(promo, subtotalCents) { + if (!promo) return 0; + if (promo.discount_type === 'percent') { + const pct = Math.min(100, Math.max(1, promo.discount_value)); + return Math.floor((subtotalCents * pct) / 100); + } + return Math.min(subtotalCents, promo.discount_value); +} + +async function incrementPromoUse(promoId, client) { + const q = client ? client.query.bind(client) : query; + await q('UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1', [promoId]); +} + +module.exports = { + normalizeCode, + findPromoByCode, + validatePromo, + calcPromoDiscountCents, + incrementPromoUse, +}; diff --git a/src/views/account/index.ejs b/src/views/account/index.ejs index 402bfb8..df3bee3 100644 --- a/src/views/account/index.ejs +++ b/src/views/account/index.ejs @@ -29,6 +29,8 @@
<%= new Date(user.created_at).toLocaleString('ru-RU') %>
Заказов
<%= orderCount %>
+
Баллы лояльности
+
<%= user.loyalty_points || 0 %> (1 балл = 1 коп. скидки)
Мои заказы diff --git a/src/views/admin/dashboard.ejs b/src/views/admin/dashboard.ejs index 650334f..9105320 100644 --- a/src/views/admin/dashboard.ejs +++ b/src/views/admin/dashboard.ejs @@ -7,6 +7,7 @@ Заказы Пользователи Товары + Промокоды Бронирования В магазин diff --git a/src/views/admin/orders.ejs b/src/views/admin/orders.ejs index 0edc6c2..516a4c7 100644 --- a/src/views/admin/orders.ejs +++ b/src/views/admin/orders.ejs @@ -7,6 +7,7 @@ Заказы Пользователи Товары + Промокоды Бронирования В магазин diff --git a/src/views/admin/products.ejs b/src/views/admin/products.ejs index 1ac4687..ad585bf 100644 --- a/src/views/admin/products.ejs +++ b/src/views/admin/products.ejs @@ -7,6 +7,7 @@ Заказы Пользователи Товары + Промокоды Бронирования В магазин diff --git a/src/views/admin/promo-codes.ejs b/src/views/admin/promo-codes.ejs new file mode 100644 index 0000000..deeec11 --- /dev/null +++ b/src/views/admin/promo-codes.ejs @@ -0,0 +1,69 @@ +<%- include('../partials/layout-start') %> + +
+

Промокоды и скидки

+ +
+ +<% if (created) { %>

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

<% } %> + +
+

Новый промокод

+
+ + + + + + + + +
+
+ + + + + + + + + + + + + + <% promos.forEach(p => { %> + + + + + + + + + <% }) %> + +
КодСкидкаДоИспользованоСтатус
<%= p.code %>
<%= p.description %>
+ <% 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 ? 'Активен' : 'Выкл.' %> +
+ +
+
+ +<%- include('../partials/layout-end') %> diff --git a/src/views/admin/reservations.ejs b/src/views/admin/reservations.ejs index ec2e1f0..8920da2 100644 --- a/src/views/admin/reservations.ejs +++ b/src/views/admin/reservations.ejs @@ -7,6 +7,7 @@ Заказы Пользователи Товары + Промокоды Бронирования В магазин diff --git a/src/views/admin/users.ejs b/src/views/admin/users.ejs index 384ca23..a1dbacd 100644 --- a/src/views/admin/users.ejs +++ b/src/views/admin/users.ejs @@ -7,6 +7,7 @@ Заказы Пользователи Товары + Промокоды Бронирования В магазин diff --git a/src/views/cart.ejs b/src/views/cart.ejs index dfd4ad8..a4bd627 100644 --- a/src/views/cart.ejs +++ b/src/views/cart.ejs @@ -3,6 +3,8 @@

Корзина

<% if (error) { %>

<%= error %>

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

<%= promoOk %>

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

<%= promoErr %>

<% } %> <% if (!items.length) { %>

Корзина пуста. Перейти в каталог

@@ -39,16 +41,76 @@ <% }) %> -
- -

Итого: <%= formatPrice(total) %>

+ +
+
+

Промокод

+ <% if (pricing.promo) { %> +

+ <%= pricing.promo.code %> + <% if (pricing.promo.description) { %> — <%= pricing.promo.description %><% } %> +

+

Скидка: −<%= formatPrice(pricing.promoDiscount) %>

+
+ До конца акции: + +
+
+ +
+ <% } else { %> +
+ + +
+ <% } %> +
+ <% if (user) { %> - Оформить заказ - <% } else { %> -

Войдите, чтобы оформить заказ.

+
+

Баллы лояльности

+

На счёте: <%= pricing.loyaltyBalance %> баллов (1 балл = 1 коп.)

+ <% if (pricing.pointsEarned > 0) { %> +

За этот заказ начислим: +<%= pricing.pointsEarned %> баллов

+ <% } %> + <% if (pricing.loyaltyDiscount > 0) { %> +

Списано: −<%= formatPrice(pricing.loyaltyDiscount) %> (<%= pricing.loyaltyPointsUsed %> баллов)

+
+ +
+ <% } else if (pricing.loyaltyBalance > 0) { %> +
+ +
+ <% } %> +
<% } %> + +
+
+
Товары
+
<%= formatPrice(pricing.subtotal) %>
+ <% if (pricing.promoDiscount > 0) { %> +
Промокод
+
−<%= formatPrice(pricing.promoDiscount) %>
+ <% } %> + <% if (pricing.loyaltyDiscount > 0) { %> +
Лояльность
+
−<%= formatPrice(pricing.loyaltyDiscount) %>
+ <% } %> +
К оплате
+
<%= formatPrice(pricing.total) %>
+
+ <% if (user) { %> + Оформить заказ + <% } else { %> +

Войдите, чтобы оформить заказ.

+ <% } %> +
<% } %> + + <%- include('partials/layout-end') %> diff --git a/src/views/checkout.ejs b/src/views/checkout.ejs index d85b92f..5dd85db 100644 --- a/src/views/checkout.ejs +++ b/src/views/checkout.ejs @@ -36,8 +36,36 @@ <% }) %> -

Итого: <%= formatPrice(total) %>

+ <% if (pricing.promo) { %> +

+ Промокод <%= pricing.promo.code %> + + () + +

+ <% } %> +
+
Товары
+
<%= formatPrice(pricing.subtotal) %>
+ <% if (pricing.promoDiscount > 0) { %> +
Скидка по промокоду
+
−<%= formatPrice(pricing.promoDiscount) %>
+ <% } %> + <% if (pricing.loyaltyDiscount > 0) { %> +
Баллы лояльности
+
−<%= formatPrice(pricing.loyaltyDiscount) %>
+ <% } %> + <% if (pricing.pointsEarned > 0) { %> +
Начислим баллов
+
+<%= pricing.pointsEarned %>
+ <% } %> +
К оплате
+
<%= formatPrice(pricing.total) %>
+
+

Изменить корзину или промокод

+ + <%- include('partials/layout-end') %> diff --git a/src/views/order.ejs b/src/views/order.ejs index b8e98ca..e2292b9 100644 --- a/src/views/order.ejs +++ b/src/views/order.ejs @@ -22,7 +22,19 @@ <% }) %> -

Итого: <%= formatPrice(order.total_cents) %>

+ <% const subtotal = order.subtotal_cents != null ? order.subtotal_cents : order.total_cents; %> + <% if (order.discount_cents > 0) { %> +
+
Товары
+
<%= formatPrice(subtotal) %>
+
Скидка
+
−<%= formatPrice(order.discount_cents) %>
+
+ <% } %> + <% if (order.loyalty_points_earned > 0) { %> +

Начислено баллов лояльности: +<%= order.loyalty_points_earned %>

+ <% } %> +

К оплате: <%= formatPrice(order.total_cents) %>

← Все заказы

diff --git a/wiki/Home.md b/wiki/Home.md index d0e3576..5e67a40 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -25,6 +25,14 @@ Админ-панель доступна только этому аккаунту. +## Установщик + +Интерактивно: админ, PostgreSQL, Docker или Ubuntu: + +```bash +bash scripts/install.sh +``` + ## Быстрый старт ### Docker diff --git a/wiki/Server-Operations.md b/wiki/Server-Operations.md index 0028711..7e6df1f 100644 --- a/wiki/Server-Operations.md +++ b/wiki/Server-Operations.md @@ -17,6 +17,26 @@ test -f "$SHOP_ROOT/package.json" && echo OK || echo "Неверный SHOP_ROOT --- +## Интерактивный установщик (рекомендуется) + +```bash +cd "$SHOP_ROOT" +bash scripts/install.sh +``` + +Скрипт спросит: + +1. **Docker** или **Ubuntu без Docker** +2. Email, имя и пароль **администратора** +3. Пользователь, пароль и имя базы **PostgreSQL** +4. **URL сайта**, секрет сессий (можно сгенерировать) +5. Опционально **SMTP** +6. Для Docker — порт и включить ли **Caddy** + +Создаётся файл `.env`, затем запускается `docker compose` или `systemd`. + +--- + ## Первая установка (Ubuntu, без Docker) ```bash @@ -73,7 +93,8 @@ bash scripts/server-update.sh | Скрипт | Назначение | |--------|------------| -| `quick-deploy-ubuntu.sh` | Первая установка / полный цикл | +| `install.sh` | **Интерактивная установка** (Docker или Ubuntu) | +| `quick-deploy-ubuntu.sh` | Первая установка / полный цикл (без вопросов) | | `server-update.sh` | `git pull`, `npm install`, перезапуск shop | | `git-sync.sh` | Исправить detached HEAD, синхронизация с `main` | | `install-postgresql-ubuntu.sh` | PostgreSQL 17 через PGDG |