commit 448cf2a465c45deb50792d7f6f1d232de324f16e Author: shop Date: Sat May 16 17:09:27 2026 +0300 Интернет-магазин: Go, PostgreSQL 17 SSL, Caddy, Docker Compose Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7b864f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +POSTGRES_USER=shop +POSTGRES_PASSWORD=shop_secret_change_me +POSTGRES_DB=shopdb + +HTTP_PORT=80 +HTTPS_PORT=443 + +# Для HTTPS с Let's Encrypt в Caddyfile +# SITE_DOMAIN=shop.example.com +# CADDY_EMAIL=you@example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98b209c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +*.exe +bin/ +vendor/ +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4914bcc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..8f41ddb --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# ShopNova — интернет-магазин (Go) + +Главная страница интернет-магазина на Go с PostgreSQL 17 (SSL), reverse proxy Caddy и Docker Compose. + +## Стек + +- Go 1.22, `pgx/v5` +- PostgreSQL 17 (SSL) +- Caddy 2 +- Docker Compose + +## Запуск + +```bash +cp .env.example .env +docker compose up --build -d +``` + +Сайт: http://localhost + +## Локальная разработка + +```bash +go run ./cmd/server +``` + +Переменная `DATABASE_URL` обязательна (см. `.env.example`). diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..faba4e2 --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,32 @@ +{ + email {$CADDY_EMAIL:admin@localhost} +} + +# HTTP → приложение (для локальной разработки) +:80 { + encode gzip zstd + + @health path /health + handle @health { + reverse_proxy app:8080 + } + + handle /static/* { + reverse_proxy app:8080 + } + + handle { + reverse_proxy app:8080 + } + + log { + output stdout + format console + } +} + +# HTTPS (раскомментируйте домен для продакшена) +# {$SITE_DOMAIN} { +# encode gzip zstd +# reverse_proxy app:8080 +# } diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..56ba985 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "html/template" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "shop/internal/config" + "shop/internal/database" + "shop/internal/handlers" + "shop/internal/repository" + "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() + + tmpl, err := loadTemplates() + if err != nil { + log.Fatalf("templates: %v", err) + } + + products := repository.NewProductRepository(pool) + home := handlers.NewHomeHandler(products, tmpl) + + 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.Handle("GET /health", http.HandlerFunc(handlers.Health)) + mux.Handle("GET /", home) + + 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 new file mode 100644 index 0000000..5137912 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,85 @@ +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 && /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 + networks: + - backend + - frontend + restart: unless-stopped + + caddy: + image: caddy:2-alpine + container_name: shop-caddy + depends_on: + - app + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: + - frontend + restart: unless-stopped + +volumes: + postgres_data: + postgres_ssl: + caddy_data: + caddy_config: + +networks: + backend: + frontend: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b821f29 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module shop + +go 1.22 + +require github.com/jackc/pgx/v5 v5.7.2 + +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/crypto v0.31.0 // 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 new file mode 100644 index 0000000..731b5df --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +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/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d1d2913 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,48 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "time" +) + +type Config struct { + HTTPAddr string + DatabaseURL string + ReadTimeout time.Duration + WriteTimeout time.Duration +} + +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), + } + 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 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 new file mode 100644 index 0000000..c8460f4 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,36 @@ +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/home.go b/internal/handlers/home.go new file mode 100644 index 0000000..f1b0a85 --- /dev/null +++ b/internal/handlers/home.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "html/template" + "log" + "net/http" + + "shop/internal/models" + "shop/internal/repository" +) + +type HomeHandler struct { + products *repository.ProductRepository + templates *template.Template +} + +func NewHomeHandler(products *repository.ProductRepository, templates *template.Template) *HomeHandler { + return &HomeHandler{products: products, templates: templates} +} + +type homePageData struct { + Title string + 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{ + Title: "Главная", + Products: featured, + Categories: categories, + TotalItems: total, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.ExecuteTemplate(w, "home.html", data); err != nil { + log.Printf("render home: %v", err) + } +} + +func Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) +} diff --git a/internal/models/product.go b/internal/models/product.go new file mode 100644 index 0000000..15a4e89 --- /dev/null +++ b/internal/models/product.go @@ -0,0 +1,11 @@ +package models + +type Product struct { + ID int + Name string + Description string + Price float64 + ImageURL string + Category string + Featured bool +} diff --git a/internal/repository/products.go b/internal/repository/products.go new file mode 100644 index 0000000..0356fd9 --- /dev/null +++ b/internal/repository/products.go @@ -0,0 +1,65 @@ +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/web/embed.go b/internal/web/embed.go new file mode 100644 index 0000000..d66769d --- /dev/null +++ b/internal/web/embed.go @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..f464a21 --- /dev/null +++ b/internal/web/static/css/style.css @@ -0,0 +1,399 @@ +: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; + } +} diff --git a/internal/web/templates/home.html b/internal/web/templates/home.html new file mode 100644 index 0000000..1ab30dc --- /dev/null +++ b/internal/web/templates/home.html @@ -0,0 +1,82 @@ +{{define "home.html"}} +{{template "layout" .}} +{{end}} + +{{define "content"}} +
+
+
+

Новая коллекция 2026

+

Стиль, который
остаётся с вами

+

Качественные товары с быстрой доставкой. В каталоге уже {{.TotalItems}} позиций.

+ +
+ +
+
+ +{{if .Categories}} +
+
+

