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: лучше запустить интерактивный установщик:
|
||||||
# Используется docker compose (переменные подставляются в compose)
|
# bash scripts/install.sh
|
||||||
|
# Или вручную: cp .env.docker.example .env
|
||||||
|
|
||||||
POSTGRES_USER=shop
|
POSTGRES_USER=shop
|
||||||
POSTGRES_PASSWORD=shop
|
POSTGRES_PASSWORD=shop
|
||||||
@@ -9,4 +10,7 @@ APP_PORT=3000
|
|||||||
SESSION_SECRET=change-me-to-a-long-random-string
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
TRUST_PROXY=0
|
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`
|
- Роли: клиент (`customer`) и **один** администратор (`admin`) — аккаунт из `ADMIN_EMAIL` в `.env`
|
||||||
- Согласие на cookies
|
- Согласие на 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
|
## Быстрый развёртывание на Ubuntu
|
||||||
|
|
||||||
Подставьте **URL своего репозитория** и каталог клона `SHOP_ROOT` (часто `/opt/shop`):
|
Подставьте **URL своего репозитория** и каталог клона `SHOP_ROOT` (часто `/opt/shop`):
|
||||||
@@ -338,6 +352,7 @@ caddy/Caddyfile.docker.example
|
|||||||
deploy/shop.service
|
deploy/shop.service
|
||||||
scripts/
|
scripts/
|
||||||
setup-postgres-ubuntu.sh
|
setup-postgres-ubuntu.sh
|
||||||
|
install.sh
|
||||||
install-postgresql-ubuntu.sh
|
install-postgresql-ubuntu.sh
|
||||||
quick-deploy-ubuntu.sh
|
quick-deploy-ubuntu.sh
|
||||||
fix-db-connection.sh
|
fix-db-connection.sh
|
||||||
|
|||||||
+2
-1
@@ -23,6 +23,8 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: shop-app
|
container_name: shop-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -31,7 +33,6 @@ services:
|
|||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
TRUST_PROXY: ${TRUST_PROXY:-0}
|
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}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-shop}:${POSTGRES_PASSWORD:-shop}@postgres:5432/${POSTGRES_DB:-shop}
|
||||||
ports:
|
ports:
|
||||||
- '${APP_PORT:-3000}:3000'
|
- '${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;
|
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 {
|
.cart-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
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) {
|
async function loadAccountUser(userId) {
|
||||||
const { rows } = await query(
|
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]
|
[userId]
|
||||||
);
|
);
|
||||||
return rows[0];
|
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;
|
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 { requireAuth } = require('../middleware/auth');
|
||||||
const { requireCookieConsent } = require('../middleware/cookieConsent');
|
const { requireCookieConsent } = require('../middleware/cookieConsent');
|
||||||
const { asyncHandler } = require('../utils/asyncHandler');
|
const { asyncHandler } = require('../utils/asyncHandler');
|
||||||
|
const { buildCartPricing } = require('../services/pricing');
|
||||||
|
const promoService = require('../services/promo');
|
||||||
|
const loyaltyService = require('../services/loyalty');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -128,14 +131,21 @@ router.get(
|
|||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const cart = getCart(req);
|
const cart = getCart(req);
|
||||||
const items = await cartItems(cart);
|
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 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', {
|
res.render('cart', {
|
||||||
title: 'Корзина',
|
title: 'Корзина',
|
||||||
items,
|
items,
|
||||||
total,
|
pricing,
|
||||||
|
total: pricing.total,
|
||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
|
promoOk,
|
||||||
|
promoErr,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -207,10 +217,13 @@ router.get(
|
|||||||
return res.redirect('/cart');
|
return res.redirect('/cart');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pricing = await buildCartPricing(items, req.session, req.session.userId);
|
||||||
|
|
||||||
res.render('checkout', {
|
res.render('checkout', {
|
||||||
title: 'Оформление заказа',
|
title: 'Оформление заказа',
|
||||||
items,
|
items,
|
||||||
total: cartTotal(items),
|
pricing,
|
||||||
|
total: pricing.total,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -227,17 +240,19 @@ router.post(
|
|||||||
return res.redirect('/cart');
|
return res.redirect('/cart');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pricing = await buildCartPricing(items, req.session, req.session.userId);
|
||||||
const { name, email, phone, address } = req.body;
|
const { name, email, phone, address } = req.body;
|
||||||
|
|
||||||
if (!name?.trim() || !email?.trim() || !address?.trim()) {
|
if (!name?.trim() || !email?.trim() || !address?.trim()) {
|
||||||
return res.status(400).render('checkout', {
|
return res.status(400).render('checkout', {
|
||||||
title: 'Оформление заказа',
|
title: 'Оформление заказа',
|
||||||
items,
|
items,
|
||||||
total: cartTotal(items),
|
pricing,
|
||||||
|
total: pricing.total,
|
||||||
error: 'Заполните имя, email и адрес доставки',
|
error: 'Заполните имя, email и адрес доставки',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = cartTotal(items);
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
try {
|
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(
|
const orderResult = await client.query(
|
||||||
`INSERT INTO orders (user_id, status, total_cents, customer_name, customer_email, customer_phone, address)
|
`INSERT INTO orders (
|
||||||
VALUES ($1, 'pending', $2, $3, $4, $5, $6)
|
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`,
|
RETURNING id`,
|
||||||
[
|
[
|
||||||
req.session.userId,
|
req.session.userId,
|
||||||
total,
|
pricing.subtotal,
|
||||||
|
pricing.promoDiscount + pricing.loyaltyDiscount,
|
||||||
|
pricing.total,
|
||||||
|
promoId,
|
||||||
|
pricing.loyaltyPointsUsed,
|
||||||
|
pricing.pointsEarned,
|
||||||
name.trim(),
|
name.trim(),
|
||||||
email.trim(),
|
email.trim(),
|
||||||
(phone || '').trim(),
|
(phone || '').trim(),
|
||||||
@@ -268,6 +310,19 @@ router.post(
|
|||||||
);
|
);
|
||||||
const orderId = orderResult.rows[0].id;
|
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) {
|
for (const item of items) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO order_items (order_id, product_id, quantity, price_cents)
|
`INSERT INTO order_items (order_id, product_id, quantity, price_cents)
|
||||||
@@ -282,6 +337,8 @@ router.post(
|
|||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
req.session.cart = {};
|
req.session.cart = {};
|
||||||
|
delete req.session.appliedPromoCode;
|
||||||
|
delete req.session.loyaltyPointsToUse;
|
||||||
res.redirect(`/orders/${orderId}?success=1`);
|
res.redirect(`/orders/${orderId}?success=1`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
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 { pool, initSchema, checkConnection } = require('./db');
|
||||||
const { runSeed } = require('./seed');
|
const { runSeed } = require('./seed');
|
||||||
const { seedAdmin } = require('./seed-admin');
|
const { seedAdmin } = require('./seed-admin');
|
||||||
|
const { seedPromoCodes } = require('./seed-promo');
|
||||||
const { loadUser } = require('./middleware/auth');
|
const { loadUser } = require('./middleware/auth');
|
||||||
const { loadCookieConsent } = require('./middleware/cookieConsent');
|
const { loadCookieConsent } = require('./middleware/cookieConsent');
|
||||||
const healthRoutes = require('./routes/health');
|
const healthRoutes = require('./routes/health');
|
||||||
@@ -19,6 +20,7 @@ const passwordResetRoutes = require('./routes/password-reset');
|
|||||||
const reservationsRoutes = require('./routes/reservations');
|
const reservationsRoutes = require('./routes/reservations');
|
||||||
const passkeyRoutes = require('./routes/passkey');
|
const passkeyRoutes = require('./routes/passkey');
|
||||||
const stockAlertsRoutes = require('./routes/stock-alerts');
|
const stockAlertsRoutes = require('./routes/stock-alerts');
|
||||||
|
const promoRoutes = require('./routes/promo');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
@@ -29,6 +31,7 @@ async function start() {
|
|||||||
await initSchema();
|
await initSchema();
|
||||||
await runSeed();
|
await runSeed();
|
||||||
await seedAdmin();
|
await seedAdmin();
|
||||||
|
await seedPromoCodes();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -70,6 +73,7 @@ async function start() {
|
|||||||
app.use('/', passwordResetRoutes);
|
app.use('/', passwordResetRoutes);
|
||||||
app.use('/reservations', reservationsRoutes);
|
app.use('/reservations', reservationsRoutes);
|
||||||
app.use('/', stockAlertsRoutes);
|
app.use('/', stockAlertsRoutes);
|
||||||
|
app.use('/', promoRoutes);
|
||||||
app.use('/', shopRoutes);
|
app.use('/', shopRoutes);
|
||||||
app.use('/', authRoutes);
|
app.use('/', authRoutes);
|
||||||
app.use('/webauthn', passkeyRoutes);
|
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>
|
<dd><%= new Date(user.created_at).toLocaleString('ru-RU') %></dd>
|
||||||
<dt>Заказов</dt>
|
<dt>Заказов</dt>
|
||||||
<dd><%= orderCount %></dd>
|
<dd><%= orderCount %></dd>
|
||||||
|
<dt>Баллы лояльности</dt>
|
||||||
|
<dd><strong><%= user.loyalty_points || 0 %></strong> <span class="muted">(1 балл = 1 коп. скидки)</span></dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="account-actions">
|
<div class="account-actions">
|
||||||
<a href="/orders" class="btn btn--primary">Мои заказы</a>
|
<a href="/orders" class="btn btn--primary">Мои заказы</a>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<a href="/admin/orders" 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/users" class="admin-nav__link">Пользователи</a>
|
||||||
<a href="/admin/products" 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="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||||
<a href="/" class="admin-nav__link">В магазин</a>
|
<a href="/" class="admin-nav__link">В магазин</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<a href="/admin/orders" class="admin-nav__link admin-nav__link--active">Заказы</a>
|
<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/users" class="admin-nav__link">Пользователи</a>
|
||||||
<a href="/admin/products" 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="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||||
<a href="/" class="admin-nav__link">В магазин</a>
|
<a href="/" class="admin-nav__link">В магазин</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<a href="/admin/orders" 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/users" class="admin-nav__link">Пользователи</a>
|
||||||
<a href="/admin/products" class="admin-nav__link admin-nav__link--active">Товары</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="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||||
<a href="/" class="admin-nav__link">В магазин</a>
|
<a href="/" class="admin-nav__link">В магазин</a>
|
||||||
</nav>
|
</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/orders" class="admin-nav__link">Заказы</a>
|
||||||
<a href="/admin/users" 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/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="/admin/reservations" class="admin-nav__link admin-nav__link--active">Бронирования</a>
|
||||||
<a href="/" class="admin-nav__link">В магазин</a>
|
<a href="/" class="admin-nav__link">В магазин</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
<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/users" class="admin-nav__link admin-nav__link--active">Пользователи</a>
|
||||||
<a href="/admin/products" 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="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||||
<a href="/" class="admin-nav__link">В магазин</a>
|
<a href="/" class="admin-nav__link">В магазин</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
+66
-4
@@ -3,6 +3,8 @@
|
|||||||
<h1>Корзина</h1>
|
<h1>Корзина</h1>
|
||||||
|
|
||||||
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
<% 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) { %>
|
<% if (!items.length) { %>
|
||||||
<p class="empty">Корзина пуста. <a href="/">Перейти в каталог</a></p>
|
<p class="empty">Корзина пуста. <a href="/">Перейти в каталог</a></p>
|
||||||
@@ -39,16 +41,76 @@
|
|||||||
<% }) %>
|
<% }) %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="cart-actions">
|
|
||||||
<button type="submit" class="btn btn--ghost">Обновить</button>
|
<div class="cart-sidebar">
|
||||||
<p class="cart-total">Итого: <strong><%= formatPrice(total) %></strong></p>
|
<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) { %>
|
<% if (user) { %>
|
||||||
<a href="/checkout" class="btn btn--primary btn--lg">Оформить заказ</a>
|
<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 { %>
|
<% } else { %>
|
||||||
<p class="hint"><a href="/login?next=/checkout">Войдите</a>, чтобы оформить заказ.</p>
|
<p class="hint"><a href="/login?next=/checkout">Войдите</a>, чтобы оформить заказ.</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
<script src="/js/promo-countdown.js"></script>
|
||||||
|
|
||||||
<%- include('partials/layout-end') %>
|
<%- include('partials/layout-end') %>
|
||||||
|
|||||||
+29
-1
@@ -36,8 +36,36 @@
|
|||||||
</li>
|
</li>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</ul>
|
</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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/promo-countdown.js"></script>
|
||||||
|
|
||||||
<%- include('partials/layout-end') %>
|
<%- include('partials/layout-end') %>
|
||||||
|
|||||||
+13
-1
@@ -22,7 +22,19 @@
|
|||||||
</li>
|
</li>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p><a href="/orders" class="link-back">← Все заказы</a></p>
|
<p><a href="/orders" class="link-back">← Все заказы</a></p>
|
||||||
|
|||||||
@@ -25,6 +25,14 @@
|
|||||||
|
|
||||||
Админ-панель доступна только этому аккаунту.
|
Админ-панель доступна только этому аккаунту.
|
||||||
|
|
||||||
|
## Установщик
|
||||||
|
|
||||||
|
Интерактивно: админ, PostgreSQL, Docker или Ubuntu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
### Docker
|
### 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)
|
## Первая установка (Ubuntu, без Docker)
|
||||||
|
|
||||||
```bash
|
```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 |
|
| `server-update.sh` | `git pull`, `npm install`, перезапуск shop |
|
||||||
| `git-sync.sh` | Исправить detached HEAD, синхронизация с `main` |
|
| `git-sync.sh` | Исправить detached HEAD, синхронизация с `main` |
|
||||||
| `install-postgresql-ubuntu.sh` | PostgreSQL 17 через PGDG |
|
| `install-postgresql-ubuntu.sh` | PostgreSQL 17 через PGDG |
|
||||||
|
|||||||
Reference in New Issue
Block a user