Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efc95d48c4 |
+32
-13
@@ -35,6 +35,9 @@ func main() {
|
|||||||
productRepo := repository.NewProductRepository(pool)
|
productRepo := repository.NewProductRepository(pool)
|
||||||
adminRepo := repository.NewAdminRepository(pool)
|
adminRepo := repository.NewAdminRepository(pool)
|
||||||
statsRepo := repository.NewStatsRepository(pool)
|
statsRepo := repository.NewStatsRepository(pool)
|
||||||
|
userRepo := repository.NewUserRepository(pool)
|
||||||
|
cartRepo := repository.NewCartRepository(pool)
|
||||||
|
orderRepo := repository.NewOrderRepository(pool)
|
||||||
|
|
||||||
storage, err := upload.NewStorage(cfg.UploadDir)
|
storage, err := upload.NewStorage(cfg.UploadDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -46,29 +49,47 @@ func main() {
|
|||||||
log.Fatalf("admin seed: %v", err)
|
log.Fatalf("admin seed: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("admin ready: %s", cfg.AdminEmail)
|
log.Printf("admin ready: %s", cfg.AdminEmail)
|
||||||
} else {
|
|
||||||
log.Println("warning: set ADMIN_EMAIL and ADMIN_PASSWORD in .env to create admin")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
home, err := handlers.NewHomeHandler(productRepo, statsRepo)
|
tmpl, err := handlers.ParseTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("handler: %v", err)
|
log.Fatalf("templates: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
session := auth.NewSession(cfg.SessionSecret)
|
adminSession := auth.NewSession(cfg.SessionSecret, "shop_admin_session", "/admin")
|
||||||
admin := handlers.NewAdminHandler(adminRepo, productRepo, statsRepo, storage, session, home.Templates())
|
userSession := auth.NewSession(cfg.SessionSecret, "shop_user_session", "/")
|
||||||
|
cartSession := auth.NewSession(cfg.SessionSecret, "shop_cart_key", "/")
|
||||||
|
|
||||||
|
customer := handlers.NewCustomerHandler(
|
||||||
|
productRepo, userRepo, cartRepo, orderRepo, statsRepo,
|
||||||
|
userSession, cartSession, tmpl,
|
||||||
|
)
|
||||||
|
admin := handlers.NewAdminHandler(adminRepo, productRepo, statsRepo, storage, adminSession, tmpl)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", home.Static()))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler()))
|
||||||
mux.Handle("GET /uploads/", storage.FileServer())
|
mux.Handle("GET /uploads/", storage.FileServer())
|
||||||
mux.HandleFunc("GET /health", home.Health)
|
mux.HandleFunc("GET /health", customer.Health)
|
||||||
mux.HandleFunc("GET /{$}", home.Home)
|
|
||||||
|
mux.HandleFunc("GET /{$}", customer.Home)
|
||||||
|
mux.HandleFunc("GET /register", customer.RegisterPage)
|
||||||
|
mux.HandleFunc("POST /register", customer.Register)
|
||||||
|
mux.HandleFunc("GET /login", customer.LoginPage)
|
||||||
|
mux.HandleFunc("POST /login", customer.Login)
|
||||||
|
mux.HandleFunc("POST /logout", customer.Logout)
|
||||||
|
mux.HandleFunc("GET /cart", customer.CartPage)
|
||||||
|
mux.HandleFunc("POST /cart/add", customer.CartAdd)
|
||||||
|
mux.HandleFunc("POST /cart/update", customer.CartUpdate)
|
||||||
|
mux.HandleFunc("POST /cart/remove", customer.CartRemove)
|
||||||
|
mux.HandleFunc("GET /checkout", customer.CheckoutPage)
|
||||||
|
mux.HandleFunc("POST /checkout", customer.Checkout)
|
||||||
|
mux.HandleFunc("GET /account", customer.Account)
|
||||||
|
mux.HandleFunc("POST /account", customer.Account)
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin/login", admin.LoginPage)
|
mux.HandleFunc("GET /admin/login", admin.LoginPage)
|
||||||
mux.HandleFunc("POST /admin/login", admin.Login)
|
mux.HandleFunc("POST /admin/login", admin.Login)
|
||||||
mux.HandleFunc("POST /admin/logout", admin.Logout)
|
mux.HandleFunc("POST /admin/logout", admin.Logout)
|
||||||
mux.HandleFunc("GET /admin/{$}", admin.Dashboard)
|
mux.HandleFunc("GET /admin/{$}", admin.Dashboard)
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin/products", admin.ProductList)
|
mux.HandleFunc("GET /admin/products", admin.ProductList)
|
||||||
mux.HandleFunc("GET /admin/products/new", admin.ProductNew)
|
mux.HandleFunc("GET /admin/products/new", admin.ProductNew)
|
||||||
mux.HandleFunc("POST /admin/products/save", admin.ProductSave)
|
mux.HandleFunc("POST /admin/products/save", admin.ProductSave)
|
||||||
@@ -98,9 +119,7 @@ func main() {
|
|||||||
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
_ = srv.Shutdown(shutdownCtx)
|
||||||
log.Printf("shutdown: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ services:
|
|||||||
- ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
|
- ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
|
||||||
- ./migrations/002_admins.sql:/docker-entrypoint-initdb.d/002_admins.sql:ro
|
- ./migrations/002_admins.sql:/docker-entrypoint-initdb.d/002_admins.sql:ro
|
||||||
- ./migrations/003_admin_features.sql:/docker-entrypoint-initdb.d/003_admin_features.sql:ro
|
- ./migrations/003_admin_features.sql:/docker-entrypoint-initdb.d/003_admin_features.sql:ro
|
||||||
|
- ./migrations/004_users_cart_orders.sql:/docker-entrypoint-initdb.d/004_users_cart_orders.sql:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
+67
-35
@@ -9,27 +9,33 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const defaultTTL = 7 * 24 * time.Hour
|
||||||
cookieName = "shop_session"
|
|
||||||
sessionTTL = 24 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
secret []byte
|
secret []byte
|
||||||
|
cookieName string
|
||||||
|
path string
|
||||||
|
ttl time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSession(secret string) *Session {
|
func NewSession(secret, cookieName, path string) *Session {
|
||||||
return &Session{secret: []byte(secret)}
|
return &Session{
|
||||||
|
secret: []byte(secret),
|
||||||
|
cookieName: cookieName,
|
||||||
|
path: path,
|
||||||
|
ttl: defaultTTL,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) Create(email string) (string, error) {
|
func (s *Session) Create(value string) (string, error) {
|
||||||
exp := time.Now().Add(sessionTTL).Unix()
|
exp := time.Now().Add(s.ttl).Unix()
|
||||||
payload := fmt.Sprintf("%s|%d", email, exp)
|
payload := fmt.Sprintf("%s|%d", value, exp)
|
||||||
sig := s.sign(payload)
|
sig := s.sign(payload)
|
||||||
token := base64.URLEncoding.EncodeToString([]byte(payload + "|" + sig))
|
return base64.URLEncoding.EncodeToString([]byte(payload + "|" + sig)), nil
|
||||||
return token, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) Validate(token string) (string, bool) {
|
func (s *Session) Validate(token string) (string, bool) {
|
||||||
@@ -37,67 +43,93 @@ func (s *Session) Validate(token string) (string, bool) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(string(raw), "|")
|
parts := strings.Split(string(raw), "|")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
value, expStr, sig := parts[0], parts[1], parts[2]
|
||||||
email, expStr, sig := parts[0], parts[1], parts[2]
|
payload := value + "|" + expStr
|
||||||
payload := email + "|" + expStr
|
|
||||||
|
|
||||||
if !hmac.Equal([]byte(sig), []byte(s.sign(payload))) {
|
if !hmac.Equal([]byte(sig), []byte(s.sign(payload))) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||||
if err != nil || time.Now().Unix() > exp {
|
if err != nil || time.Now().Unix() > exp {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
return value, true
|
||||||
return email, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) SetCookie(w http.ResponseWriter, r *http.Request, token string) {
|
func (s *Session) SetCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: s.cookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
Path: "/admin",
|
Path: s.path,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: s.isSecure(r),
|
Secure: isSecure(r),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: int(sessionTTL.Seconds()),
|
MaxAge: int(s.ttl.Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) ClearCookie(w http.ResponseWriter, r *http.Request) {
|
func (s *Session) ClearCookie(w http.ResponseWriter, r *http.Request) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: s.cookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/admin",
|
Path: s.path,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: s.isSecure(r),
|
Secure: isSecure(r),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) isSecure(r *http.Request) bool {
|
|
||||||
if r.TLS != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Session) FromRequest(r *http.Request) (string, bool) {
|
func (s *Session) FromRequest(r *http.Request) (string, bool) {
|
||||||
c, err := r.Cookie(cookieName)
|
c, err := r.Cookie(s.cookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return s.Validate(c.Value)
|
return s.Validate(c.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Session) EnsureKey(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
if key, ok := s.FromRequest(r); ok && key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
key := uuid.New().String()
|
||||||
|
token, _ := s.Create(key)
|
||||||
|
s.SetCookie(w, r, token)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Session) sign(payload string) string {
|
func (s *Session) sign(payload string) string {
|
||||||
mac := hmac.New(sha256.New, s.secret)
|
mac := hmac.New(sha256.New, s.secret)
|
||||||
mac.Write([]byte(payload))
|
mac.Write([]byte(payload))
|
||||||
return base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
return base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isSecure(r *http.Request) bool {
|
||||||
|
if r.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserIDFromSession(s *Session, r *http.Request) (int, bool) {
|
||||||
|
val, ok := s.FromRequest(r)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(val, "user:") {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(strings.TrimPrefix(val, "user:"))
|
||||||
|
return id, err == nil && id > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetUserSession(s *Session, w http.ResponseWriter, r *http.Request, userID int) error {
|
||||||
|
token, err := s.Create(fmt.Sprintf("user:%d", userID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.SetCookie(w, r, token)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,50 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
|||||||
visit_date DATE NOT NULL DEFAULT CURRENT_DATE
|
visit_date DATE NOT NULL DEFAULT CURRENT_DATE
|
||||||
)`,
|
)`,
|
||||||
`INSERT INTO site_stats (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
|
`INSERT INTO site_stats (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS carts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
session_key VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_user ON carts (user_id) WHERE user_id IS NOT NULL`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_session ON carts (session_key) WHERE user_id IS NULL`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS cart_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
cart_id INT NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
|
||||||
|
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
UNIQUE (cart_id, product_id)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
guest_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
guest_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
guest_phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
total NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'new',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS order_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
product_name VARCHAR(255) NOT NULL,
|
||||||
|
price NUMERIC(10,2) NOT NULL,
|
||||||
|
quantity INT NOT NULL CHECK (quantity > 0)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, q := range queries {
|
for _, q := range queries {
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"shop/internal/auth"
|
||||||
|
"shop/internal/models"
|
||||||
|
"shop/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomerHandler struct {
|
||||||
|
products *repository.ProductRepository
|
||||||
|
users *repository.UserRepository
|
||||||
|
cart *repository.CartRepository
|
||||||
|
orders *repository.OrderRepository
|
||||||
|
stats *repository.StatsRepository
|
||||||
|
userSession *auth.Session
|
||||||
|
cartSession *auth.Session
|
||||||
|
templates *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
type shopPage struct {
|
||||||
|
Title string
|
||||||
|
User *models.User
|
||||||
|
CartCount int
|
||||||
|
Products interface{}
|
||||||
|
Categories interface{}
|
||||||
|
Error string
|
||||||
|
Success string
|
||||||
|
}
|
||||||
|
|
||||||
|
type cartPage struct {
|
||||||
|
shopPage
|
||||||
|
Items []models.CartItem
|
||||||
|
Total float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkoutPage struct {
|
||||||
|
shopPage
|
||||||
|
Items []models.CartItem
|
||||||
|
Total float64
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Phone string
|
||||||
|
Address string
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountPage struct {
|
||||||
|
shopPage
|
||||||
|
Orders []models.Order
|
||||||
|
Name string
|
||||||
|
Phone string
|
||||||
|
Address string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCustomerHandler(
|
||||||
|
products *repository.ProductRepository,
|
||||||
|
users *repository.UserRepository,
|
||||||
|
cart *repository.CartRepository,
|
||||||
|
orders *repository.OrderRepository,
|
||||||
|
stats *repository.StatsRepository,
|
||||||
|
userSession, cartSession *auth.Session,
|
||||||
|
templates *template.Template,
|
||||||
|
) *CustomerHandler {
|
||||||
|
return &CustomerHandler{
|
||||||
|
products: products, users: users, cart: cart, orders: orders, stats: stats,
|
||||||
|
userSession: userSession, cartSession: cartSession, templates: templates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) render(w http.ResponseWriter, name string, data any) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := h.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
log.Printf("render %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) basePage(r *http.Request, w http.ResponseWriter, title string) shopPage {
|
||||||
|
p := shopPage{Title: title}
|
||||||
|
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||||
|
if u, err := h.users.GetByID(r.Context(), uid); err == nil {
|
||||||
|
p.User = &u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cartID, _ := h.resolveCart(r, w)
|
||||||
|
if cartID > 0 {
|
||||||
|
if n, err := h.cart.ItemCount(r.Context(), cartID); err == nil {
|
||||||
|
p.CartCount = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) resolveCart(r *http.Request, w http.ResponseWriter) (int, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
sessionKey := h.cartSession.EnsureKey(w, r)
|
||||||
|
var userID *int
|
||||||
|
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||||
|
userID = &uid
|
||||||
|
}
|
||||||
|
return h.cart.GetOrCreate(ctx, sessionKey, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
_ = h.stats.IncrementHomeVisit(ctx)
|
||||||
|
|
||||||
|
products, _ := h.products.Featured(ctx, 8)
|
||||||
|
categories, _ := h.products.Categories(ctx)
|
||||||
|
|
||||||
|
p := h.basePage(r, w, "Shop — Интернет-магазин")
|
||||||
|
p.Products = products
|
||||||
|
p.Categories = categories
|
||||||
|
h.render(w, "home.html", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) RegisterPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(w, "register.html", h.basePage(r, w, "Регистрация"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
name := r.FormValue("name")
|
||||||
|
|
||||||
|
p := h.basePage(r, w, "Регистрация")
|
||||||
|
if email == "" || password == "" || name == "" {
|
||||||
|
p.Error = "Заполните все поля"
|
||||||
|
h.render(w, "register.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(password) < 6 {
|
||||||
|
p.Error = "Пароль минимум 6 символов"
|
||||||
|
h.render(w, "register.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exists, _ := h.users.EmailExists(ctx, email)
|
||||||
|
if exists {
|
||||||
|
p.Error = "Email уже зарегистрирован"
|
||||||
|
h.render(w, "register.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.users.Create(ctx, email, password, name)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("register: %v", err)
|
||||||
|
p.Error = "Ошибка регистрации"
|
||||||
|
h.render(w, "register.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := h.cartSession.EnsureKey(w, r)
|
||||||
|
_ = h.cart.MergeToUser(ctx, sessionKey, u.ID)
|
||||||
|
_ = auth.SetUserSession(h.userSession, w, r, u.ID)
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(w, "login.html", h.basePage(r, w, "Вход"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
u, err := h.users.Authenticate(ctx, r.FormValue("email"), r.FormValue("password"))
|
||||||
|
p := h.basePage(r, w, "Вход")
|
||||||
|
if err != nil {
|
||||||
|
p.Error = "Неверный email или пароль"
|
||||||
|
h.render(w, "login.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionKey := h.cartSession.EnsureKey(w, r)
|
||||||
|
_ = h.cart.MergeToUser(ctx, sessionKey, u.ID)
|
||||||
|
_ = auth.SetUserSession(h.userSession, w, r, u.ID)
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.userSession.ClearCookie(w, r)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) CartAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productID, _ := strconv.Atoi(r.FormValue("product_id"))
|
||||||
|
qty, _ := strconv.Atoi(r.FormValue("quantity"))
|
||||||
|
if qty < 1 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
if productID <= 0 {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cartID, err := h.resolveCart(r, w)
|
||||||
|
if err == nil {
|
||||||
|
_ = h.cart.AddItem(r.Context(), cartID, productID, qty)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/cart", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) CartPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cartID, err := h.resolveCart(r, w)
|
||||||
|
p := cartPage{shopPage: h.basePage(r, w, "Корзина")}
|
||||||
|
if err != nil || cartID == 0 {
|
||||||
|
h.render(w, "cart.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
p.Items, _ = h.cart.Items(ctx, cartID)
|
||||||
|
p.Total, _ = h.cart.Total(ctx, cartID)
|
||||||
|
h.render(w, "cart.html", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) CartUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productID, _ := strconv.Atoi(r.FormValue("product_id"))
|
||||||
|
qty, _ := strconv.Atoi(r.FormValue("quantity"))
|
||||||
|
cartID, _ := h.resolveCart(r, w)
|
||||||
|
if cartID > 0 {
|
||||||
|
_ = h.cart.UpdateItem(r.Context(), cartID, productID, qty)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/cart", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) CartRemove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productID, _ := strconv.Atoi(r.FormValue("product_id"))
|
||||||
|
cartID, _ := h.resolveCart(r, w)
|
||||||
|
if cartID > 0 {
|
||||||
|
_ = h.cart.RemoveItem(r.Context(), cartID, productID)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/cart", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) CheckoutPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cartID, _ := h.resolveCart(r, w)
|
||||||
|
ctx := r.Context()
|
||||||
|
items, _ := h.cart.Items(ctx, cartID)
|
||||||
|
if len(items) == 0 {
|
||||||
|
http.Redirect(w, r, "/cart", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, _ := h.cart.Total(ctx, cartID)
|
||||||
|
p := checkoutPage{
|
||||||
|
shopPage: h.basePage(r, w, "Оформление заказа"),
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
}
|
||||||
|
if p.User != nil {
|
||||||
|
p.Name = p.User.Name
|
||||||
|
p.Email = p.User.Email
|
||||||
|
p.Phone = p.User.Phone
|
||||||
|
p.Address = p.User.Address
|
||||||
|
}
|
||||||
|
h.render(w, "checkout.html", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
cartID, _ := h.resolveCart(r, w)
|
||||||
|
items, _ := h.cart.Items(ctx, cartID)
|
||||||
|
if len(items) == 0 {
|
||||||
|
http.Redirect(w, r, "/cart", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, _ := h.cart.Total(ctx, cartID)
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
email := r.FormValue("email")
|
||||||
|
phone := r.FormValue("phone")
|
||||||
|
address := r.FormValue("address")
|
||||||
|
|
||||||
|
p := checkoutPage{
|
||||||
|
shopPage: h.basePage(r, w, "Оформление заказа"),
|
||||||
|
Items: items, Total: total,
|
||||||
|
Name: name, Email: email, Phone: phone, Address: address,
|
||||||
|
}
|
||||||
|
if name == "" || email == "" || phone == "" || address == "" {
|
||||||
|
p.Error = "Заполните все поля доставки"
|
||||||
|
h.render(w, "checkout.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order := models.Order{
|
||||||
|
GuestName: name, GuestEmail: email, GuestPhone: phone,
|
||||||
|
Address: address, Total: total,
|
||||||
|
}
|
||||||
|
if uid, ok := auth.UserIDFromSession(h.userSession, r); ok {
|
||||||
|
order.UserID = &uid
|
||||||
|
_ = h.users.UpdateProfile(ctx, &models.User{ID: uid, Name: name, Phone: phone, Address: address})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.orders.Create(ctx, &order, items); err != nil {
|
||||||
|
log.Printf("checkout: %v", err)
|
||||||
|
p.Error = "Ошибка оформления заказа"
|
||||||
|
h.render(w, "checkout.html", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.cart.Clear(ctx, cartID)
|
||||||
|
|
||||||
|
success := h.basePage(r, w, "Заказ оформлен")
|
||||||
|
success.Success = "Заказ #" + strconv.Itoa(order.ID) + " успешно создан!"
|
||||||
|
h.render(w, "order_success.html", success)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Account(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, ok := auth.UserIDFromSession(h.userSession, r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
u, err := h.users.GetByID(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
u.Name = r.FormValue("name")
|
||||||
|
u.Phone = r.FormValue("phone")
|
||||||
|
u.Address = r.FormValue("address")
|
||||||
|
_ = h.users.UpdateProfile(ctx, &u)
|
||||||
|
}
|
||||||
|
|
||||||
|
orders, _ := h.orders.ByUser(ctx, uid)
|
||||||
|
p := accountPage{
|
||||||
|
shopPage: h.basePage(r, w, "Личный кабинет"),
|
||||||
|
Orders: orders,
|
||||||
|
Name: u.Name,
|
||||||
|
Phone: u.Phone,
|
||||||
|
Address: u.Address,
|
||||||
|
}
|
||||||
|
p.User = &u
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
p.Success = "Профиль сохранён"
|
||||||
|
}
|
||||||
|
h.render(w, "account.html", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerHandler) Health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"shop/internal/repository"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*
|
//go:embed templates/*
|
||||||
@@ -17,86 +15,23 @@ var templateFS embed.FS
|
|||||||
//go:embed static/*
|
//go:embed static/*
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
type HomeHandler struct {
|
|
||||||
repo *repository.ProductRepository
|
|
||||||
stats *repository.StatsRepository
|
|
||||||
templates *template.Template
|
|
||||||
}
|
|
||||||
|
|
||||||
type homePageData struct {
|
|
||||||
Title string
|
|
||||||
Products interface{}
|
|
||||||
Categories interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHomeHandler(repo *repository.ProductRepository, stats *repository.StatsRepository) (*HomeHandler, error) {
|
|
||||||
funcMap := template.FuncMap{
|
|
||||||
"plain": stripHTML,
|
|
||||||
}
|
|
||||||
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &HomeHandler{repo: repo, stats: stats, templates: tmpl}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
||||||
|
|
||||||
func stripHTML(s string) string {
|
func stripHTML(s string) string {
|
||||||
return tagRe.ReplaceAllString(s, "")
|
return tagRe.ReplaceAllString(s, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HomeHandler) Templates() *template.Template {
|
func ParseTemplates() (*template.Template, error) {
|
||||||
return h.templates
|
funcMap := template.FuncMap{
|
||||||
|
"plain": stripHTML,
|
||||||
|
}
|
||||||
|
return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HomeHandler) Static() http.Handler {
|
func StaticHandler() http.Handler {
|
||||||
sub, err := fs.Sub(staticFS, "static")
|
sub, err := fs.Sub(staticFS, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
return http.FileServer(http.FS(sub))
|
return http.FileServer(http.FS(sub))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HomeHandler) Home(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
if err := h.stats.IncrementHomeVisit(ctx); err != nil {
|
|
||||||
log.Printf("visit counter: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
products, err := h.repo.Featured(ctx, 8)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("featured products: %v", err)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
categories, err := h.repo.Categories(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("categories: %v", err)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := homePageData{
|
|
||||||
Title: "Shop — Интернет-магазин",
|
|
||||||
Products: products,
|
|
||||||
Categories: categories,
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := h.templates.ExecuteTemplate(w, "home.html", data); err != nil {
|
|
||||||
log.Printf("render: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HomeHandler) Health(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"status":"ok"}`))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
.shop-page {
|
||||||
|
padding: 40px 24px 80px;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-card h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-card--empty,
|
||||||
|
.shop-card--success {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-card--success h1 {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-form input,
|
||||||
|
.shop-form textarea {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-form input:focus,
|
||||||
|
.shop-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-alert--error {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-alert--ok {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-hint a {
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-layout,
|
||||||
|
.checkout-layout,
|
||||||
|
.account-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-layout {
|
||||||
|
grid-template-columns: 1fr 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-layout {
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-layout {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-layout .shop-card {
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__img {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__info h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__price {
|
||||||
|
color: #4f46e5;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__qty {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item__qty input {
|
||||||
|
width: 60px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-summary {
|
||||||
|
background: #fff;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
height: fit-content;
|
||||||
|
position: sticky;
|
||||||
|
top: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-summary h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-summary__total {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 16px 0;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__head {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__items {
|
||||||
|
list-style: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__total {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card .admin-badge,
|
||||||
|
.order-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__footer form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cart-layout,
|
||||||
|
.checkout-layout,
|
||||||
|
.account-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{{define "register.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<div class="shop-card">
|
||||||
|
<h1>Регистрация</h1>
|
||||||
|
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
|
||||||
|
<form method="POST" action="/register" class="shop-form">
|
||||||
|
<label>Имя<input type="text" name="name" required></label>
|
||||||
|
<label>Email<input type="email" name="email" required></label>
|
||||||
|
<label>Пароль (мин. 6 символов)<input type="password" name="password" required minlength="6"></label>
|
||||||
|
<button type="submit" class="btn btn--primary btn--lg">Зарегистрироваться</button>
|
||||||
|
</form>
|
||||||
|
<p class="shop-hint">Уже есть аккаунт? <a href="/login">Войти</a></p>
|
||||||
|
<p class="shop-hint">Можно покупать и <a href="/cart">без регистрации</a></p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "login.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<div class="shop-card">
|
||||||
|
<h1>Вход</h1>
|
||||||
|
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
|
||||||
|
<form method="POST" action="/login" class="shop-form">
|
||||||
|
<label>Email<input type="email" name="email" required></label>
|
||||||
|
<label>Пароль<input type="password" name="password" required></label>
|
||||||
|
<button type="submit" class="btn btn--primary btn--lg">Войти</button>
|
||||||
|
</form>
|
||||||
|
<p class="shop-hint">Нет аккаунта? <a href="/register">Регистрация</a></p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -11,38 +11,17 @@
|
|||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="header">
|
{{template "shop_header" .}}
|
||||||
<div class="container header__inner">
|
|
||||||
<a href="/" class="logo">
|
|
||||||
<span class="logo__icon">◆</span>
|
|
||||||
<span class="logo__text">Shop</span>
|
|
||||||
</a>
|
|
||||||
<nav class="nav">
|
|
||||||
<a href="#catalog" class="nav__link">Каталог</a>
|
|
||||||
<a href="#categories" class="nav__link">Категории</a>
|
|
||||||
<a href="#delivery" class="nav__link">Доставка</a>
|
|
||||||
</nav>
|
|
||||||
<div class="header__actions">
|
|
||||||
<button class="btn btn--ghost" aria-label="Поиск">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn--primary">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>
|
|
||||||
Корзина
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container hero__inner">
|
<div class="container hero__inner">
|
||||||
<div class="hero__content">
|
<div class="hero__content">
|
||||||
<span class="badge">Новая коллекция 2026</span>
|
<span class="badge">Новая коллекция 2026</span>
|
||||||
<h1 class="hero__title">Всё лучшее —<br>в одном магазине</h1>
|
<h1 class="hero__title">Всё лучшее —<br>в одном магазине</h1>
|
||||||
<p class="hero__text">Электроника, одежда, дом и сад. Быстрая доставка по всей стране, гарантия качества и удобная оплата.</p>
|
<p class="hero__text">Электроника, одежда, дом и сад. Покупайте с регистрацией или без — как вам удобно.</p>
|
||||||
<div class="hero__cta">
|
<div class="hero__cta">
|
||||||
<a href="#catalog" class="btn btn--primary btn--lg">Смотреть каталог</a>
|
<a href="#catalog" class="btn btn--primary btn--lg">Смотреть каталог</a>
|
||||||
<a href="#delivery" class="btn btn--outline btn--lg">Условия доставки</a>
|
<a href="/register" class="btn btn--outline btn--lg">Создать аккаунт</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero__visual">
|
<div class="hero__visual">
|
||||||
@@ -51,8 +30,8 @@
|
|||||||
<span class="hero__card-value">от 3 000 ₽</span>
|
<span class="hero__card-value">от 3 000 ₽</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero__card hero__card--2">
|
<div class="hero__card hero__card--2">
|
||||||
<span class="hero__card-label">Товаров в каталоге</span>
|
<span class="hero__card-label">Покупка без регистрации</span>
|
||||||
<span class="hero__card-value">10 000+</span>
|
<span class="hero__card-value">Доступна</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +57,6 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section__header">
|
<div class="section__header">
|
||||||
<h2 class="section__title">Популярные товары</h2>
|
<h2 class="section__title">Популярные товары</h2>
|
||||||
<a href="#" class="link">Весь каталог →</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="products">
|
<div class="products">
|
||||||
{{range .Products}}
|
{{range .Products}}
|
||||||
@@ -92,7 +70,11 @@
|
|||||||
<p class="product-card__desc">{{plain .Description}}</p>
|
<p class="product-card__desc">{{plain .Description}}</p>
|
||||||
<div class="product-card__footer">
|
<div class="product-card__footer">
|
||||||
<span class="product-card__price">{{printf "%.0f" .Price}} ₽</span>
|
<span class="product-card__price">{{printf "%.0f" .Price}} ₽</span>
|
||||||
<button class="btn btn--primary btn--sm">В корзину</button>
|
<form method="POST" action="/cart/add">
|
||||||
|
<input type="hidden" name="product_id" value="{{.ID}}">
|
||||||
|
<input type="hidden" name="quantity" value="1">
|
||||||
|
<button type="submit" class="btn btn--primary btn--sm">В корзину</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -110,22 +92,22 @@
|
|||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature__icon">🚚</div>
|
<div class="feature__icon">🚚</div>
|
||||||
<h3 class="feature__title">Быстрая доставка</h3>
|
<h3 class="feature__title">Быстрая доставка</h3>
|
||||||
<p class="feature__text">Доставим заказ за 1–3 дня в любой город России</p>
|
<p class="feature__text">Доставим заказ за 1–3 дня</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<div class="feature__icon">👤</div>
|
||||||
|
<h3 class="feature__title">Без регистрации</h3>
|
||||||
|
<p class="feature__text">Оформите заказ как гость — достаточно email и телефона</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature__icon">🔒</div>
|
<div class="feature__icon">🔒</div>
|
||||||
<h3 class="feature__title">Безопасная оплата</h3>
|
<h3 class="feature__title">Личный кабинет</h3>
|
||||||
<p class="feature__text">Карты, СБП и оплата при получении</p>
|
<p class="feature__text">История заказов и сохранённые данные для постоянных клиентов</p>
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature__icon">↩️</div>
|
|
||||||
<h3 class="feature__title">Лёгкий возврат</h3>
|
|
||||||
<p class="feature__text">14 дней на возврат без лишних вопросов</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature__icon">💬</div>
|
<div class="feature__icon">💬</div>
|
||||||
<h3 class="feature__title">Поддержка 24/7</h3>
|
<h3 class="feature__title">Поддержка 24/7</h3>
|
||||||
<p class="feature__text">Ответим на любой вопрос в чате или по телефону</p>
|
<p class="feature__text">Ответим на любой вопрос</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,9 +121,9 @@
|
|||||||
<p class="footer__copy">© 2026 Shop. Все права защищены.</p>
|
<p class="footer__copy">© 2026 Shop. Все права защищены.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer__links">
|
<div class="footer__links">
|
||||||
<a href="#">О компании</a>
|
<a href="/account">Личный кабинет</a>
|
||||||
<a href="#">Контакты</a>
|
<a href="/cart">Корзина</a>
|
||||||
<a href="#">Политика конфиденциальности</a>
|
<a href="/login">Вход</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{{define "shop_header"}}
|
||||||
|
<header class="header">
|
||||||
|
<div class="container header__inner">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
<span class="logo__icon">◆</span>
|
||||||
|
<span class="logo__text">Shop</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/#catalog" class="nav__link">Каталог</a>
|
||||||
|
<a href="/#categories" class="nav__link">Категории</a>
|
||||||
|
<a href="/#delivery" class="nav__link">Доставка</a>
|
||||||
|
</nav>
|
||||||
|
<div class="header__actions">
|
||||||
|
{{if .User}}
|
||||||
|
<a href="/account" class="btn btn--ghost btn--sm">👤 {{.User.Name}}</a>
|
||||||
|
{{else}}
|
||||||
|
<a href="/login" class="btn btn--ghost btn--sm">Войти</a>
|
||||||
|
<a href="/register" class="btn btn--ghost btn--sm">Регистрация</a>
|
||||||
|
{{end}}
|
||||||
|
<a href="/cart" class="btn btn--primary btn--sm">
|
||||||
|
🛒 Корзина{{if .CartCount}} ({{.CartCount}}){{end}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
{{define "cart.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<h1>Корзина</h1>
|
||||||
|
{{if .Items}}
|
||||||
|
<div class="cart-layout">
|
||||||
|
<div class="cart-items">
|
||||||
|
{{range .Items}}
|
||||||
|
<div class="cart-item">
|
||||||
|
<img src="{{.Product.ImageURL}}" alt="{{.Product.Name}}" class="cart-item__img">
|
||||||
|
<div class="cart-item__info">
|
||||||
|
<h3>{{.Product.Name}}</h3>
|
||||||
|
<p class="cart-item__price">{{printf "%.0f" .Product.Price}} ₽</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/cart/update" class="cart-item__qty">
|
||||||
|
<input type="hidden" name="product_id" value="{{.ProductID}}">
|
||||||
|
<input type="number" name="quantity" value="{{.Quantity}}" min="1" max="99">
|
||||||
|
<button type="submit" class="btn btn--ghost btn--sm">OK</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/cart/remove">
|
||||||
|
<input type="hidden" name="product_id" value="{{.ProductID}}">
|
||||||
|
<button type="submit" class="btn btn--danger btn--sm">×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<aside class="cart-summary">
|
||||||
|
<h2>Итого</h2>
|
||||||
|
<p class="cart-summary__total">{{printf "%.0f" .Total}} ₽</p>
|
||||||
|
<a href="/checkout" class="btn btn--primary btn--lg">Оформить заказ</a>
|
||||||
|
<p class="shop-hint">Регистрация не обязательна</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="shop-card shop-card--empty">
|
||||||
|
<p>Корзина пуста</p>
|
||||||
|
<a href="/" class="btn btn--primary">В каталог</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "checkout.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<h1>Оформление заказа</h1>
|
||||||
|
{{if .Error}}<div class="shop-alert shop-alert--error">{{.Error}}</div>{{end}}
|
||||||
|
<div class="checkout-layout">
|
||||||
|
<form method="POST" action="/checkout" class="shop-card shop-form">
|
||||||
|
<h2>Данные для доставки</h2>
|
||||||
|
{{if .User}}
|
||||||
|
<p class="shop-hint">Вы вошли как {{.User.Email}}. Данные подставлены из профиля.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="shop-hint">Покупка без регистрации — укажите контакты для доставки.</p>
|
||||||
|
{{end}}
|
||||||
|
<label>Имя<input type="text" name="name" value="{{.Name}}" required></label>
|
||||||
|
<label>Email<input type="email" name="email" value="{{.Email}}" required></label>
|
||||||
|
<label>Телефон<input type="tel" name="phone" value="{{.Phone}}" required></label>
|
||||||
|
<label>Адрес доставки<textarea name="address" rows="3" required>{{.Address}}</textarea></label>
|
||||||
|
<button type="submit" class="btn btn--primary btn--lg">Подтвердить заказ</button>
|
||||||
|
</form>
|
||||||
|
<aside class="cart-summary">
|
||||||
|
<h2>Ваш заказ</h2>
|
||||||
|
{{range .Items}}
|
||||||
|
<div class="checkout-item">
|
||||||
|
<span>{{.Product.Name}} × {{.Quantity}}</span>
|
||||||
|
<span>{{printf "%.0f" .Product.Price}} ₽</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<p class="cart-summary__total">Итого: {{printf "%.0f" .Total}} ₽</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "order_success.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<div class="shop-card shop-card--success">
|
||||||
|
<h1>✓ Заказ оформлен!</h1>
|
||||||
|
<p>{{.Success}}</p>
|
||||||
|
<p class="shop-hint">Мы свяжемся с вами для подтверждения доставки.</p>
|
||||||
|
<div class="shop-actions">
|
||||||
|
<a href="/" class="btn btn--primary">На главную</a>
|
||||||
|
{{if .User}}<a href="/account" class="btn btn--outline">Мои заказы</a>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "account.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/shop.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "shop_header" .}}
|
||||||
|
<main class="shop-page container">
|
||||||
|
<div class="account-header">
|
||||||
|
<h1>Личный кабинет</h1>
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<button type="submit" class="btn btn--ghost">Выйти</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Success}}<div class="shop-alert shop-alert--ok">{{.Success}}</div>{{end}}
|
||||||
|
|
||||||
|
<div class="account-layout">
|
||||||
|
<section class="shop-card">
|
||||||
|
<h2>Профиль</h2>
|
||||||
|
<p class="shop-hint">Email: {{.User.Email}}</p>
|
||||||
|
<form method="POST" action="/account" class="shop-form">
|
||||||
|
<label>Имя<input type="text" name="name" value="{{.Name}}" required></label>
|
||||||
|
<label>Телефон<input type="tel" name="phone" value="{{.Phone}}"></label>
|
||||||
|
<label>Адрес<textarea name="address" rows="2">{{.Address}}</textarea></label>
|
||||||
|
<button type="submit" class="btn btn--primary">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="shop-card">
|
||||||
|
<h2>Мои заказы</h2>
|
||||||
|
{{if .Orders}}
|
||||||
|
{{range .Orders}}
|
||||||
|
<div class="order-card">
|
||||||
|
<div class="order-card__head">
|
||||||
|
<strong>Заказ #{{.ID}}</strong>
|
||||||
|
<span>{{.CreatedAt.Format "02.01.2006 15:04"}}</span>
|
||||||
|
<span class="admin-badge admin-badge--ok">{{.Status}}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="order-card__items">
|
||||||
|
{{range .Items}}
|
||||||
|
<li>{{.ProductName}} × {{.Quantity}} — {{printf "%.0f" .Price}} ₽</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<p class="order-card__total">Итого: {{printf "%.0f" .Total}} ₽</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="shop-hint">Заказов пока нет. <a href="/">Перейти в каталог</a></p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Phone string
|
||||||
|
Address string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cart struct {
|
||||||
|
ID int
|
||||||
|
UserID *int
|
||||||
|
SessionKey string
|
||||||
|
Items []CartItem
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartItem struct {
|
||||||
|
ID int
|
||||||
|
CartID int
|
||||||
|
ProductID int
|
||||||
|
Quantity int
|
||||||
|
Product Product
|
||||||
|
}
|
||||||
|
|
||||||
|
type Order struct {
|
||||||
|
ID int
|
||||||
|
UserID *int
|
||||||
|
GuestName string
|
||||||
|
GuestEmail string
|
||||||
|
GuestPhone string
|
||||||
|
Address string
|
||||||
|
Total float64
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Items []OrderItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderItem struct {
|
||||||
|
ID int
|
||||||
|
OrderID int
|
||||||
|
ProductID int
|
||||||
|
ProductName string
|
||||||
|
Price float64
|
||||||
|
Quantity int
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"shop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CartRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCartRepository(pool *pgxpool.Pool) *CartRepository {
|
||||||
|
return &CartRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) GetOrCreate(ctx context.Context, sessionKey string, userID *int) (int, error) {
|
||||||
|
if userID != nil {
|
||||||
|
var cartID int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT id FROM carts WHERE user_id = $1`, *userID).Scan(&cartID)
|
||||||
|
if err == nil {
|
||||||
|
return cartID, nil
|
||||||
|
}
|
||||||
|
if err != pgx.ErrNoRows {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
err = r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO carts (user_id, session_key) VALUES ($1, $2) RETURNING id
|
||||||
|
`, *userID, sessionKey).Scan(&cartID)
|
||||||
|
return cartID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cartID int
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id FROM carts WHERE session_key = $1 AND user_id IS NULL
|
||||||
|
`, sessionKey).Scan(&cartID)
|
||||||
|
if err == nil {
|
||||||
|
return cartID, nil
|
||||||
|
}
|
||||||
|
if err != pgx.ErrNoRows {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
err = r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO carts (session_key) VALUES ($1) RETURNING id
|
||||||
|
`, sessionKey).Scan(&cartID)
|
||||||
|
return cartID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) MergeToUser(ctx context.Context, sessionKey string, userID int) error {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var guestCartID int
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
SELECT id FROM carts WHERE session_key = $1 AND user_id IS NULL
|
||||||
|
`, sessionKey).Scan(&guestCartID)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var userCartID int
|
||||||
|
err = tx.QueryRow(ctx, `SELECT id FROM carts WHERE user_id = $1`, userID).Scan(&userCartID)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
_, err = tx.Exec(ctx, `UPDATE carts SET user_id = $1 WHERE id = $2`, userID, guestCartID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO cart_items (cart_id, product_id, quantity)
|
||||||
|
SELECT $1, product_id, quantity FROM cart_items WHERE cart_id = $2
|
||||||
|
ON CONFLICT (cart_id, product_id) DO UPDATE
|
||||||
|
SET quantity = cart_items.quantity + EXCLUDED.quantity
|
||||||
|
`, userCartID, guestCartID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `DELETE FROM carts WHERE id = $1`, guestCartID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) AddItem(ctx context.Context, cartID, productID, qty int) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO cart_items (cart_id, product_id, quantity)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (cart_id, product_id) DO UPDATE
|
||||||
|
SET quantity = cart_items.quantity + EXCLUDED.quantity
|
||||||
|
`, cartID, productID, qty)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) UpdateItem(ctx context.Context, cartID, productID, qty int) error {
|
||||||
|
if qty <= 0 {
|
||||||
|
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2`, cartID, productID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE cart_items SET quantity = $3 WHERE cart_id = $1 AND product_id = $2
|
||||||
|
`, cartID, productID, qty)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) RemoveItem(ctx context.Context, cartID, productID int) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2`, cartID, productID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) Clear(ctx context.Context, cartID int) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1`, cartID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) ItemCount(ctx context.Context, cartID int) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(SUM(quantity), 0) FROM cart_items WHERE cart_id = $1
|
||||||
|
`, cartID).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartItem, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT ci.id, ci.cart_id, ci.product_id, ci.quantity,
|
||||||
|
p.id, p.name, p.description, p.details, p.price, p.image_url, p.category, p.is_active, p.created_at, p.updated_at
|
||||||
|
FROM cart_items ci
|
||||||
|
JOIN products p ON p.id = ci.product_id
|
||||||
|
WHERE ci.cart_id = $1 AND p.is_active = true
|
||||||
|
ORDER BY ci.id
|
||||||
|
`, cartID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []models.CartItem
|
||||||
|
for rows.Next() {
|
||||||
|
var ci models.CartItem
|
||||||
|
var p models.Product
|
||||||
|
if err := rows.Scan(
|
||||||
|
&ci.ID, &ci.CartID, &ci.ProductID, &ci.Quantity,
|
||||||
|
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ci.Product = p
|
||||||
|
items = append(items, ci)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CartRepository) Total(ctx context.Context, cartID int) (float64, error) {
|
||||||
|
var total float64
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(SUM(ci.quantity * p.price), 0)
|
||||||
|
FROM cart_items ci JOIN products p ON p.id = ci.product_id
|
||||||
|
WHERE ci.cart_id = $1
|
||||||
|
`, cartID).Scan(&total)
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"shop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrderRepository(pool *pgxpool.Pool) *OrderRepository {
|
||||||
|
return &OrderRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items []models.CartItem) error {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'new')
|
||||||
|
RETURNING id, created_at
|
||||||
|
`, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total,
|
||||||
|
).Scan(&order.ID, &order.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO order_items (order_id, product_id, product_name, price, quantity)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`, order.ID, item.ProductID, item.Product.Name, item.Product.Price, item.Quantity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrderRepository) ByUser(ctx context.Context, userID int) ([]models.Order, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at
|
||||||
|
FROM orders WHERE user_id = $1 ORDER BY created_at DESC
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var orders []models.Order
|
||||||
|
for rows.Next() {
|
||||||
|
var o models.Order
|
||||||
|
if err := rows.Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
o.Items, err = r.items(ctx, o.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
orders = append(orders, o)
|
||||||
|
}
|
||||||
|
return orders, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrderRepository) items(ctx context.Context, orderID int) ([]models.OrderItem, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, order_id, product_id, product_name, price, quantity
|
||||||
|
FROM order_items WHERE order_id = $1
|
||||||
|
`, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []models.OrderItem
|
||||||
|
for rows.Next() {
|
||||||
|
var i models.OrderItem
|
||||||
|
if err := rows.Scan(&i.ID, &i.OrderID, &i.ProductID, &i.ProductName, &i.Price, &i.Quantity); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"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, password, name string) (models.User, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return models.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var u models.User
|
||||||
|
err = r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO users (email, password_hash, name)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, email, name, phone, address, created_at
|
||||||
|
`, email, string(hash), name).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Authenticate(ctx context.Context, email, password string) (models.User, error) {
|
||||||
|
var hash string
|
||||||
|
var u models.User
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, phone, address, created_at, password_hash
|
||||||
|
FROM users WHERE email = $1
|
||||||
|
`, email).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt, &hash)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return models.User{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return models.User{}, err
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||||
|
return models.User{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByID(ctx context.Context, id int) (models.User, error) {
|
||||||
|
var u models.User
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, phone, address, created_at FROM users WHERE id = $1
|
||||||
|
`, id).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) UpdateProfile(ctx context.Context, u *models.User) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET name = $1, phone = $2, address = $3 WHERE id = $4
|
||||||
|
`, u.Name, u.Phone, u.Address, u.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- Пользователи, корзина, заказы
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS carts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
session_key VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_user ON carts (user_id) WHERE user_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_session ON carts (session_key) WHERE user_id IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cart_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
cart_id INT NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
|
||||||
|
product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
UNIQUE (cart_id, product_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
guest_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
guest_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
guest_phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
total NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'new',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
product_name VARCHAR(255) NOT NULL,
|
||||||
|
price NUMERIC(10, 2) NOT NULL,
|
||||||
|
quantity INT NOT NULL CHECK (quantity > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id);
|
||||||
Reference in New Issue
Block a user