Категории

+
    + {{range .Categories}} +
  • {{.}}
  • + {{end}} +
+
+
+{{end}} + +
+
+
+

Популярные товары

+

Избранное из нашего ассортимента

+
+ {{if .Products}} +
+ {{range .Products}} +
+
+
+ {{.Category}} +

{{.Name}}

+

{{.Description}}

+ +
+
+ {{end}} +
+ {{else}} +

Товары скоро появятся. Проверьте подключение к базе данных.

+ {{end}} +
+
+ +
+
+
+

Быстрая доставка

+

Отправка в день заказа по всей России.

+
+
+

Гарантия качества

+

14 дней на возврат без лишних вопросов.

+
+
+

Безопасная оплата

+

Шифрование данных и защищённые платежи.

+
+
+
+{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html new file mode 100644 index 0000000..712a30b --- /dev/null +++ b/internal/web/templates/layout.html @@ -0,0 +1,39 @@ +{{define "layout"}} + + + + + + {{.Title}} — ShopNova + + + + + + + +
+ {{template "content" .}} +
+ + + +{{end}} diff --git a/postgres/init/01_schema.sql b/postgres/init/01_schema.sql new file mode 100644 index 0000000..f9ab2b3 --- /dev/null +++ b/postgres/init/01_schema.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + description TEXT NOT NULL DEFAULT '', + price NUMERIC(10, 2) NOT NULL CHECK (price >= 0), + image_url TEXT NOT NULL DEFAULT '', + category VARCHAR(100) NOT NULL DEFAULT 'Разное', + featured BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_products_featured ON products (featured) WHERE featured = true; +CREATE INDEX IF NOT EXISTS idx_products_category ON products (category); + +INSERT INTO products (name, description, price, image_url, category, featured) VALUES +('Куртка Urban Shell', 'Лёгкая ветровка с водоотталкивающим покрытием.', 8990.00, 'https://images.unsplash.com/photo-1544022613-e87ca75a784a?w=600&q=80', 'Одежда', true), +('Кроссовки Nova Run', 'Амортизация для бега и повседневной носки.', 7490.00, 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=600&q=80', 'Обувь', true), +('Рюкзак City Pack', 'Отделение для ноутбука 15" и USB-порт.', 4590.00, 'https://images.unsplash.com/photo-1553062407-98eeb64c6a62?w=600&q=80', 'Аксессуары', true), +('Часы Minimal Steel', 'Корпус из нержавеющей стали, сапфировое стекло.', 12990.00, 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=600&q=80', 'Аксессуары', true), +('Худи Soft Loop', 'Хлопок премиум, уютная посадка.', 3990.00, 'https://images.unsplash.com/photo-1556821840-3a63f95609a7?w=600&q=80', 'Одежда', true), +('Наушники Air Tone', 'Активное шумоподавление, 30 ч автономности.', 9990.00, 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=600&q=80', 'Электроника', true), +('Футболка Essential', 'Базовая модель из органического хлопка.', 1990.00, 'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=600&q=80', 'Одежда', false), +('Кеды Classic Low', 'Кожаный верх, резиновая подошва.', 5490.00, 'https://images.unsplash.com/photo-1460353581641-37baddab0fa2?w=600&q=80', 'Обувь', true); diff --git a/postgres/ssl/generate-certs.sh b/postgres/ssl/generate-certs.sh new file mode 100644 index 0000000..1850c7f --- /dev/null +++ b/postgres/ssl/generate-certs.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -e + +DIR="${1:-/certs}" +CRT="$DIR/server.crt" +KEY="$DIR/server.key" + +if [ -f "$CRT" ] && [ -f "$KEY" ]; then + echo "SSL certificates already exist in $DIR" + exit 0 +fi + +mkdir -p "$DIR" + +openssl req -new -x509 -days 825 -nodes -text \ + -out "$CRT" \ + -keyout "$KEY" \ + -subj "/CN=postgres.shop.local" + +chmod 600 "$KEY" +chmod 644 "$CRT" + +# postgres image runs as uid 999 +chown 999:999 "$KEY" "$CRT" 2>/dev/null || true + +echo "Generated PostgreSQL SSL certificates in $DIR" diff --git a/push-to-gitea.ps1 b/push-to-gitea.ps1 new file mode 100644 index 0000000..fc764c3 --- /dev/null +++ b/push-to-gitea.ps1 @@ -0,0 +1,36 @@ +# Push в https://git.evilfox.cc/test/shop3.git +# При запросе введите логин и пароль (или токен Gitea) от git.evilfox.cc + +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot + +$git = Get-Command git -ErrorAction SilentlyContinue +if (-not $git) { + $git = "C:\Program Files\Git\bin\git.exe" + if (-not (Test-Path $git)) { + Write-Error "Git не найден. Установите: winget install Git.Git" + } +} else { + $git = $git.Source +} + +$remote = "https://git.evilfox.cc/test/shop3.git" + +if (-not (Test-Path .git)) { + & $git init -b main +} + +& $git add -A +$status = & $git status --porcelain +if ($status) { + & $git commit -m "Интернет-магазин: Go, PostgreSQL 17 SSL, Caddy, Docker Compose" +} + +$remotes = & $git remote 2>$null +if ($remotes -contains "origin") { + & $git remote set-url origin $remote +} else { + & $git remote add origin $remote +} + +& $git push -u origin main