feat: интерактивный установщик install.sh (Docker / Ubuntu, админ, БД)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+7
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
Executable
+257
@@ -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 "============================================"
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -29,6 +29,8 @@
|
||||
<dd><%= new Date(user.created_at).toLocaleString('ru-RU') %></dd>
|
||||
<dt>Заказов</dt>
|
||||
<dd><%= orderCount %></dd>
|
||||
<dt>Баллы лояльности</dt>
|
||||
<dd><strong><%= user.loyalty_points || 0 %></strong> <span class="muted">(1 балл = 1 коп. скидки)</span></dd>
|
||||
</dl>
|
||||
<div class="account-actions">
|
||||
<a href="/orders" class="btn btn--primary">Мои заказы</a>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="/admin/orders" class="admin-nav__link admin-nav__link--active">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link admin-nav__link--active">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<%- include('../partials/layout-start') %>
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>Промокоды и скидки</h1>
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin" class="admin-nav__link">Обзор</a>
|
||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link admin-nav__link--active">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<% if (created) { %><p class="alert alert--success">Промокод создан</p><% } %>
|
||||
|
||||
<section class="card account-section--narrow" style="margin-bottom:1.5rem">
|
||||
<h2>Новый промокод</h2>
|
||||
<form action="/admin/promo-codes" method="post" class="form">
|
||||
<label class="label">Код <input type="text" name="code" class="input" required placeholder="SUMMER20"></label>
|
||||
<label class="label">Описание <input type="text" name="description" class="input" placeholder="Летняя скидка"></label>
|
||||
<label class="label">Тип скидки
|
||||
<select name="discount_type" class="input">
|
||||
<option value="percent">Процент %</option>
|
||||
<option value="fixed">Фиксированная (₽)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="label">Значение (10 = 10% или 500 ₽) <input type="number" name="discount_value" class="input" min="1" required></label>
|
||||
<label class="label">Действует дней <input type="number" name="valid_days" class="input" value="30" min="1"></label>
|
||||
<label class="label">Мин. сумма заказа (₽) <input type="number" name="min_order_rub" class="input" value="0" min="0"></label>
|
||||
<label class="label">Лимит использований (пусто = без лимита) <input type="number" name="max_uses" class="input" min="1"></label>
|
||||
<button type="submit" class="btn btn--primary">Создать</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<table class="cart-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Код</th>
|
||||
<th>Скидка</th>
|
||||
<th>До</th>
|
||||
<th>Использовано</th>
|
||||
<th>Статус</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% promos.forEach(p => { %>
|
||||
<tr>
|
||||
<td><strong><%= p.code %></strong><br><span class="muted"><%= p.description %></span></td>
|
||||
<td>
|
||||
<% if (p.discount_type === 'percent') { %><%= p.discount_value %>%<% } else { %><%= formatPrice(p.discount_value) %><% } %>
|
||||
<% if (p.min_order_cents > 0) { %><br><span class="muted">от <%= formatPrice(p.min_order_cents) %></span><% } %>
|
||||
</td>
|
||||
<td><%= new Date(p.expires_at).toLocaleString('ru-RU') %></td>
|
||||
<td><%= p.use_count %><% if (p.max_uses) { %> / <%= p.max_uses %><% } %></td>
|
||||
<td><%= p.active ? 'Активен' : 'Выкл.' %></td>
|
||||
<td>
|
||||
<form action="/admin/promo-codes/<%= p.id %>/toggle" method="post">
|
||||
<button type="submit" class="btn btn--ghost btn--sm"><%= p.active ? 'Выключить' : 'Включить' %></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<%- include('../partials/layout-end') %>
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link admin-nav__link--active">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link admin-nav__link--active">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
|
||||
+68
-6
@@ -3,6 +3,8 @@
|
||||
<h1>Корзина</h1>
|
||||
|
||||
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
||||
<% if (promoOk) { %><p class="alert alert--success"><%= promoOk %></p><% } %>
|
||||
<% if (promoErr) { %><p class="alert alert--error"><%= promoErr %></p><% } %>
|
||||
|
||||
<% if (!items.length) { %>
|
||||
<p class="empty">Корзина пуста. <a href="/">Перейти в каталог</a></p>
|
||||
@@ -39,16 +41,76 @@
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="cart-actions">
|
||||
<button type="submit" class="btn btn--ghost">Обновить</button>
|
||||
<p class="cart-total">Итого: <strong><%= formatPrice(total) %></strong></p>
|
||||
|
||||
<div class="cart-sidebar">
|
||||
<section class="card promo-box">
|
||||
<h2 class="promo-box__title">Промокод</h2>
|
||||
<% if (pricing.promo) { %>
|
||||
<p class="promo-box__applied">
|
||||
<strong><%= pricing.promo.code %></strong>
|
||||
<% if (pricing.promo.description) { %> — <%= pricing.promo.description %><% } %>
|
||||
</p>
|
||||
<p class="promo-box__discount">Скидка: −<%= formatPrice(pricing.promoDiscount) %></p>
|
||||
<div class="promo-countdown" data-expires="<%= pricing.promo.expires_at %>">
|
||||
<span class="promo-countdown__label">До конца акции:</span>
|
||||
<span class="promo-countdown__timer">—</span>
|
||||
</div>
|
||||
<form action="/cart/promo/remove" method="post" class="inline-form">
|
||||
<button type="submit" class="btn btn--ghost btn--sm">Убрать промокод</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<form action="/cart/promo" method="post" class="promo-box__form">
|
||||
<input type="text" name="code" class="input" placeholder="WELCOME10" required autocomplete="off">
|
||||
<button type="submit" class="btn btn--primary">Применить</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<% if (user) { %>
|
||||
<a href="/checkout" class="btn btn--primary btn--lg">Оформить заказ</a>
|
||||
<% } else { %>
|
||||
<p class="hint"><a href="/login?next=/checkout">Войдите</a>, чтобы оформить заказ.</p>
|
||||
<section class="card promo-box">
|
||||
<h2 class="promo-box__title">Баллы лояльности</h2>
|
||||
<p class="muted">На счёте: <strong><%= pricing.loyaltyBalance %></strong> баллов (1 балл = 1 коп.)</p>
|
||||
<% if (pricing.pointsEarned > 0) { %>
|
||||
<p class="muted">За этот заказ начислим: +<%= pricing.pointsEarned %> баллов</p>
|
||||
<% } %>
|
||||
<% if (pricing.loyaltyDiscount > 0) { %>
|
||||
<p class="promo-box__discount">Списано: −<%= formatPrice(pricing.loyaltyDiscount) %> (<%= pricing.loyaltyPointsUsed %> баллов)</p>
|
||||
<form action="/cart/loyalty/remove" method="post">
|
||||
<button type="submit" class="btn btn--ghost btn--sm">Отменить списание</button>
|
||||
</form>
|
||||
<% } else if (pricing.loyaltyBalance > 0) { %>
|
||||
<form action="/cart/loyalty" method="post" class="promo-box__form">
|
||||
<button type="submit" name="use_all" value="1" class="btn btn--ghost">Списать все доступные</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<section class="card cart-summary">
|
||||
<dl class="cart-summary__dl">
|
||||
<dt>Товары</dt>
|
||||
<dd><%= formatPrice(pricing.subtotal) %></dd>
|
||||
<% if (pricing.promoDiscount > 0) { %>
|
||||
<dt>Промокод</dt>
|
||||
<dd class="cart-summary__discount">−<%= formatPrice(pricing.promoDiscount) %></dd>
|
||||
<% } %>
|
||||
<% if (pricing.loyaltyDiscount > 0) { %>
|
||||
<dt>Лояльность</dt>
|
||||
<dd class="cart-summary__discount">−<%= formatPrice(pricing.loyaltyDiscount) %></dd>
|
||||
<% } %>
|
||||
<dt class="cart-summary__total-label">К оплате</dt>
|
||||
<dd class="cart-summary__total"><%= formatPrice(pricing.total) %></dd>
|
||||
</dl>
|
||||
<% if (user) { %>
|
||||
<a href="/checkout" class="btn btn--primary btn--lg btn--block">Оформить заказ</a>
|
||||
<% } else { %>
|
||||
<p class="hint"><a href="/login?next=/checkout">Войдите</a>, чтобы оформить заказ.</p>
|
||||
<% } %>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
<% } %>
|
||||
|
||||
<script src="/js/promo-countdown.js"></script>
|
||||
|
||||
<%- include('partials/layout-end') %>
|
||||
|
||||
+29
-1
@@ -36,8 +36,36 @@
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<p class="checkout-total">Итого: <strong><%= formatPrice(total) %></strong></p>
|
||||
<% if (pricing.promo) { %>
|
||||
<p class="checkout-promo">
|
||||
Промокод <strong><%= pricing.promo.code %></strong>
|
||||
<span class="promo-countdown" data-expires="<%= pricing.promo.expires_at %>">
|
||||
(<span class="promo-countdown__timer">—</span>)
|
||||
</span>
|
||||
</p>
|
||||
<% } %>
|
||||
<dl class="cart-summary__dl">
|
||||
<dt>Товары</dt>
|
||||
<dd><%= formatPrice(pricing.subtotal) %></dd>
|
||||
<% if (pricing.promoDiscount > 0) { %>
|
||||
<dt>Скидка по промокоду</dt>
|
||||
<dd class="cart-summary__discount">−<%= formatPrice(pricing.promoDiscount) %></dd>
|
||||
<% } %>
|
||||
<% if (pricing.loyaltyDiscount > 0) { %>
|
||||
<dt>Баллы лояльности</dt>
|
||||
<dd class="cart-summary__discount">−<%= formatPrice(pricing.loyaltyDiscount) %></dd>
|
||||
<% } %>
|
||||
<% if (pricing.pointsEarned > 0) { %>
|
||||
<dt>Начислим баллов</dt>
|
||||
<dd>+<%= pricing.pointsEarned %></dd>
|
||||
<% } %>
|
||||
<dt class="cart-summary__total-label">К оплате</dt>
|
||||
<dd class="cart-summary__total"><%= formatPrice(pricing.total) %></dd>
|
||||
</dl>
|
||||
<p class="muted"><a href="/cart">Изменить корзину или промокод</a></p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script src="/js/promo-countdown.js"></script>
|
||||
|
||||
<%- include('partials/layout-end') %>
|
||||
|
||||
+13
-1
@@ -22,7 +22,19 @@
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<p class="checkout-total">Итого: <strong><%= formatPrice(order.total_cents) %></strong></p>
|
||||
<% const subtotal = order.subtotal_cents != null ? order.subtotal_cents : order.total_cents; %>
|
||||
<% if (order.discount_cents > 0) { %>
|
||||
<dl class="cart-summary__dl">
|
||||
<dt>Товары</dt>
|
||||
<dd><%= formatPrice(subtotal) %></dd>
|
||||
<dt>Скидка</dt>
|
||||
<dd class="cart-summary__discount">−<%= formatPrice(order.discount_cents) %></dd>
|
||||
</dl>
|
||||
<% } %>
|
||||
<% if (order.loyalty_points_earned > 0) { %>
|
||||
<p class="muted">Начислено баллов лояльности: +<%= order.loyalty_points_earned %></p>
|
||||
<% } %>
|
||||
<p class="checkout-total">К оплате: <strong><%= formatPrice(order.total_cents) %></strong></p>
|
||||
</div>
|
||||
|
||||
<p><a href="/orders" class="link-back">← Все заказы</a></p>
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
|
||||
Админ-панель доступна только этому аккаунту.
|
||||
|
||||
## Установщик
|
||||
|
||||
Интерактивно: админ, PostgreSQL, Docker или Ubuntu:
|
||||
|
||||
```bash
|
||||
bash scripts/install.sh
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user