Add user auth, cart, checkout and personal account for guests and registered users

This commit is contained in:
shop
2026-06-25 17:07:02 +03:00
parent 08727a1995
commit efc95d48c4
16 changed files with 1528 additions and 159 deletions
+377
View File
@@ -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"}`))
}
+6 -71
View File
@@ -7,8 +7,6 @@ import (
"log"
"net/http"
"regexp"
"shop/internal/repository"
)
//go:embed templates/*
@@ -17,86 +15,23 @@ var templateFS embed.FS
//go:embed static/*
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(`<[^>]*>`)
func stripHTML(s string) string {
return tagRe.ReplaceAllString(s, "")
}
func (h *HomeHandler) Templates() *template.Template {
return h.templates
func ParseTemplates() (*template.Template, error) {
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")
if err != nil {
log.Fatal(err)
}
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"}`))
}
+263
View File
@@ -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;
}
}
+57
View File
@@ -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}}
+22 -40
View File
@@ -11,38 +11,17 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<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">
<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>
{{template "shop_header" .}}
<section class="hero">
<div class="container hero__inner">
<div class="hero__content">
<span class="badge">Новая коллекция 2026</span>
<h1 class="hero__title">Всё лучшее —<br>в одном магазине</h1>
<p class="hero__text">Электроника, одежда, дом и сад. Быстрая доставка по всей стране, гарантия качества и удобная оплата.</p>
<p class="hero__text">Электроника, одежда, дом и сад. Покупайте с регистрацией или без — как вам удобно.</p>
<div class="hero__cta">
<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 class="hero__visual">
@@ -51,8 +30,8 @@
<span class="hero__card-value">от 3 000 ₽</span>
</div>
<div class="hero__card hero__card--2">
<span class="hero__card-label">Товаров в каталоге</span>
<span class="hero__card-value">10 000+</span>
<span class="hero__card-label">Покупка без регистрации</span>
<span class="hero__card-value">Доступна</span>
</div>
</div>
</div>
@@ -78,7 +57,6 @@
<div class="container">
<div class="section__header">
<h2 class="section__title">Популярные товары</h2>
<a href="#" class="link">Весь каталог →</a>
</div>
<div class="products">
{{range .Products}}
@@ -92,7 +70,11 @@
<p class="product-card__desc">{{plain .Description}}</p>
<div class="product-card__footer">
<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>
</article>
@@ -110,22 +92,22 @@
<div class="feature">
<div class="feature__icon">🚚</div>
<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 class="feature">
<div class="feature__icon">🔒</div>
<h3 class="feature__title">Безопасная оплата</h3>
<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>
<h3 class="feature__title">Личный кабинет</h3>
<p class="feature__text">История заказов и сохранённые данные для постоянных клиентов</p>
</div>
<div class="feature">
<div class="feature__icon">💬</div>
<h3 class="feature__title">Поддержка 24/7</h3>
<p class="feature__text">Ответим на любой вопрос в чате или по телефону</p>
<p class="feature__text">Ответим на любой вопрос</p>
</div>
</div>
</div>
@@ -139,9 +121,9 @@
<p class="footer__copy">© 2026 Shop. Все права защищены.</p>
</div>
<div class="footer__links">
<a href="#">О компании</a>
<a href="#">Контакты</a>
<a href="#">Политика конфиденциальности</a>
<a href="/account">Личный кабинет</a>
<a href="/cart">Корзина</a>
<a href="/login">Вход</a>
</div>
</div>
</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}}
+187
View File
@@ -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}}