{{.Name}}
-{{.Description}}
- -diff --git a/.env.example b/.env.example
index c26a206..e3ea6c4 100644
--- a/.env.example
+++ b/.env.example
@@ -1,19 +1,2 @@
-# Скопируйте в .env или запустите: ./install.sh
-
-SITE_DOMAIN=localhost
-ACME_EMAIL=admin@localhost
-HTTP_PORT=80
-HTTPS_PORT=443
-
-POSTGRES_USER=shop
-POSTGRES_PASSWORD=shop_secret_change_me
-POSTGRES_DB=shopdb
-
-DATABASE_URL=postgres://shop:shop_secret_change_me@postgres:5432/shopdb?sslmode=require
-APP_PORT=8080
-SESSION_TTL_HOURS=168
-COOKIE_SECURE=false
-
-DB_HOST=postgres
-DB_PORT=5432
-DB_SSLMODE=require
+PORT=3000
+SESSION_SECRET=change-me-to-a-long-random-string
diff --git a/.gitignore b/.gitignore
index 98b209c..dde968d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,6 @@
+node_modules/
+data/
.env
-*.exe
-bin/
-vendor/
-.idea/
-.vscode/
+*.db
+*.db-journal
+sessions.db
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index e6a70f8..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Changelog
-
-## [Unreleased]
-
-### Изменено
-
-- Caddy заменён на **Traefik v3** (маршруты в `traefik/dynamic/shop.yml`)
-- `CADDY_EMAIL` → `ACME_EMAIL` в `.env`
-
-## [0.20] — 2026-05-16
-
-### Добавлено
-
-- Регистрация (`/register`), вход (`/login`), выход
-- Личный кабинет (`/account`) с редактированием профиля
-- Сессии в cookie, хеширование паролей bcrypt
-- Таблицы `users` и `sessions` в PostgreSQL
-
-## [0.10-beta] — 2026-05-16
-### Добавлено
-
-- Главная страница интернет-магазина (Go, HTML/CSS)
-- PostgreSQL 17 с SSL, Docker Compose, Caddy
-- Интерактивный установщик (`install.sh` / `install.ps1`) — домен и база данных
-- Проверка версий (`check.sh` / `check.ps1`, `/health`, `/version`)
-- Инструкция быстрого деплоя на сервер в README
-
-### Стек
-
-- Go 1.22, pgx/v5
-- PostgreSQL 17, Caddy 2
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 7c730fb..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,27 +0,0 @@
-FROM golang:1.22-alpine AS builder
-
-WORKDIR /src
-RUN apk add --no-cache git ca-certificates
-
-COPY go.mod go.sum ./
-RUN go mod download
-
-COPY . .
-RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
- -o /app/server ./cmd/server
-
-FROM alpine:3.20
-
-RUN apk add --no-cache ca-certificates tzdata wget \
- && adduser -D -u 10001 app
-
-WORKDIR /app
-COPY --from=builder /app/server .
-
-USER app
-EXPOSE 8080
-
-HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
- CMD wget -qO- http://127.0.0.1:8080/health || exit 1
-
-CMD ["./server"]
diff --git a/README.md b/README.md
index 16adb6e..7db3b46 100644
--- a/README.md
+++ b/README.md
@@ -1,118 +1,69 @@
-# ShopNova — интернет-магазин (Go)
+# Shop
-**Версия:** `0.20` · [Релизы](https://git.evilfox.cc/test/shop3/releases)
+Интернет-магазин на **Node.js** с локальной базой **SQLite**.
-Главная страница интернет-магазина на Go с PostgreSQL 17 (SSL), reverse proxy **Traefik** и Docker Compose.
+## Возможности
-Репозиторий: https://git.evilfox.cc/test/shop3.git
+- Каталог товаров с категориями и поиском
+- Корзина и оформление заказа
+- Регистрация и вход пользователей
+- История заказов в личном кабинете
-Клонировать конкретную версию:
+## Требования
+
+- [Node.js](https://nodejs.org/) 18 или новее (вместе с npm)
+
+## Установка и запуск
```bash
-git clone --branch v0.20 https://git.evilfox.cc/test/shop3.git
+npm install
+cp .env.example .env # Windows: copy .env.example .env
+npm run dev
```
-## Быстрая установка на сервере
+Сайт: [http://localhost:3000](http://localhost:3000)
-Требования: **Git**, **Docker**, **Docker Compose** (плагин `docker compose`).
+При первом запуске создаётся база `data/shop.db` и загружаются демо-товары.
+
+## Переменные окружения
+
+| Переменная | Описание | По умолчанию |
+|-------------------|-----------------------------------|--------------|
+| `PORT` | Порт HTTP-сервера | `3000` |
+| `SESSION_SECRET` | Секрет для подписи сессий | dev-значение |
+
+## Скрипты
+
+| Команда | Описание |
+|----------------|-----------------------------|
+| `npm start` | Запуск сервера |
+| `npm run dev` | Запуск с автоперезагрузкой |
+| `npm run seed` | Заполнение каталога (если пуст) |
+
+## База данных
+
+Данные хранятся локально в каталоге `data/`:
+
+- `shop.db` — товары, пользователи, заказы
+- `sessions.db` — сессии
+
+Каталог `data/` не попадает в git (см. `.gitignore`).
+
+## Структура проекта
+
+```
+src/
+ server.js — точка входа
+ db.js — схема SQLite
+ seed.js — демо-данные
+ routes/ — маршруты
+ views/ — шаблоны EJS
+ public/css/ — стили
+```
+
+## Git remote
```bash
-# 1. Клонировать
-git clone https://git.evilfox.cc/test/shop3.git
-cd shop3
-
-# 2. Установщик (домен + база данных → .env и traefik/dynamic/shop.yml)
-chmod +x install.sh check.sh
-./install.sh
-
-# 3. Запуск
-docker compose up --build -d
-
-# 4. Проверка (после старта контейнеров)
-./check.sh --after-start
+git remote add origin https://git.evilfox.cc/admin/shop.git
+git push -u origin main
```
-
-Одной цепочкой (после клона введите ответы установщика):
-
-```bash
-git clone https://git.evilfox.cc/test/shop3.git && cd shop3 && chmod +x install.sh check.sh && ./install.sh && docker compose up --build -d && ./check.sh --after-start
-```
-
-С Go на сервере вместо `install.sh`:
-
-```bash
-go run ./cmd/install
-go run ./cmd/check
-```
-
-Без Go — установщик сам запустится в контейнере `golang:1.22-alpine`.
-
-### Обновление на сервере
-
-```bash
-cd shop3
-git pull
-docker compose up --build -d
-```
-
-### Полезные команды
-
-```bash
-docker compose ps # статус контейнеров
-docker compose logs -f traefik app # логи прокси и приложения
-curl -s http://localhost/health | jq
-curl -s http://localhost/version | jq
-```
-
-Сайт: `http://localhost` или `https://ваш-домен` (если указали в установщике).
-
----
-
-## Установка на Windows (локально)
-
-```powershell
-git clone https://git.evilfox.cc/test/shop3.git
-cd shop3
-.\install.ps1
-.\check.ps1
-docker compose up --build -d
-```
-
-## Проверка версий
-
-Проверяет Go, Docker, Docker Compose и PostgreSQL (**ожидается 17.x**):
-
-```bash
-./check.sh
-# или: go run ./cmd/check
-```
-
-После запуска сервера:
-
-- `GET /health` — статус и проверки
-- `GET /version` — версии приложения, Go и PostgreSQL
-
-## Регистрация и личный кабинет
-
-| URL | Описание |
-|-----|----------|
-| `/register` | Регистрация |
-| `/login` | Вход |
-| `/account` | Личный кабинет (только для авторизованных) |
-| `POST /logout` | Выход |
-
-Сессии в cookie `shop_session`, пароли — bcrypt.
-
-Если БД уже была создана до обновления, примените миграцию:
-
-```bash
-docker compose exec -T postgres psql -U shop -d shopdb < postgres/init/02_users.sql
-```
-
-## Локальная разработка
-
-```bash
-go run ./cmd/server
-```
-
-`DATABASE_URL` задаётся в `.env` (см. `.env.example` или установщик).
diff --git a/check.ps1 b/check.ps1
deleted file mode 100644
index d384887..0000000
--- a/check.ps1
+++ /dev/null
@@ -1,15 +0,0 @@
-# Проверка версий (до или после docker compose)
-param([switch]$AfterStart)
-
-$ErrorActionPreference = "Stop"
-Set-Location $PSScriptRoot
-
-if ($AfterStart) {
- & "$PSScriptRoot\check.sh" --after-start
-} else {
- & bash "$PSScriptRoot\check.sh" 2>$null
- if ($LASTEXITCODE -ne 0) {
- $env:CHECK_SKIP_DB = "1"
- go run ./cmd/check
- }
-}
diff --git a/check.sh b/check.sh
deleted file mode 100755
index 4bbc5a4..0000000
--- a/check.sh
+++ /dev/null
@@ -1,88 +0,0 @@
-#!/bin/sh
-set -e
-cd "$(dirname "$0")"
-
-MODE="${1:-pre}"
-
-host_docker_check() {
- if command -v docker >/dev/null 2>&1; then
- if docker version >/dev/null 2>&1; then
- echo " ✓ docker: $(docker version --format '{{.Server.Version}}' 2>/dev/null || docker --version)"
- else
- echo " ! docker: установлен, но демон недоступен (запустите docker)"
- fi
- if docker compose version >/dev/null 2>&1; then
- echo " ✓ docker_compose: $(docker compose version --short 2>/dev/null || docker compose version)"
- else
- echo " ! docker_compose: не найден"
- fi
- else
- echo " ! docker: не найден на хосте"
- echo " ! docker_compose: не найден на хосте"
- fi
-}
-
-run_go_check() {
- if command -v go >/dev/null 2>&1; then
- CHECK_HOST_TOOLS=1 "$@" go run ./cmd/check
- else
- CHECK_HOST_TOOLS=0 "$@" docker run --rm -v "$(pwd):/app" -w /app golang:1.22-alpine go run ./cmd/check
- fi
-}
-
-load_env() {
- if [ -f .env ]; then
- set -a
- # shellcheck disable=SC1091
- . ./.env
- set +a
- fi
-}
-
-post_start_check() {
- echo "=== Проверка после запуска ==="
- load_env
- host_docker_check
- echo ""
-
- if docker compose ps postgres 2>/dev/null | grep -qi running; then
- if docker compose exec -T postgres pg_isready -U "${POSTGRES_USER:-shop}" -d "${POSTGRES_DB:-shopdb}" >/dev/null 2>&1; then
- echo " ✓ postgresql: готова"
- else
- echo " ✗ postgresql: не отвечает"
- exit 1
- fi
- else
- echo " ✗ postgres: контейнер не запущен — docker compose up -d"
- exit 1
- fi
-
- if command -v curl >/dev/null 2>&1; then
- if curl -sf "http://127.0.0.1:${HTTP_PORT:-80}/health" >/dev/null 2>&1; then
- echo " ✓ /health: OK"
- else
- echo " ✗ /health: не отвечает — docker compose logs app traefik"
- exit 1
- fi
- elif docker compose exec -T app wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1; then
- echo " ✓ app /health: OK"
- fi
-
- echo ""
- CHECK_SKIP_DB=1 CHECK_HOST_TOOLS=1 run_go_check
-}
-
-case "$MODE" in
- --after-start|post)
- post_start_check
- ;;
- *)
- echo "=== Проверка перед запуском (БД будет проверена после docker compose) ==="
- host_docker_check
- echo ""
- CHECK_SKIP_DB=1 run_go_check
- echo ""
- echo "Далее: docker compose up --build -d"
- echo "Затем: ./check.sh --after-start"
- ;;
-esac
diff --git a/cmd/check/main.go b/cmd/check/main.go
deleted file mode 100644
index 7a4e4b2..0000000
--- a/cmd/check/main.go
+++ /dev/null
@@ -1,192 +0,0 @@
-package main
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/url"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/jackc/pgx/v5/pgxpool"
-
- "shop/internal/check"
-)
-
-func main() {
- loadDotEnv()
-
- skipDB := os.Getenv("CHECK_SKIP_DB") == "1"
- hostTools := os.Getenv("CHECK_HOST_TOOLS") != "0"
-
- ctx := context.Background()
- report := check.AppInfo()
- if hostTools {
- report.Items = append(report.Items, check.ToolVersions(ctx)...)
- }
-
- dbURL := os.Getenv("DATABASE_URL")
- if skipDB {
- report.Items = append(report.Items, check.Item{
- Name: "database",
- Status: check.StatusWarn,
- Detail: "проверка отложена — сначала выполните: docker compose up -d, затем ./check.sh --after-start",
- })
- } else if dbURL == "" {
- report.Items = append(report.Items, check.Item{
- Name: "database",
- Status: check.StatusWarn,
- Detail: "DATABASE_URL не задан — запустите: ./install.sh",
- })
- } else {
- appendDBChecks(ctx, &report, dbURL)
- }
-
- enc := json.NewEncoder(os.Stdout)
- enc.SetIndent("", " ")
- _ = enc.Encode(report)
-
- fmt.Println()
- printSummary(report)
-
- if !report.Healthy() {
- os.Exit(1)
- }
-}
-
-func appendDBChecks(ctx context.Context, report *check.Report, dbURL string) {
- pool, err := pgxpool.New(ctx, dbURL)
- if err != nil {
- report.Items = append(report.Items, dbCheckItem(err, dbURL))
- return
- }
- defer pool.Close()
-
- dbItems, err := check.Database(ctx, pool)
- if err != nil {
- report.Items = append(report.Items, check.Item{
- Name: "database",
- Status: dbCheckStatus(err, dbURL),
- Detail: err.Error(),
- })
- return
- }
- report.Items = append(report.Items, dbItems...)
-}
-
-func dbCheckItem(err error, dbURL string) check.Item {
- return check.Item{
- Name: "database",
- Status: dbCheckStatus(err, dbURL),
- Detail: err.Error(),
- }
-}
-
-func dbCheckStatus(err error, dbURL string) check.Status {
- if err == nil {
- return check.StatusOK
- }
- msg := strings.ToLower(err.Error())
- if isDockerInternalHost(dbURL) && (strings.Contains(msg, "no such host") ||
- strings.Contains(msg, "name or service not known") ||
- strings.Contains(msg, "hostname resolving")) {
- return check.StatusWarn
- }
- return check.StatusError
-}
-
-func isDockerInternalHost(dbURL string) bool {
- u, err := url.Parse(dbURL)
- if err != nil {
- return false
- }
- host := u.Hostname()
- return host == "postgres" || host == "db" || host == "shop-postgres"
-}
-
-func printSummary(r check.Report) {
- fmt.Printf("ShopNova %s | %s\n\n", r.AppVersion, r.GoVersion)
- for _, it := range r.Items {
- mark := "✓"
- switch it.Status {
- case check.StatusWarn:
- mark = "!"
- case check.StatusError:
- mark = "✗"
- }
- line := fmt.Sprintf(" %s %-18s %s", mark, it.Name+":", it.Detail)
- if it.Expected != "" {
- line += " (ожидается " + it.Expected + ")"
- }
- fmt.Println(line)
- }
-}
-
-func loadDotEnv() {
- root, _ := os.Getwd()
- path := filepath.Join(root, ".env")
- data, err := os.ReadFile(path)
- if err != nil {
- return
- }
- for _, line := range splitLines(string(data)) {
- line = trimComment(line)
- if line == "" {
- continue
- }
- k, v, ok := splitKV(line)
- if !ok {
- continue
- }
- if os.Getenv(k) == "" {
- _ = os.Setenv(k, v)
- }
- }
-}
-
-func splitLines(s string) []string {
- var lines []string
- start := 0
- for i := 0; i < len(s); i++ {
- if s[i] == '\n' {
- lines = append(lines, s[start:i])
- start = i + 1
- }
- }
- if start < len(s) {
- lines = append(lines, s[start:])
- }
- return lines
-}
-
-func trimComment(s string) string {
- if i := indexByte(s, '#'); i >= 0 {
- s = s[:i]
- }
- for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\r') {
- s = s[:len(s)-1]
- }
- for len(s) > 0 && s[0] == ' ' {
- s = s[1:]
- }
- return s
-}
-
-func splitKV(s string) (string, string, bool) {
- for i := 0; i < len(s); i++ {
- if s[i] == '=' {
- return s[:i], s[i+1:], true
- }
- }
- return "", "", false
-}
-
-func indexByte(s string, c byte) int {
- for i := 0; i < len(s); i++ {
- if s[i] == c {
- return i
- }
- }
- return -1
-}
diff --git a/cmd/install/main.go b/cmd/install/main.go
deleted file mode 100644
index 2716522..0000000
--- a/cmd/install/main.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
- "path/filepath"
-
- "shop/internal/setup"
-)
-
-func main() {
- root, err := os.Getwd()
- if err != nil {
- fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
- os.Exit(1)
- }
-
- if _, err := os.Stat(filepath.Join(root, "docker-compose.yml")); os.IsNotExist(err) {
- fmt.Fprintln(os.Stderr, "запустите установщик из корня проекта (где docker-compose.yml)")
- os.Exit(1)
- }
-
- if _, err := setup.RunInteractive(root); err != nil {
- fmt.Fprintf(os.Stderr, "установка не завершена: %v\n", err)
- os.Exit(1)
- }
-}
diff --git a/cmd/server/main.go b/cmd/server/main.go
deleted file mode 100644
index 06c00f6..0000000
--- a/cmd/server/main.go
+++ /dev/null
@@ -1,163 +0,0 @@
-package main
-
-import (
- "context"
- "html/template"
- "io/fs"
- "log"
- "net/http"
- "os"
- "os/signal"
- "syscall"
- "time"
-
- "shop/internal/auth"
- "shop/internal/check"
- "shop/internal/config"
- "shop/internal/database"
- "shop/internal/handlers"
- "shop/internal/repository"
- "shop/internal/version"
- "shop/internal/web"
-)
-
-func main() {
- cfg, err := config.Load()
- if err != nil {
- log.Fatalf("config: %v", err)
- }
-
- ctx := context.Background()
- pool, err := database.Connect(ctx, cfg.DatabaseURL)
- if err != nil {
- log.Fatalf("database: %v", err)
- }
- defer pool.Close()
-
- startupReport, err := check.WithDatabase(ctx, pool)
- if err != nil {
- log.Fatalf("version check: %v", err)
- }
- for _, it := range startupReport.Items {
- if it.Name == "postgresql" && it.Status == check.StatusWarn {
- log.Printf("warning: %s — %s", it.Name, it.Detail)
- }
- }
- log.Printf("ShopNova %s | Go %s | PostgreSQL check OK", version.AppVersion, version.GoRuntime())
-
- tmpl, err := loadTemplates()
- if err != nil {
- log.Fatalf("templates: %v", err)
- }
-
- users := repository.NewUserRepository(pool)
- sessions := repository.NewSessionRepository(pool)
- authSvc := auth.NewService(users, sessions, cfg.SessionTTL, cfg.CookieSecure)
-
- pages := handlers.NewPages(tmpl, authSvc)
- products := repository.NewProductRepository(pool)
- home := handlers.NewHomeHandler(products, pages)
- health := handlers.NewHealthHandler(pool)
- authH := handlers.NewAuthHandler(pages, authSvc)
- account := handlers.NewAccountHandler(pages, authSvc)
-
- staticSub, err := fs.Sub(web.StaticFS, "static")
- if err != nil {
- log.Fatalf("static fs: %v", err)
- }
-
- mux := http.NewServeMux()
- mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
- mux.HandleFunc("GET /health", health.Health)
- mux.HandleFunc("GET /version", health.Version)
- mux.Handle("GET /", home)
- mux.HandleFunc("/register", authH.Register)
- mux.HandleFunc("/login", authH.Login)
- mux.HandleFunc("POST /logout", authH.Logout)
- mux.HandleFunc("/account", account.Account)
-
- srv := &http.Server{
- Addr: cfg.HTTPAddr,
- Handler: mux,
- ReadTimeout: cfg.ReadTimeout,
- WriteTimeout: cfg.WriteTimeout,
- }
-
- go func() {
- log.Printf("server listening on %s", cfg.HTTPAddr)
- if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- log.Fatalf("listen: %v", err)
- }
- }()
-
- quit := make(chan os.Signal, 1)
- signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
- <-quit
-
- shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- if err := srv.Shutdown(shutdownCtx); err != nil {
- log.Printf("shutdown: %v", err)
- }
-}
-
-func loadTemplates() (*template.Template, error) {
- funcMap := template.FuncMap{
- "formatPrice": func(v float64) string {
- return formatRub(v)
- },
- }
- return template.New("").Funcs(funcMap).ParseFS(web.TemplatesFS, "templates/*.html")
-}
-
-func formatRub(v float64) string {
- intPart := int64(v)
- frac := int64((v - float64(intPart)) * 100)
- if frac < 0 {
- frac = -frac
- }
- return formatThousands(intPart) + "," + pad2(frac) + " ₽"
-}
-
-func formatThousands(n int64) string {
- if n < 0 {
- n = -n
- }
- s := ""
- for n >= 1000 {
- s = "," + pad3(n%1000) + s
- n /= 1000
- }
- return itoa(n) + s
-}
-
-func pad3(n int64) string {
- if n < 10 {
- return "00" + itoa(n)
- }
- if n < 100 {
- return "0" + itoa(n)
- }
- return itoa(n)
-}
-
-func pad2(n int64) string {
- if n < 10 {
- return "0" + itoa(n)
- }
- return itoa(n)
-}
-
-func itoa(n int64) string {
- if n == 0 {
- return "0"
- }
- var b [20]byte
- i := len(b)
- for n > 0 {
- i--
- b[i] = byte('0' + n%10)
- n /= 10
- }
- return string(b[i:])
-}
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 8161360..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,94 +0,0 @@
-services:
- ssl-init:
- image: alpine:3.20
- container_name: shop-ssl-init
- volumes:
- - postgres_ssl:/certs
- - ./postgres/ssl/generate-certs.sh:/generate-certs.sh:ro
- entrypoint: ["/bin/sh", "-c", "apk add --no-cache openssl > /dev/null && sh /generate-certs.sh /certs"]
- restart: "no"
-
- postgres:
- image: postgres:17-alpine
- container_name: shop-postgres
- depends_on:
- ssl-init:
- condition: service_completed_successfully
- environment:
- POSTGRES_USER: ${POSTGRES_USER:-shop}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shop_secret}
- POSTGRES_DB: ${POSTGRES_DB:-shopdb}
- volumes:
- - postgres_data:/var/lib/postgresql/data
- - postgres_ssl:/var/lib/postgresql/ssl:ro
- - ./postgres/init:/docker-entrypoint-initdb.d:ro
- command:
- - postgres
- - -c
- - ssl=on
- - -c
- - ssl_cert_file=/var/lib/postgresql/ssl/server.crt
- - -c
- - ssl_key_file=/var/lib/postgresql/ssl/server.key
- - -c
- - ssl_min_protocol_version=TLSv1.2
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
- interval: 5s
- timeout: 5s
- retries: 10
- start_period: 15s
- networks:
- - backend
- restart: unless-stopped
-
- app:
- build:
- context: .
- dockerfile: Dockerfile
- container_name: shop-app
- depends_on:
- postgres:
- condition: service_healthy
- environment:
- APP_PORT: "8080"
- DATABASE_URL: postgres://${POSTGRES_USER:-shop}:${POSTGRES_PASSWORD:-shop_secret}@postgres:5432/${POSTGRES_DB:-shopdb}?sslmode=require
- COOKIE_SECURE: ${COOKIE_SECURE:-false}
- networks:
- - backend
- - frontend
- restart: unless-stopped
-
- traefik:
- image: traefik:v3.2
- container_name: shop-traefik
- depends_on:
- - app
- command:
- - --log.level=INFO
- - --accesslog=true
- - --providers.file.directory=/etc/traefik/dynamic
- - --providers.file.watch=true
- - --entrypoints.web.address=:80
- - --entrypoints.websecure.address=:443
- - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:-admin@localhost}
- - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
- ports:
- - "${HTTP_PORT:-80}:80"
- - "${HTTPS_PORT:-443}:443"
- volumes:
- - ./traefik/dynamic:/etc/traefik/dynamic:ro
- - traefik_letsencrypt:/letsencrypt
- networks:
- - frontend
- restart: unless-stopped
-
-volumes:
- postgres_data:
- postgres_ssl:
- traefik_letsencrypt:
-
-networks:
- backend:
- frontend:
diff --git a/go.mod b/go.mod
deleted file mode 100644
index a613f61..0000000
--- a/go.mod
+++ /dev/null
@@ -1,16 +0,0 @@
-module shop
-
-go 1.22
-
-require (
- github.com/jackc/pgx/v5 v5.7.2
- golang.org/x/crypto v0.31.0
-)
-
-require (
- github.com/jackc/pgpassfile v1.0.0 // indirect
- github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
- github.com/jackc/puddle/v2 v2.2.2 // indirect
- golang.org/x/sync v0.10.0 // indirect
- golang.org/x/text v0.21.0 // indirect
-)
diff --git a/go.sum b/go.sum
deleted file mode 100644
index 731b5df..0000000
--- a/go.sum
+++ /dev/null
@@ -1,28 +0,0 @@
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
-github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
-github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
-github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
-github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
-github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
-github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
-golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/install.ps1 b/install.ps1
deleted file mode 100644
index 58e7764..0000000
--- a/install.ps1
+++ /dev/null
@@ -1,4 +0,0 @@
-# Интерактивная установка: домен, база данных, .env, Traefik
-$ErrorActionPreference = "Stop"
-Set-Location $PSScriptRoot
-go run ./cmd/install
diff --git a/install.sh b/install.sh
deleted file mode 100755
index 84e1f1d..0000000
--- a/install.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/bin/sh
-set -e
-cd "$(dirname "$0")"
-if command -v go >/dev/null 2>&1; then
- go run ./cmd/install
-else
- echo "Go не найден — запуск через Docker..."
- docker run --rm -it -v "$(pwd):/app" -w /app golang:1.22-alpine go run ./cmd/install
-fi
diff --git a/internal/auth/cookies.go b/internal/auth/cookies.go
deleted file mode 100644
index f51cdf2..0000000
--- a/internal/auth/cookies.go
+++ /dev/null
@@ -1,3 +0,0 @@
-package auth
-
-const SessionCookieName = "shop_session"
diff --git a/internal/auth/password.go b/internal/auth/password.go
deleted file mode 100644
index 680639b..0000000
--- a/internal/auth/password.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package auth
-
-import (
- "errors"
- "unicode/utf8"
-
- "golang.org/x/crypto/bcrypt"
-)
-
-const (
- minPasswordLen = 8
- bcryptCost = 12
-)
-
-var ErrInvalidCredentials = errors.New("неверный email или пароль")
-
-func HashPassword(password string) (string, error) {
- if utf8.RuneCountInString(password) < minPasswordLen {
- return "", errors.New("пароль должен быть не короче 8 символов")
- }
- b, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
- return string(b), err
-}
-
-func CheckPassword(hash, password string) bool {
- return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
-}
diff --git a/internal/auth/service.go b/internal/auth/service.go
deleted file mode 100644
index ef4adbd..0000000
--- a/internal/auth/service.go
+++ /dev/null
@@ -1,137 +0,0 @@
-package auth
-
-import (
- "context"
- "crypto/rand"
- "encoding/hex"
- "errors"
- "fmt"
- "net/http"
- "regexp"
- "strings"
- "time"
-
- "shop/internal/models"
- "shop/internal/repository"
-)
-
-var (
- ErrEmailTaken = errors.New("этот email уже зарегистрирован")
- ErrInvalidEmail = errors.New("некорректный email")
- ErrInvalidName = errors.New("имя должно быть не короче 2 символов")
- ErrNotAuthenticated = errors.New("требуется авторизация")
-)
-
-var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
-
-type Service struct {
- users *repository.UserRepository
- sessions *repository.SessionRepository
- ttl time.Duration
- secure bool
-}
-
-func NewService(users *repository.UserRepository, sessions *repository.SessionRepository, ttl time.Duration, cookieSecure bool) *Service {
- return &Service{users: users, sessions: sessions, ttl: ttl, secure: cookieSecure}
-}
-
-func (s *Service) Register(ctx context.Context, email, password, name string) (*models.User, error) {
- email = strings.TrimSpace(strings.ToLower(email))
- name = strings.TrimSpace(name)
- if !emailRe.MatchString(email) {
- return nil, ErrInvalidEmail
- }
- if len([]rune(name)) < 2 {
- return nil, ErrInvalidName
- }
- hash, err := HashPassword(password)
- if err != nil {
- return nil, err
- }
- user, err := s.users.Create(ctx, email, hash, name)
- if err != nil {
- if repository.IsUniqueViolation(err) {
- return nil, ErrEmailTaken
- }
- return nil, err
- }
- return user, nil
-}
-
-func (s *Service) Login(ctx context.Context, w http.ResponseWriter, email, password string) error {
- email = strings.TrimSpace(strings.ToLower(email))
- user, hash, err := s.users.ByEmailWithHash(ctx, email)
- if err != nil || !CheckPassword(hash, password) {
- return ErrInvalidCredentials
- }
- return s.setSession(ctx, w, user.ID)
-}
-
-func (s *Service) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request) {
- c, err := r.Cookie(SessionCookieName)
- if err == nil && c.Value != "" {
- _ = s.sessions.Delete(ctx, c.Value)
- }
- s.clearCookie(w)
-}
-
-func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (*models.User, error) {
- c, err := r.Cookie(SessionCookieName)
- if err != nil || c.Value == "" {
- return nil, nil
- }
- userID, err := s.sessions.UserID(ctx, c.Value)
- if err != nil || userID == 0 {
- return nil, nil
- }
- return s.users.ByID(ctx, userID)
-}
-
-func (s *Service) UpdateName(ctx context.Context, userID int, name string) error {
- name = strings.TrimSpace(name)
- if len([]rune(name)) < 2 {
- return ErrInvalidName
- }
- return s.users.UpdateName(ctx, userID, name)
-}
-
-func (s *Service) setSession(ctx context.Context, w http.ResponseWriter, userID int) error {
- token, err := newToken()
- if err != nil {
- return err
- }
- expires := time.Now().Add(s.ttl)
- if err := s.sessions.Create(ctx, token, userID, expires); err != nil {
- return err
- }
- http.SetCookie(w, &http.Cookie{
- Name: SessionCookieName,
- Value: token,
- Path: "/",
- Expires: expires,
- HttpOnly: true,
- SameSite: http.SameSiteLaxMode,
- Secure: s.secure,
- })
- return nil
-}
-
-func (s *Service) clearCookie(w http.ResponseWriter) {
- http.SetCookie(w, &http.Cookie{
- Name: SessionCookieName,
- Value: "",
- Path: "/",
- MaxAge: -1,
- HttpOnly: true,
- SameSite: http.SameSiteLaxMode,
- Secure: s.secure,
- })
-}
-
-func newToken() (string, error) {
- b := make([]byte, 32)
- if _, err := rand.Read(b); err != nil {
- return "", fmt.Errorf("session token: %w", err)
- }
- return hex.EncodeToString(b), nil
-}
diff --git a/internal/check/check.go b/internal/check/check.go
deleted file mode 100644
index f09e833..0000000
--- a/internal/check/check.go
+++ /dev/null
@@ -1,176 +0,0 @@
-package check
-
-import (
- "context"
- "fmt"
- "os/exec"
- "regexp"
- "strconv"
- "strings"
- "time"
-
- "github.com/jackc/pgx/v5/pgxpool"
-
- "shop/internal/version"
-)
-
-type Status string
-
-const (
- StatusOK Status = "ok"
- StatusWarn Status = "warn"
- StatusError Status = "error"
-)
-
-type Item struct {
- Name string `json:"name"`
- Status Status `json:"status"`
- Detail string `json:"detail"`
- Expected string `json:"expected,omitempty"`
-}
-
-type Report struct {
- AppVersion string `json:"app_version"`
- GoVersion string `json:"go_version"`
- Items []Item `json:"checks"`
-}
-
-func (r Report) Healthy() bool {
- for _, it := range r.Items {
- if it.Status == StatusError {
- return false
- }
- }
- return true
-}
-
-func AppInfo() Report {
- return Report{
- AppVersion: version.AppVersion,
- GoVersion: version.GoRuntime(),
- Items: []Item{
- {
- Name: "go_runtime",
- Status: goRuntimeStatus(),
- Detail: version.GoRuntime(),
- Expected: ">=" + version.MinGoVersion,
- },
- },
- }
-}
-
-func goRuntimeStatus() Status {
- v := strings.TrimPrefix(version.GoRuntime(), "go")
- major, minor, ok := parseGoVersion(v)
- if !ok {
- return StatusWarn
- }
- expMajor, expMinor, _ := parseGoVersion(version.MinGoVersion)
- if major > expMajor || (major == expMajor && minor >= expMinor) {
- return StatusOK
- }
- return StatusWarn
-}
-
-func parseGoVersion(v string) (major, minor int, ok bool) {
- parts := strings.Split(v, ".")
- if len(parts) < 2 {
- return 0, 0, false
- }
- major, err1 := strconv.Atoi(parts[0])
- minor, err2 := strconv.Atoi(parts[1])
- return major, minor, err1 == nil && err2 == nil
-}
-
-func WithDatabase(ctx context.Context, pool *pgxpool.Pool) (Report, error) {
- r := AppInfo()
- dbItems, err := Database(ctx, pool)
- if err != nil {
- return r, err
- }
- r.Items = append(r.Items, dbItems...)
- return r, nil
-}
-
-func Database(ctx context.Context, pool *pgxpool.Pool) ([]Item, error) {
- ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
-
- var pgVersion string
- if err := pool.QueryRow(ctx, `SHOW server_version`).Scan(&pgVersion); err != nil {
- return []Item{{
- Name: "database",
- Status: StatusError,
- Detail: err.Error(),
- }}, nil
- }
-
- major, ok := postgresMajor(pgVersion)
- pgStatus := StatusOK
- detail := pgVersion
- expected := fmt.Sprintf("%d.x", version.ExpectedPostgresMajor)
-
- if !ok {
- pgStatus = StatusWarn
- detail = pgVersion + " (не удалось определить major)"
- } else if major != version.ExpectedPostgresMajor {
- pgStatus = StatusWarn
- detail = fmt.Sprintf("%s (ожидается PostgreSQL %d)", pgVersion, version.ExpectedPostgresMajor)
- }
-
- return []Item{
- {Name: "database", Status: StatusOK, Detail: "подключено"},
- {Name: "postgresql", Status: pgStatus, Detail: detail, Expected: expected},
- }, nil
-}
-
-var pgMajorRe = regexp.MustCompile(`^(\d+)`)
-
-func postgresMajor(v string) (int, bool) {
- m := pgMajorRe.FindStringSubmatch(strings.TrimSpace(v))
- if len(m) < 2 {
- return 0, false
- }
- n, err := strconv.Atoi(m[1])
- return n, err == nil
-}
-
-func ToolVersions(ctx context.Context) []Item {
- var items []Item
- if out, err := run(ctx, "docker", "version", "--format", "{{.Server.Version}}"); err == nil {
- items = append(items, Item{Name: "docker", Status: StatusOK, Detail: strings.TrimSpace(out)})
- } else {
- items = append(items, Item{Name: "docker", Status: StatusWarn, Detail: "не найден"})
- }
- if out, err := run(ctx, "docker", "compose", "version", "--short"); err == nil {
- items = append(items, Item{Name: "docker_compose", Status: StatusOK, Detail: strings.TrimSpace(out)})
- } else {
- items = append(items, Item{Name: "docker_compose", Status: StatusWarn, Detail: "не найден"})
- }
- return items
-}
-
-func run(ctx context.Context, name string, args ...string) (string, error) {
- ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
- defer cancel()
- cmd := exec.CommandContext(ctx, name, args...)
- out, err := cmd.Output()
- return string(out), err
-}
-
-func Merge(reports ...Report) Report {
- if len(reports) == 0 {
- return AppInfo()
- }
- out := reports[0]
- for _, r := range reports[1:] {
- if out.AppVersion == "" {
- out.AppVersion = r.AppVersion
- }
- if out.GoVersion == "" {
- out.GoVersion = r.GoVersion
- }
- out.Items = append(out.Items, r.Items...)
- }
- return out
-}
diff --git a/internal/config/config.go b/internal/config/config.go
deleted file mode 100644
index 517b7f6..0000000
--- a/internal/config/config.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package config
-
-import (
- "fmt"
- "os"
- "strconv"
- "time"
-)
-
-type Config struct {
- HTTPAddr string
- DatabaseURL string
- ReadTimeout time.Duration
- WriteTimeout time.Duration
- SessionTTL time.Duration
- CookieSecure bool
-}
-
-func Load() (Config, error) {
- port := env("APP_PORT", "8080")
- cfg := Config{
- HTTPAddr: ":" + port,
- DatabaseURL: os.Getenv("DATABASE_URL"),
- ReadTimeout: durationEnv("HTTP_READ_TIMEOUT", 10*time.Second),
- WriteTimeout: durationEnv("HTTP_WRITE_TIMEOUT", 30*time.Second),
- SessionTTL: sessionTTL(),
- CookieSecure: env("COOKIE_SECURE", "false") == "true",
- }
- if cfg.DatabaseURL == "" {
- return cfg, fmt.Errorf("DATABASE_URL is required")
- }
- return cfg, nil
-}
-
-func env(key, fallback string) string {
- if v := os.Getenv(key); v != "" {
- return v
- }
- return fallback
-}
-
-func sessionTTL() time.Duration {
- if v := os.Getenv("SESSION_TTL_HOURS"); v != "" {
- if h, err := strconv.Atoi(v); err == nil && h > 0 {
- return time.Duration(h) * time.Hour
- }
- }
- return 168 * time.Hour
-}
-
-func durationEnv(key string, fallback time.Duration) time.Duration {
- v := os.Getenv(key)
- if v == "" {
- return fallback
- }
- sec, err := strconv.Atoi(v)
- if err != nil || sec <= 0 {
- return fallback
- }
- return time.Duration(sec) * time.Second
-}
diff --git a/internal/database/database.go b/internal/database/database.go
deleted file mode 100644
index c8460f4..0000000
--- a/internal/database/database.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package database
-
-import (
- "context"
- "fmt"
- "time"
-
- "github.com/jackc/pgx/v5/pgxpool"
-)
-
-func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
- cfg, err := pgxpool.ParseConfig(databaseURL)
- if err != nil {
- return nil, fmt.Errorf("parse database url: %w", err)
- }
-
- cfg.MaxConns = 10
- cfg.MinConns = 2
- cfg.MaxConnLifetime = time.Hour
- cfg.HealthCheckPeriod = 30 * time.Second
-
- pool, err := pgxpool.NewWithConfig(ctx, cfg)
- if err != nil {
- return nil, fmt.Errorf("create pool: %w", err)
- }
-
- ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
- defer cancel()
-
- if err := pool.Ping(ctx); err != nil {
- pool.Close()
- return nil, fmt.Errorf("ping database: %w", err)
- }
-
- return pool, nil
-}
diff --git a/internal/handlers/account.go b/internal/handlers/account.go
deleted file mode 100644
index e65d272..0000000
--- a/internal/handlers/account.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package handlers
-
-import (
- "net/http"
-
- "shop/internal/auth"
-)
-
-type AccountHandler struct {
- pages *Pages
- auth *auth.Service
-}
-
-func NewAccountHandler(pages *Pages, authSvc *auth.Service) *AccountHandler {
- return &AccountHandler{pages: pages, auth: authSvc}
-}
-
-type accountPageData struct {
- Layout
- Name string
-}
-
-func (h *AccountHandler) Account(w http.ResponseWriter, r *http.Request) {
- user, err := h.auth.UserFromRequest(r.Context(), r)
- if err != nil || user == nil {
- http.Redirect(w, r, "/login?next=/account", http.StatusSeeOther)
- return
- }
-
- switch r.Method {
- case http.MethodGet:
- data := accountPageData{
- Layout: h.pages.layout(r, "Личный кабинет", "account"),
- Name: user.Name,
- }
- data.Success = flashMsg(r, "ok")
- h.pages.render(w, "account.html", data)
- case http.MethodPost:
- h.updateProfile(w, r)
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-func (h *AccountHandler) updateProfile(w http.ResponseWriter, r *http.Request) {
- user, _ := h.auth.UserFromRequest(r.Context(), r)
- if user == nil {
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- return
- }
- if err := r.ParseForm(); err != nil {
- http.Redirect(w, r, "/account", http.StatusSeeOther)
- return
- }
- name := r.FormValue("name")
- if err := h.auth.UpdateName(r.Context(), user.ID, name); err != nil {
- data := accountPageData{
- Layout: h.pages.layout(r, "Личный кабинет", "account"),
- Name: name,
- }
- data.Error = err.Error()
- h.pages.render(w, "account.html", data)
- return
- }
- http.Redirect(w, r, "/account?ok=profile", http.StatusSeeOther)
-}
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
deleted file mode 100644
index 3857057..0000000
--- a/internal/handlers/auth.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package handlers
-
-import (
- "errors"
- "net/http"
- "strings"
-
- "shop/internal/auth"
-)
-
-type AuthHandler struct {
- pages *Pages
- auth *auth.Service
-}
-
-func NewAuthHandler(pages *Pages, authSvc *auth.Service) *AuthHandler {
- return &AuthHandler{pages: pages, auth: authSvc}
-}
-
-type authPageData struct {
- Layout
- Email string
- Name string
- Next string
-}
-
-func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- h.showRegister(w, r, "", "")
- case http.MethodPost:
- h.postRegister(w, r)
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-func (h *AuthHandler) showRegister(w http.ResponseWriter, r *http.Request, errMsg string, email string) {
- data := authPageData{
- Layout: h.pages.layout(r, "Регистрация", "register"),
- Email: email,
- }
- data.Error = errMsg
- if msg := flashMsg(r, "ok"); msg != "" {
- data.Success = msg
- }
- h.pages.render(w, "register.html", data)
-}
-
-func (h *AuthHandler) postRegister(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseForm(); err != nil {
- h.showRegister(w, r, "Неверные данные формы", "")
- return
- }
- email := r.FormValue("email")
- name := r.FormValue("name")
- password := r.FormValue("password")
- password2 := r.FormValue("password_confirm")
- if password != password2 {
- h.showRegister(w, r, "Пароли не совпадают", email)
- return
- }
- _, err := h.auth.Register(r.Context(), email, password, name)
- if err != nil {
- h.showRegister(w, r, err.Error(), email)
- return
- }
- http.Redirect(w, r, "/login?ok=registered", http.StatusSeeOther)
-}
-
-func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- h.showLogin(w, r, "", "")
- case http.MethodPost:
- h.postLogin(w, r)
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-func (h *AuthHandler) showLogin(w http.ResponseWriter, r *http.Request, errMsg, email string) {
- data := authPageData{
- Layout: h.pages.layout(r, "Вход", "login"),
- Email: email,
- Next: safeNext(r.URL.Query().Get("next")),
- }
- data.Error = errMsg
- data.Success = flashMsg(r, "ok")
- if data.Layout.User != nil {
- http.Redirect(w, r, "/account", http.StatusSeeOther)
- return
- }
- h.pages.render(w, "login.html", data)
-}
-
-func (h *AuthHandler) postLogin(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseForm(); err != nil {
- h.showLogin(w, r, "Неверные данные формы", "")
- return
- }
- email := r.FormValue("email")
- password := r.FormValue("password")
- if err := h.auth.Login(r.Context(), w, email, password); err != nil {
- msg := err.Error()
- if errors.Is(err, auth.ErrInvalidCredentials) {
- msg = "Неверный email или пароль"
- }
- h.showLogin(w, r, msg, email)
- return
- }
- next := safeNext(r.FormValue("next"))
- http.Redirect(w, r, next+"?ok=login", http.StatusSeeOther)
-}
-
-func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- h.auth.Logout(r.Context(), w, r)
- http.Redirect(w, r, "/?ok=logout", http.StatusSeeOther)
-}
-
-func safeNext(next string) string {
- next = strings.TrimSpace(next)
- if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
- return "/account"
- }
- return next
-}
diff --git a/internal/handlers/health.go b/internal/handlers/health.go
deleted file mode 100644
index d0e88c3..0000000
--- a/internal/handlers/health.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package handlers
-
-import (
- "encoding/json"
- "net/http"
-
- "github.com/jackc/pgx/v5/pgxpool"
-
- "shop/internal/check"
-)
-
-type HealthHandler struct {
- pool *pgxpool.Pool
-}
-
-func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler {
- return &HealthHandler{pool: pool}
-}
-
-func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- report, err := check.WithDatabase(ctx, h.pool)
- if err != nil {
- writeJSON(w, http.StatusServiceUnavailable, map[string]any{
- "status": "error",
- "error": err.Error(),
- "version": report.AppVersion,
- })
- return
- }
-
- status := "ok"
- code := http.StatusOK
- if !report.Healthy() {
- status = "degraded"
- code = http.StatusServiceUnavailable
- }
-
- writeJSON(w, code, map[string]any{
- "status": status,
- "app_version": report.AppVersion,
- "go_version": report.GoVersion,
- "checks": report.Items,
- })
-}
-
-func (h *HealthHandler) Version(w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- report, _ := check.WithDatabase(ctx, h.pool)
- writeJSON(w, http.StatusOK, report)
-}
-
-func writeJSON(w http.ResponseWriter, code int, v any) {
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- w.WriteHeader(code)
- _ = json.NewEncoder(w).Encode(v)
-}
diff --git a/internal/handlers/home.go b/internal/handlers/home.go
deleted file mode 100644
index 73b0228..0000000
--- a/internal/handlers/home.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package handlers
-
-import (
- "log"
- "net/http"
-
- "shop/internal/models"
- "shop/internal/repository"
-)
-
-type HomeHandler struct {
- products *repository.ProductRepository
- pages *Pages
-}
-
-func NewHomeHandler(products *repository.ProductRepository, pages *Pages) *HomeHandler {
- return &HomeHandler{products: products, pages: pages}
-}
-
-type homePageData struct {
- Layout
- Products []models.Product
- Categories []string
- TotalItems int
-}
-
-func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/" {
- http.NotFound(w, r)
- return
- }
-
- ctx := r.Context()
- featured, err := h.products.Featured(ctx, 8)
- if err != nil {
- log.Printf("featured products: %v", err)
- http.Error(w, "Ошибка загрузки каталога", http.StatusInternalServerError)
- return
- }
-
- categories, err := h.products.Categories(ctx)
- if err != nil {
- log.Printf("categories: %v", err)
- categories = nil
- }
-
- total, err := h.products.Count(ctx)
- if err != nil {
- log.Printf("count: %v", err)
- total = 0
- }
-
- data := homePageData{
- Layout: h.pages.layout(r, "Главная", "home"),
- Products: featured,
- Categories: categories,
- TotalItems: total,
- }
- data.Success = flashMsg(r, "ok")
-
- h.pages.render(w, "home.html", data)
-}
diff --git a/internal/handlers/page.go b/internal/handlers/page.go
deleted file mode 100644
index 4a641f7..0000000
--- a/internal/handlers/page.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package handlers
-
-import (
- "html/template"
- "net/http"
-
- "shop/internal/auth"
- "shop/internal/models"
-)
-
-type Layout struct {
- Title string
- Nav string
- User *models.User
- Error string
- Success string
-}
-
-type Pages struct {
- tmpl *template.Template
- auth *auth.Service
-}
-
-func NewPages(tmpl *template.Template, authSvc *auth.Service) *Pages {
- return &Pages{tmpl: tmpl, auth: authSvc}
-}
-
-func (p *Pages) layout(r *http.Request, title, nav string) Layout {
- user, _ := p.auth.UserFromRequest(r.Context(), r)
- return Layout{Title: title, Nav: nav, User: user}
-}
-
-func (p *Pages) render(w http.ResponseWriter, name string, data any) {
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := p.tmpl.ExecuteTemplate(w, name, data); err != nil {
- http.Error(w, "ошибка шаблона", http.StatusInternalServerError)
- }
-}
-
-func flashMsg(r *http.Request, key string) string {
- switch r.URL.Query().Get(key) {
- case "registered":
- return "Регистрация успешна. Войдите в аккаунт."
- case "login":
- return "Вы успешно вошли."
- case "logout":
- return "Вы вышли из аккаунта."
- case "profile":
- return "Профиль обновлён."
- default:
- return ""
- }
-}
diff --git a/internal/models/product.go b/internal/models/product.go
deleted file mode 100644
index 15a4e89..0000000
--- a/internal/models/product.go
+++ /dev/null
@@ -1,11 +0,0 @@
-package models
-
-type Product struct {
- ID int
- Name string
- Description string
- Price float64
- ImageURL string
- Category string
- Featured bool
-}
diff --git a/internal/models/user.go b/internal/models/user.go
deleted file mode 100644
index 73902e7..0000000
--- a/internal/models/user.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package models
-
-import "time"
-
-type User struct {
- ID int
- Email string
- Name string
- CreatedAt time.Time
-}
diff --git a/internal/repository/products.go b/internal/repository/products.go
deleted file mode 100644
index 0356fd9..0000000
--- a/internal/repository/products.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package repository
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5/pgxpool"
-
- "shop/internal/models"
-)
-
-type ProductRepository struct {
- pool *pgxpool.Pool
-}
-
-func NewProductRepository(pool *pgxpool.Pool) *ProductRepository {
- return &ProductRepository{pool: pool}
-}
-
-func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.Product, error) {
- rows, err := r.pool.Query(ctx, `
- SELECT id, name, description, price, image_url, category, featured
- FROM products
- WHERE featured = true
- ORDER BY id
- LIMIT $1`, limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var items []models.Product
- for rows.Next() {
- var p models.Product
- if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Price, &p.ImageURL, &p.Category, &p.Featured); err != nil {
- return nil, err
- }
- items = append(items, p)
- }
- return items, rows.Err()
-}
-
-func (r *ProductRepository) Categories(ctx context.Context) ([]string, error) {
- rows, err := r.pool.Query(ctx, `
- SELECT DISTINCT category FROM products ORDER BY category`)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var cats []string
- for rows.Next() {
- var c string
- if err := rows.Scan(&c); err != nil {
- return nil, err
- }
- cats = append(cats, c)
- }
- return cats, rows.Err()
-}
-
-func (r *ProductRepository) Count(ctx context.Context) (int, error) {
- var n int
- err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM products`).Scan(&n)
- return n, err
-}
diff --git a/internal/repository/sessions.go b/internal/repository/sessions.go
deleted file mode 100644
index 389e47d..0000000
--- a/internal/repository/sessions.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package repository
-
-import (
- "context"
- "errors"
- "time"
-
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgxpool"
-)
-
-type SessionRepository struct {
- pool *pgxpool.Pool
-}
-
-func NewSessionRepository(pool *pgxpool.Pool) *SessionRepository {
- return &SessionRepository{pool: pool}
-}
-
-func (r *SessionRepository) Create(ctx context.Context, token string, userID int, expires time.Time) error {
- _, err := r.pool.Exec(ctx, `
- INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, $3)`,
- token, userID, expires)
- return err
-}
-
-func (r *SessionRepository) UserID(ctx context.Context, token string) (int, error) {
- var userID int
- err := r.pool.QueryRow(ctx, `
- SELECT user_id FROM sessions
- WHERE id = $1 AND expires_at > NOW()`, token).Scan(&userID)
- if errors.Is(err, pgx.ErrNoRows) {
- return 0, nil
- }
- if err != nil {
- return 0, err
- }
- return userID, nil
-}
-
-func (r *SessionRepository) Delete(ctx context.Context, token string) error {
- _, err := r.pool.Exec(ctx, `DELETE FROM sessions WHERE id = $1`, token)
- return err
-}
diff --git a/internal/repository/users.go b/internal/repository/users.go
deleted file mode 100644
index b004a65..0000000
--- a/internal/repository/users.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package repository
-
-import (
- "context"
- "errors"
-
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgconn"
- "github.com/jackc/pgx/v5/pgxpool"
-
- "shop/internal/models"
-)
-
-type UserRepository struct {
- pool *pgxpool.Pool
-}
-
-func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
- return &UserRepository{pool: pool}
-}
-
-func (r *UserRepository) Create(ctx context.Context, email, passwordHash, name string) (*models.User, error) {
- var u models.User
- err := r.pool.QueryRow(ctx, `
- INSERT INTO users (email, password_hash, name)
- VALUES ($1, $2, $3)
- RETURNING id, email, name, created_at`,
- email, passwordHash, name,
- ).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
- if err != nil {
- return nil, err
- }
- return &u, nil
-}
-
-func (r *UserRepository) ByID(ctx context.Context, id int) (*models.User, error) {
- var u models.User
- err := r.pool.QueryRow(ctx, `
- SELECT id, email, name, created_at FROM users WHERE id = $1`, id,
- ).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- return &u, nil
-}
-
-func (r *UserRepository) ByEmailWithHash(ctx context.Context, email string) (*models.User, string, error) {
- var u models.User
- var hash string
- err := r.pool.QueryRow(ctx, `
- SELECT id, email, name, created_at, password_hash
- FROM users WHERE email = $1`, email,
- ).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt, &hash)
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, "", nil
- }
- if err != nil {
- return nil, "", err
- }
- return &u, hash, nil
-}
-
-func (r *UserRepository) UpdateName(ctx context.Context, userID int, name string) error {
- _, err := r.pool.Exec(ctx, `UPDATE users SET name = $1 WHERE id = $2`, name, userID)
- return err
-}
-
-func (r *UserRepository) Count(ctx context.Context) (int, error) {
- var n int
- err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
- return n, err
-}
-
-func IsUniqueViolation(err error) bool {
- var pgErr *pgconn.PgError
- return errors.As(err, &pgErr) && pgErr.Code == "23505"
-}
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
deleted file mode 100644
index 0ba425d..0000000
--- a/internal/setup/setup.go
+++ /dev/null
@@ -1,223 +0,0 @@
-package setup
-
-import (
- "bufio"
- "fmt"
- "net/url"
- "os"
- "path/filepath"
- "strings"
-)
-
-type Config struct {
- SiteDomain string
- AcmeEmail string
- HTTPPort string
- HTTPSPort string
- UseDockerDB bool
- DBHost string
- DBPort string
- DBUser string
- DBPassword string
- DBName string
- DBSSLMode string
-}
-
-func RunInteractive(root string) (Config, error) {
- in := bufio.NewReader(os.Stdin)
- fmt.Println("=== Установщик ShopNova ===")
- fmt.Println()
-
- cfg := Config{}
-
- cfg.SiteDomain = ask(in, "Домен сайта (например shop.example.com, Enter = localhost)", "localhost")
- cfg.AcmeEmail = ask(in, "Email для Let's Encrypt (Traefik)", "admin@localhost")
- cfg.HTTPPort = ask(in, "HTTP порт", "80")
- cfg.HTTPSPort = ask(in, "HTTPS порт", "443")
-
- useDocker := askYesNo(in, "Использовать PostgreSQL из Docker Compose?", true)
- cfg.UseDockerDB = useDocker
-
- if useDocker {
- cfg.DBHost = "postgres"
- cfg.DBPort = "5432"
- cfg.DBSSLMode = "require"
- fmt.Println("\n--- База данных (контейнер postgres) ---")
- } else {
- fmt.Println("\n--- База данных (внешний сервер) ---")
- cfg.DBHost = ask(in, "Хост БД", "localhost")
- cfg.DBPort = ask(in, "Порт БД", "5432")
- cfg.DBSSLMode = ask(in, "SSL mode (disable|require|verify-full)", "require")
- }
-
- cfg.DBUser = ask(in, "Пользователь БД", "shop")
- cfg.DBPassword = askPassword(in, "Пароль БД")
- cfg.DBName = ask(in, "Имя базы данных", "shopdb")
-
- if err := WriteFiles(root, cfg); err != nil {
- return cfg, err
- }
-
- fmt.Println("\n✓ Созданы файлы: .env, traefik/dynamic/shop.yml")
- fmt.Println("\nДальше (на сервере):")
- fmt.Println(" docker compose up --build -d")
- fmt.Println(" ./check.sh --after-start")
- if !useLocalDomain(cfg.SiteDomain) {
- fmt.Printf(" Сайт: https://%s\n", cfg.SiteDomain)
- } else {
- fmt.Printf(" Сайт: http://localhost:%s\n", cfg.HTTPPort)
- }
- return cfg, nil
-}
-
-func WriteFiles(root string, cfg Config) error {
- envPath := filepath.Join(root, ".env")
- traefikPath := filepath.Join(root, "traefik", "dynamic", "shop.yml")
-
- if err := os.WriteFile(envPath, []byte(buildEnv(cfg)), 0o600); err != nil {
- return fmt.Errorf("write .env: %w", err)
- }
- if err := os.MkdirAll(filepath.Dir(traefikPath), 0o755); err != nil {
- return err
- }
- if err := os.WriteFile(traefikPath, []byte(buildTraefikDynamic(cfg)), 0o644); err != nil {
- return fmt.Errorf("write traefik config: %w", err)
- }
- return nil
-}
-
-func buildEnv(cfg Config) string {
- dbURL := DatabaseURL(cfg)
- cookieSecure := "false"
- if !useLocalDomain(cfg.SiteDomain) {
- cookieSecure = "true"
- }
- lines := []string{
- "# Сгенерировано установщиком ShopNova",
- fmt.Sprintf("SITE_DOMAIN=%s", cfg.SiteDomain),
- fmt.Sprintf("ACME_EMAIL=%s", cfg.AcmeEmail),
- fmt.Sprintf("HTTP_PORT=%s", cfg.HTTPPort),
- fmt.Sprintf("HTTPS_PORT=%s", cfg.HTTPSPort),
- "",
- fmt.Sprintf("POSTGRES_USER=%s", cfg.DBUser),
- fmt.Sprintf("POSTGRES_PASSWORD=%s", cfg.DBPassword),
- fmt.Sprintf("POSTGRES_DB=%s", cfg.DBName),
- "",
- fmt.Sprintf("DATABASE_URL=%s", dbURL),
- "APP_PORT=8080",
- fmt.Sprintf("COOKIE_SECURE=%s", cookieSecure),
- "SESSION_TTL_HOURS=168",
- "",
- fmt.Sprintf("DB_HOST=%s", cfg.DBHost),
- fmt.Sprintf("DB_PORT=%s", cfg.DBPort),
- fmt.Sprintf("DB_SSLMODE=%s", cfg.DBSSLMode),
- }
- return strings.Join(lines, "\n") + "\n"
-}
-
-func DatabaseURL(cfg Config) string {
- u := &url.URL{
- Scheme: "postgres",
- User: url.UserPassword(cfg.DBUser, cfg.DBPassword),
- Host: fmt.Sprintf("%s:%s", cfg.DBHost, cfg.DBPort),
- Path: cfg.DBName,
- }
- q := u.Query()
- q.Set("sslmode", cfg.DBSSLMode)
- u.RawQuery = q.Encode()
- return u.String()
-}
-
-func buildTraefikDynamic(cfg Config) string {
- domain := strings.TrimSpace(cfg.SiteDomain)
- if useLocalDomain(domain) {
- return fmt.Sprintf(`# Сгенерировано установщиком ShopNova (localhost)
-http:
- routers:
- shop:
- rule: "Host(`+"`%s`"+`)"
- entryPoints: [web]
- middlewares: [gzip]
- service: shop
- middlewares:
- gzip:
- compress: {}
- services:
- shop:
- loadBalancer:
- servers:
- - url: "http://app:8080"
-`, domain)
- }
-
- return fmt.Sprintf(`# Сгенерировано установщиком ShopNova
-http:
- routers:
- shop-http:
- rule: "Host(`+"`%s`"+`)"
- entryPoints: [web]
- middlewares: [redirect-https]
- service: shop
- shop:
- rule: "Host(`+"`%s`"+`)"
- entryPoints: [websecure]
- middlewares: [gzip]
- service: shop
- tls:
- certResolver: letsencrypt
- middlewares:
- redirect-https:
- redirectScheme:
- scheme: https
- permanent: true
- gzip:
- compress: {}
- services:
- shop:
- loadBalancer:
- servers:
- - url: "http://app:8080"
-`, domain, domain)
-}
-
-func useLocalDomain(d string) bool {
- d = strings.ToLower(strings.TrimSpace(d))
- return d == "" || d == "localhost" || d == "127.0.0.1" || d == "local"
-}
-
-func ask(in *bufio.Reader, prompt, def string) string {
- if def != "" {
- fmt.Printf("%s [%s]: ", prompt, def)
- } else {
- fmt.Printf("%s: ", prompt)
- }
- line, _ := in.ReadString('\n')
- line = strings.TrimSpace(line)
- if line == "" {
- return def
- }
- return line
-}
-
-func askPassword(in *bufio.Reader, prompt string) string {
- fmt.Printf("%s: ", prompt)
- line, _ := in.ReadString('\n')
- return strings.TrimSpace(line)
-}
-
-func askYesNo(in *bufio.Reader, prompt string, def bool) bool {
- defStr := "y"
- if !def {
- defStr = "n"
- }
- for {
- ans := strings.ToLower(ask(in, prompt+" (y/n)", defStr))
- switch ans {
- case "y", "yes", "д", "да":
- return true
- case "n", "no", "н", "нет":
- return false
- }
- fmt.Println("Введите y или n")
- }
-}
diff --git a/internal/version/version.go b/internal/version/version.go
deleted file mode 100644
index bac2489..0000000
--- a/internal/version/version.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package version
-
-import "runtime"
-
-const (
- AppVersion = "0.20"
- ExpectedPostgresMajor = 17
- MinGoVersion = "1.22"
-)
-
-func GoRuntime() string {
- return runtime.Version()
-}
diff --git a/internal/web/embed.go b/internal/web/embed.go
deleted file mode 100644
index d66769d..0000000
--- a/internal/web/embed.go
+++ /dev/null
@@ -1,9 +0,0 @@
-package web
-
-import "embed"
-
-//go:embed templates/*
-var TemplatesFS embed.FS
-
-//go:embed static/*
-var StaticFS embed.FS
diff --git a/internal/web/static/css/style.css b/internal/web/static/css/style.css
deleted file mode 100644
index 5c9ef0e..0000000
--- a/internal/web/static/css/style.css
+++ /dev/null
@@ -1,615 +0,0 @@
-:root {
- --bg: #0f0f12;
- --bg-elevated: #1a1a21;
- --surface: #23232d;
- --text: #f4f4f6;
- --text-muted: #9b9bab;
- --accent: #e8c547;
- --accent-hover: #f5d76a;
- --border: rgba(255, 255, 255, 0.08);
- --radius: 14px;
- --shadow: 0 24px 48px rgba(0, 0, 0, 0.45);
- --font: "DM Sans", system-ui, sans-serif;
- --font-display: "Instrument Serif", Georgia, serif;
-}
-
-*,
-*::before,
-*::after {
- box-sizing: border-box;
-}
-
-html {
- scroll-behavior: smooth;
-}
-
-body {
- margin: 0;
- font-family: var(--font);
- background: var(--bg);
- color: var(--text);
- line-height: 1.6;
- min-height: 100vh;
-}
-
-.container {
- width: min(1120px, 92vw);
- margin-inline: auto;
-}
-
-a {
- color: inherit;
- text-decoration: none;
-}
-
-/* Header */
-.site-header {
- position: sticky;
- top: 0;
- z-index: 100;
- backdrop-filter: blur(12px);
- background: rgba(15, 15, 18, 0.85);
- border-bottom: 1px solid var(--border);
-}
-
-.header-inner {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 1.5rem;
- padding: 1rem 0;
-}
-
-.logo {
- font-family: var(--font-display);
- font-size: 1.5rem;
- font-weight: 400;
- letter-spacing: -0.02em;
-}
-
-.logo span {
- color: var(--accent);
-}
-
-.nav {
- display: flex;
- gap: 1.75rem;
-}
-
-.nav-link {
- font-size: 0.95rem;
- color: var(--text-muted);
- transition: color 0.2s;
-}
-
-.nav-link:hover,
-.nav-link.active {
- color: var(--text);
-}
-
-.header-actions {
- display: flex;
- gap: 0.75rem;
-}
-
-/* Buttons */
-.btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0.55rem 1.1rem;
- font-family: inherit;
- font-size: 0.9rem;
- font-weight: 500;
- border-radius: 999px;
- border: none;
- cursor: pointer;
- transition: background 0.2s, transform 0.15s;
-}
-
-.btn:active {
- transform: scale(0.98);
-}
-
-.btn-primary {
- background: var(--accent);
- color: #1a1508;
-}
-
-.btn-primary:hover {
- background: var(--accent-hover);
-}
-
-.btn-ghost {
- background: transparent;
- color: var(--text);
- border: 1px solid var(--border);
-}
-
-.btn-ghost:hover {
- background: var(--surface);
-}
-
-.btn-lg {
- padding: 0.85rem 1.5rem;
- font-size: 1rem;
-}
-
-.btn-sm {
- padding: 0.4rem 0.85rem;
- font-size: 0.82rem;
-}
-
-/* Hero */
-.hero {
- padding: 4rem 0 5rem;
- background:
- radial-gradient(ellipse 80% 60% at 70% 20%, rgba(232, 197, 71, 0.12), transparent),
- var(--bg);
-}
-
-.hero-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 3rem;
- align-items: center;
-}
-
-.eyebrow {
- text-transform: uppercase;
- letter-spacing: 0.14em;
- font-size: 0.75rem;
- color: var(--accent);
- margin: 0 0 1rem;
-}
-
-.hero-title {
- font-family: var(--font-display);
- font-size: clamp(2.5rem, 5vw, 3.75rem);
- font-weight: 400;
- line-height: 1.1;
- margin: 0 0 1.25rem;
-}
-
-.hero-title em {
- font-style: italic;
- color: var(--accent);
-}
-
-.hero-lead {
- color: var(--text-muted);
- font-size: 1.1rem;
- max-width: 28ch;
- margin: 0 0 2rem;
-}
-
-.hero-cta {
- display: flex;
- flex-wrap: wrap;
- gap: 1rem;
-}
-
-.hero-visual {
- position: relative;
- min-height: 320px;
-}
-
-.hero-card {
- position: absolute;
- border-radius: var(--radius);
- box-shadow: var(--shadow);
-}
-
-.hero-card-a {
- inset: 10% 20% 25% 0;
- background: linear-gradient(145deg, #3d3d4a, #23232d);
- border: 1px solid var(--border);
-}
-
-.hero-card-b {
- inset: 35% 0 0 25%;
- background: linear-gradient(145deg, rgba(232, 197, 71, 0.35), #2a2820);
- border: 1px solid rgba(232, 197, 71, 0.25);
-}
-
-/* Sections */
-.section {
- padding: 4rem 0;
-}
-
-.section-title {
- font-family: var(--font-display);
- font-size: 2rem;
- font-weight: 400;
- margin: 0 0 0.5rem;
-}
-
-.section-sub {
- color: var(--text-muted);
- margin: 0 0 2rem;
-}
-
-.section-head {
- margin-bottom: 2rem;
-}
-
-/* Categories */
-.category-list {
- display: flex;
- flex-wrap: wrap;
- gap: 0.75rem;
- list-style: none;
- padding: 0;
- margin: 0;
-}
-
-.category-chip {
- display: inline-block;
- padding: 0.5rem 1rem;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: 999px;
- font-size: 0.9rem;
- transition: border-color 0.2s, background 0.2s;
-}
-
-.category-chip:hover {
- border-color: var(--accent);
- background: var(--bg-elevated);
-}
-
-/* Products */
-.product-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
- gap: 1.5rem;
-}
-
-.product-card {
- background: var(--bg-elevated);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- overflow: hidden;
- transition: transform 0.25s, box-shadow 0.25s;
-}
-
-.product-card:hover {
- transform: translateY(-4px);
- box-shadow: var(--shadow);
-}
-
-.product-image {
- aspect-ratio: 4 / 3;
- background-size: cover;
- background-position: center;
- background-color: var(--surface);
-}
-
-.product-body {
- padding: 1.25rem;
-}
-
-.product-category {
- font-size: 0.75rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--accent);
-}
-
-.product-name {
- font-size: 1.1rem;
- margin: 0.35rem 0 0.5rem;
-}
-
-.product-desc {
- font-size: 0.88rem;
- color: var(--text-muted);
- margin: 0 0 1rem;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.product-footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 0.75rem;
-}
-
-.product-price {
- font-weight: 600;
- font-size: 1.05rem;
-}
-
-.empty-state {
- text-align: center;
- color: var(--text-muted);
- padding: 3rem;
- background: var(--bg-elevated);
- border-radius: var(--radius);
- border: 1px dashed var(--border);
-}
-
-/* Features */
-.features {
- background: var(--bg-elevated);
- border-block: 1px solid var(--border);
-}
-
-.features-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 2rem;
-}
-
-.feature h3 {
- margin: 0 0 0.5rem;
- font-size: 1.1rem;
-}
-
-.feature p {
- margin: 0;
- color: var(--text-muted);
- font-size: 0.95rem;
-}
-
-/* Footer */
-.site-footer {
- padding: 2.5rem 0;
- border-top: 1px solid var(--border);
-}
-
-.footer-inner {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- gap: 1rem;
-}
-
-.footer-brand {
- font-family: var(--font-display);
- font-size: 1.25rem;
- margin: 0;
-}
-
-.footer-copy {
- margin: 0;
- color: var(--text-muted);
- font-size: 0.9rem;
-}
-
-@media (max-width: 768px) {
- .nav {
- display: none;
- }
-
- .hero-grid {
- grid-template-columns: 1fr;
- }
-
- .hero-visual {
- min-height: 200px;
- }
-
- .features-grid {
- grid-template-columns: 1fr;
- }
-
- .account-grid {
- grid-template-columns: 1fr;
- }
-
- .user-greeting {
- display: none;
- }
-}
-
-/* Auth */
-.inline-form {
- display: inline;
- margin: 0;
-}
-
-.user-greeting {
- font-size: 0.9rem;
- color: var(--text-muted);
- margin-right: 0.25rem;
-}
-
-.alert {
- margin: 1rem 0 0;
- padding: 0.75rem 1rem;
- border-radius: var(--radius);
- font-size: 0.95rem;
-}
-
-.alert-success {
- background: rgba(72, 187, 120, 0.15);
- border: 1px solid rgba(72, 187, 120, 0.35);
- color: #9ae6b4;
-}
-
-.alert-error {
- background: rgba(245, 101, 101, 0.12);
- border: 1px solid rgba(245, 101, 101, 0.35);
- color: #feb2b2;
-}
-
-.auth-section {
- padding: 3rem 0 5rem;
-}
-
-.auth-container {
- display: flex;
- justify-content: center;
-}
-
-.auth-card {
- width: min(420px, 100%);
- background: var(--bg-elevated);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 2rem;
-}
-
-.auth-title {
- font-family: var(--font-display);
- font-size: 2rem;
- font-weight: 400;
- margin: 0 0 0.35rem;
-}
-
-.auth-sub {
- color: var(--text-muted);
- margin: 0 0 1.5rem;
-}
-
-.auth-form {
- display: flex;
- flex-direction: column;
- gap: 1rem;
-}
-
-.form-field {
- display: flex;
- flex-direction: column;
- gap: 0.35rem;
-}
-
-.form-field span {
- font-size: 0.85rem;
- color: var(--text-muted);
-}
-
-.form-field input {
- padding: 0.65rem 0.85rem;
- border-radius: 10px;
- border: 1px solid var(--border);
- background: var(--surface);
- color: var(--text);
- font-family: inherit;
- font-size: 1rem;
-}
-
-.form-field input:focus {
- outline: none;
- border-color: var(--accent);
-}
-
-.form-field input:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-.btn-block {
- width: 100%;
- margin-top: 0.5rem;
-}
-
-.auth-footer {
- margin: 1.25rem 0 0;
- text-align: center;
- color: var(--text-muted);
- font-size: 0.95rem;
-}
-
-.auth-footer a {
- color: var(--accent);
-}
-
-.auth-footer a:hover {
- color: var(--accent-hover);
-}
-
-/* Account */
-.account-section {
- padding: 2.5rem 0 4rem;
-}
-
-.account-grid {
- display: grid;
- grid-template-columns: 260px 1fr;
- gap: 2rem;
- margin-top: 1.5rem;
-}
-
-.account-user-card {
- background: var(--bg-elevated);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 1.25rem;
-}
-
-.account-label {
- font-size: 0.75rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--accent);
- margin: 0 0 0.5rem;
-}
-
-.account-name {
- font-size: 1.25rem;
- font-weight: 600;
- margin: 0 0 0.25rem;
-}
-
-.account-email {
- color: var(--text-muted);
- font-size: 0.9rem;
- margin: 0 0 0.75rem;
- word-break: break-all;
-}
-
-.account-meta {
- font-size: 0.82rem;
- color: var(--text-muted);
- margin: 0;
-}
-
-.account-nav {
- display: flex;
- flex-direction: column;
- gap: 0.35rem;
- margin-top: 1rem;
-}
-
-.account-nav-item {
- display: block;
- padding: 0.55rem 0.85rem;
- border-radius: 8px;
- font-size: 0.95rem;
- color: var(--text-muted);
-}
-
-.account-nav-item.active {
- background: var(--surface);
- color: var(--text);
-}
-
-.account-nav-item:hover:not(.active) {
- background: rgba(255, 255, 255, 0.04);
- color: var(--text);
-}
-
-.account-main {
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
-}
-
-.account-card h2 {
- margin: 0 0 1rem;
- font-size: 1.15rem;
-}
-
-.account-hint h3 {
- margin: 0 0 0.5rem;
-}
-
-.text-muted {
- color: var(--text-muted);
- font-size: 0.95rem;
-}
diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html
deleted file mode 100644
index 735e60d..0000000
--- a/internal/web/templates/account.html
+++ /dev/null
@@ -1,46 +0,0 @@
-{{define "account.html"}}
-{{template "layout" .}}
-{{end}}
-
-{{define "content"}}
- История заказов появится в следующих версиях. Пока вы можете просматривать каталог и добавлять товары в корзину. Новая коллекция 2026 Качественные товары с быстрой доставкой. В каталоге уже {{.TotalItems}} позиций. Избранное из нашего ассортимента {{.Description}} Товары скоро появятся. Проверьте подключение к базе данных. Отправка в день заказа по всей России. 14 дней на возврат без лишних вопросов. Шифрование данных и защищённые платежи.Личный кабинет
- Настройки профиля
-
- Заказы
- Стиль, который
-
остаётся с вамиКатегории
-
- {{range .Categories}}
-
- Популярные товары
- {{.Name}}
- Быстрая доставка
- Гарантия качества
- Безопасная оплата
-
{{.Success}}
{{.Error}}
Войдите в личный кабинет
- - -Создайте аккаунт для заказов и личного кабинета
- - -<%= user.name %>
+<%= user.email %>
+С нами с <%= new Date(user.created_at).toLocaleDateString('ru-RU') %>
+ +<%= error %>
<% } %> + +<% if (!items.length) { %> +Корзина пуста. Перейти в каталог
+<% } else { %> + +<% } %> + +<%- include('partials/layout-end') %> diff --git a/src/views/checkout.ejs b/src/views/checkout.ejs new file mode 100644 index 0000000..d85b92f --- /dev/null +++ b/src/views/checkout.ejs @@ -0,0 +1,43 @@ +<%- include('partials/layout-start') %> + +<%= error %>
<% } %> + +Доставка по России. Оплата при получении.
+Товары не найдены. Попробуйте другой запрос.
+<% } else { %> +<%= formatPrice(p.price_cents) %>
+ +Заказ успешно оформлен! Мы свяжемся с вами по email.
+<% } %> + +Статус: <%= statusLabels[order.status] || order.status %>
+Дата: <%= new Date(order.created_at).toLocaleString('ru-RU') %>
+Доставка: <%= order.address %>
+Контакт: <%= order.customer_name %>, <%= order.customer_email %><% if (order.customer_phone) { %>, <%= order.customer_phone %><% } %>
+ +Итого: <%= formatPrice(order.total_cents) %>
+Заказов пока нет. Перейти в каталог
+<% } else { %> +| № | +Дата | +Статус | +Сумма | ++ |
|---|---|---|---|---|
| #<%= o.id %> | +<%= new Date(o.created_at).toLocaleString('ru-RU') %> | +<%= statusLabels[o.status] || o.status %> | +<%= formatPrice(o.total_cents) %> | +Подробнее | +