From 6e9c6e796c24807db975a92069d57bc440bd7526 Mon Sep 17 00:00:00 2001 From: Triton Date: Mon, 14 Sep 2026 03:41:27 +0500 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=85=D0=BE=D0=B4/=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20(cookie-auth?= =?UTF-8?q?)=20+=20=D0=BA=D0=BE=D1=80=D0=B7=D0=B8=D0=BD=D0=B0=20(=D1=81?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D1=8F)=20+=20=D0=BE=D1=82=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=D1=8B=20=D0=BE=D1=82=20=D0=BA=D1=80=D0=B0=D1=91?= =?UTF-8?q?=D0=B2=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Controllers/AccountController.cs | 113 ++++++++++++++++++ Controllers/CartController.cs | 65 +++++++++++ Data/UserStore.cs | 51 ++++++++ Models/AppUser.cs | 8 ++ Models/CartItem.cs | 10 ++ Models/LoginViewModel.cs | 19 +++ Models/RegisterViewModel.cs | 28 +++++ Program.cs | 29 ++++- README.md | 17 +++ Services/CartService.cs | 115 ++++++++++++++++++ Views/Account/Login.cshtml | 47 ++++++++ Views/Account/Profile.cshtml | 26 +++++ Views/Account/Register.cshtml | 51 ++++++++ Views/Cart/Index.cshtml | 85 ++++++++++++++ Views/Shared/_Layout.cshtml | 35 ++++-- Views/Shared/_ProductCard.cshtml | 5 +- wwwroot/css/site.css | 192 ++++++++++++++++++++++++++++++- 17 files changed, 883 insertions(+), 13 deletions(-) create mode 100644 Controllers/AccountController.cs create mode 100644 Controllers/CartController.cs create mode 100644 Data/UserStore.cs create mode 100644 Models/AppUser.cs create mode 100644 Models/CartItem.cs create mode 100644 Models/LoginViewModel.cs create mode 100644 Models/RegisterViewModel.cs create mode 100644 Services/CartService.cs create mode 100644 Views/Account/Login.cshtml create mode 100644 Views/Account/Profile.cshtml create mode 100644 Views/Account/Register.cshtml create mode 100644 Views/Cart/Index.cshtml diff --git a/Controllers/AccountController.cs b/Controllers/AccountController.cs new file mode 100644 index 0000000..2615a29 --- /dev/null +++ b/Controllers/AccountController.cs @@ -0,0 +1,113 @@ +using System.Security.Claims; + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +using TechnoWorld.Data; +using TechnoWorld.Models; + +namespace TechnoWorld.Controllers; + +public class AccountController : Controller +{ + [HttpGet] + public IActionResult Login(string? returnUrl = null) + { + ViewData["Title"] = "Вход"; + ViewData["ReturnUrl"] = returnUrl; + return View(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Login(LoginViewModel model, string? returnUrl = null) + { + ViewData["Title"] = "Вход"; + + if (ModelState.IsValid) + { + var user = UserStore.Validate(model.Login, model.Password); + if (user != null) + { + await SignInAsync(user, model.RememberMe); + return Redirect(Url.IsLocalUrl(returnUrl) ? returnUrl : "/"); + } + + ModelState.AddModelError(string.Empty, "Неверный логин или пароль"); + } + + ViewData["ReturnUrl"] = returnUrl; + return View(model); + } + + [HttpGet] + public IActionResult Register() + { + ViewData["Title"] = "Регистрация"; + return View(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Register(RegisterViewModel model) + { + ViewData["Title"] = "Регистрация"; + + if (ModelState.IsValid) + { + var ok = UserStore.Register(model.Login, model.Name, model.Password); + if (ok) + { + var user = UserStore.Validate(model.Login, model.Password); + if (user != null) + { + await SignInAsync(user, false); + } + return RedirectToAction(nameof(Profile)); + } + + ModelState.AddModelError(nameof(RegisterViewModel.Login), "Логин уже занят"); + } + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Logout() + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return RedirectToAction("Index", "Home"); + } + + [HttpGet] + [Authorize] + public IActionResult Profile() + { + ViewData["Title"] = "Мой аккаунт"; + return View(); + } + + private async Task SignInAsync(AppUser user, bool remember) + { + var claims = new List + { + new(ClaimTypes.Name, user.Login), + new(ClaimTypes.GivenName, user.Name) + }; + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + principal, + new AuthenticationProperties + { + IsPersistent = remember, + ExpiresUtc = remember ? DateTimeOffset.UtcNow.AddDays(7) : null + }); + } +} \ No newline at end of file diff --git a/Controllers/CartController.cs b/Controllers/CartController.cs new file mode 100644 index 0000000..5245697 --- /dev/null +++ b/Controllers/CartController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Mvc; + +using TechnoWorld.Services; + +namespace TechnoWorld.Controllers; + +public class CartController : Controller +{ + private readonly CartService _cart; + + public CartController(CartService cart) + { + _cart = cart; + } + + [HttpGet] + public IActionResult Index() + { + ViewData["Title"] = "Корзина"; + var items = _cart.GetItems(); + ViewData["Total"] = _cart.GetTotal(); + return View(items); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult Add(int productId) + { + _cart.Add(productId); + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult Update(int productId, int quantity) + { + _cart.SetQuantity(productId, quantity); + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult Remove(int productId) + { + _cart.Remove(productId); + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult Clear() + { + _cart.Clear(); + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult Checkout() + { + _cart.Clear(); + TempData["OrderSuccess"] = "Заказ оформлен! Мы свяжемся с вами для подтверждения."; + return RedirectToAction(nameof(Index)); + } +} \ No newline at end of file diff --git a/Data/UserStore.cs b/Data/UserStore.cs new file mode 100644 index 0000000..8f54293 --- /dev/null +++ b/Data/UserStore.cs @@ -0,0 +1,51 @@ +using System.Security.Cryptography; + +using TechnoWorld.Models; + +namespace TechnoWorld.Data; + +public static class UserStore +{ + private static readonly List Users = []; + + public static bool Register(string login, string name, string password) + { + login = login.Trim(); + if (string.IsNullOrWhiteSpace(login) || Find(login) != null) + { + return false; + } + + Users.Add(new AppUser + { + Login = login, + Name = name.Trim(), + PasswordHash = Hash(password) + }); + + return true; + } + + public static AppUser? Validate(string login, string password) + { + var user = Find(login.Trim()); + if (user == null || user.PasswordHash != Hash(password)) + { + return null; + } + + return user; + } + + private static AppUser? Find(string login) + { + return Users.FirstOrDefault(u => + u.Login.Equals(login, StringComparison.OrdinalIgnoreCase)); + } + + private static string Hash(string value) + { + var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value)); + return Convert.ToHexString(bytes); + } +} \ No newline at end of file diff --git a/Models/AppUser.cs b/Models/AppUser.cs new file mode 100644 index 0000000..2e1800a --- /dev/null +++ b/Models/AppUser.cs @@ -0,0 +1,8 @@ +namespace TechnoWorld.Models; + +public class AppUser +{ + public string Login { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Models/CartItem.cs b/Models/CartItem.cs new file mode 100644 index 0000000..3d9461d --- /dev/null +++ b/Models/CartItem.cs @@ -0,0 +1,10 @@ +namespace TechnoWorld.Models; + +public class CartItem +{ + public int ProductId { get; set; } + public Product Product { get; set; } = new(); + public int Quantity { get; set; } + + public decimal Subtotal => Product.Price * Quantity; +} \ No newline at end of file diff --git a/Models/LoginViewModel.cs b/Models/LoginViewModel.cs new file mode 100644 index 0000000..46b1fe9 --- /dev/null +++ b/Models/LoginViewModel.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace TechnoWorld.Models; + +public class LoginViewModel +{ + [Required(ErrorMessage = "Введите логин")] + [StringLength(50, ErrorMessage = "Логин слишком длинный")] + [Display(Name = "Логин")] + public string Login { get; set; } = string.Empty; + + [Required(ErrorMessage = "Введите пароль")] + [DataType(DataType.Password)] + [Display(Name = "Пароль")] + public string Password { get; set; } = string.Empty; + + [Display(Name = "Запомнить меня")] + public bool RememberMe { get; set; } +} \ No newline at end of file diff --git a/Models/RegisterViewModel.cs b/Models/RegisterViewModel.cs new file mode 100644 index 0000000..d5645c9 --- /dev/null +++ b/Models/RegisterViewModel.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace TechnoWorld.Models; + +public class RegisterViewModel +{ + [Required(ErrorMessage = "Введите имя")] + [StringLength(100, ErrorMessage = "Имя слишком длинное")] + [Display(Name = "Имя")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "Введите логин")] + [StringLength(50, ErrorMessage = "Логин слишком длинный")] + [Display(Name = "Логин")] + public string Login { get; set; } = string.Empty; + + [Required(ErrorMessage = "Введите пароль")] + [StringLength(100, MinimumLength = 4, ErrorMessage = "Пароль должен быть не короче 4 символов")] + [DataType(DataType.Password)] + [Display(Name = "Пароль")] + public string Password { get; set; } = string.Empty; + + [Required(ErrorMessage = "Повторите пароль")] + [DataType(DataType.Password)] + [Compare(nameof(Password), ErrorMessage = "Пароли не совпадают")] + [Display(Name = "Подтверждение пароля")] + public string ConfirmPassword { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index cf7a41a..71a6384 100644 --- a/Program.cs +++ b/Program.cs @@ -1,10 +1,31 @@ - +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; +using TechnoWorld.Services; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/Account/Login"; + options.LogoutPath = "/Account/Logout"; + options.AccessDeniedPath = "/Account/Login"; + options.ExpireTimeSpan = TimeSpan.FromDays(7); + }); + +builder.Services.AddSession(options => +{ + options.IdleTimeout = TimeSpan.FromDays(1); + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; +}); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) @@ -21,12 +42,16 @@ app.UseStaticFiles(); app.UseRouting(); +app.UseAuthentication(); + app.UseAuthorization(); +app.UseSession(); + app.UseStatusCodePagesWithReExecute("/Home/Error", "?statusCode={0}"); app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); -app.Run(); +app.Run(); \ No newline at end of file diff --git a/README.md b/README.md index 9c078d7..3b9b2b3 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ ASP.NET Core MVC проект интернет-магазина техники. |----------------|-----------------------------------------------------------------| | **Главная** | Промо-баннер, преимущества магазина, хиты продаж (из данных), CTA | | **Каталог** | 14 товаров из данных, фильтр по 5 категориям, поиск | +| **Корзина** | Добавление, изменение количества, удаление, очистка, оформление | +| **Вход** | Авторизация по cookie, регистрация, личный кабинет | | **О системе** | Описание сервиса, роли пользователей, план развития | | **Контакты** | Контактная информация, форма обратной связи с валидацией | | **404** | Страница ошибки с кнопкой возврата на главную | @@ -28,6 +30,21 @@ ASP.NET Core MVC проект интернет-магазина техники. - Поиск по названию и характеристикам: `Catalog/Index?q=...` - Общая частичная карточка `_ProductCard.cshtml` для каталога и хитов +### Корзина + +- Хранение — сессия `CartService` (`Services/CartService.cs`), данные в JSON +- Кнопка «Купить» на карточке добавляет товар; счётчик в шапке обновляется +- Страница `Cart/Index`: изменение количества, удаление позиции, очистка +- Оформление заказа очищает корзину и показывает подтверждение через `TempData` + +### Вход и аккаунт + +- Cookie-аутентификация (Microsoft.AspNetCore.Authentication.Cookies) +- `Account/Login` и `Account/Register` — авторизация и регистрация с валидацией +- Пользователи хранятся в памяти (`Data/UserStore`), пароли — SHA-256 +- Личный кабинет `Account/Profile` (только для авторизованных) +- Шапка: показывает логин + «Выйти» для вошедших, «Войти» для гостей + ### Технический стек - **ASP.NET Core MVC** (.NET 10) diff --git a/Services/CartService.cs b/Services/CartService.cs new file mode 100644 index 0000000..f883f60 --- /dev/null +++ b/Services/CartService.cs @@ -0,0 +1,115 @@ +using System.Text.Json; + +using TechnoWorld.Data; +using TechnoWorld.Models; + +namespace TechnoWorld.Services; + +public class CartService +{ + private const string SessionKey = "CartItems"; + private readonly IHttpContextAccessor _http; + + public CartService(IHttpContextAccessor http) + { + _http = http; + } + + public List GetItems() + { + var ids = GetIds(); + var items = new List(); + + foreach (var (productId, quantity) in ids) + { + var product = ProductStore.Products.FirstOrDefault(p => p.Id == productId); + if (product == null) + { + continue; + } + + items.Add(new CartItem { ProductId = productId, Product = product, Quantity = quantity }); + } + + return items; + } + + public int GetCount() + { + return GetIds().Values.Sum(); + } + + public decimal GetTotal() + { + return GetItems().Sum(i => i.Subtotal); + } + + public void Add(int productId, int quantity = 1) + { + var ids = GetIds(); + ids[productId] = ids.GetValueOrDefault(productId) + quantity; + Save(ids); + } + + public void SetQuantity(int productId, int quantity) + { + var ids = GetIds(); + if (quantity <= 0) + { + ids.Remove(productId); + } + else + { + ids[productId] = quantity; + } + + Save(ids); + } + + public void Remove(int productId) + { + var ids = GetIds(); + ids.Remove(productId); + Save(ids); + } + + public void Clear() + { + _http.HttpContext?.Session.Remove(SessionKey); + } + + private Dictionary GetIds() + { + var session = _http.HttpContext?.Session; + if (session == null) + { + return new Dictionary(); + } + + var json = session.GetString(SessionKey); + if (string.IsNullOrEmpty(json)) + { + return new Dictionary(); + } + + try + { + return JsonSerializer.Deserialize>(json) ?? new Dictionary(); + } + catch + { + return new Dictionary(); + } + } + + private void Save(Dictionary ids) + { + var session = _http.HttpContext?.Session; + if (session == null) + { + return; + } + + session.SetString(SessionKey, JsonSerializer.Serialize(ids)); + } +} \ No newline at end of file diff --git a/Views/Account/Login.cshtml b/Views/Account/Login.cshtml new file mode 100644 index 0000000..50327bf --- /dev/null +++ b/Views/Account/Login.cshtml @@ -0,0 +1,47 @@ +@model LoginViewModel +@{ + ViewData["Title"] = "Вход"; + var returnUrl = (string?)ViewData["ReturnUrl"]; +} + +
+ Аккаунт +

Вход в личный кабинет

+

Войдите, чтобы оформлять заказы быстрее.

+
+ +
+
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ + + +

+ Нет аккаунта? + Зарегистрироваться +

+
+
+ +@section Scripts { + +} \ No newline at end of file diff --git a/Views/Account/Profile.cshtml b/Views/Account/Profile.cshtml new file mode 100644 index 0000000..2eb67c6 --- /dev/null +++ b/Views/Account/Profile.cshtml @@ -0,0 +1,26 @@ +@{ + ViewData["Title"] = "Мой аккаунт"; +} + +
+ Аккаунт +

Мой аккаунт

+

Привет, @User.Identity?.Name! Вы вошли в личный кабинет.

+
+ +
+
+ Логин + @User.Identity?.Name +
+
+ Имя + + @User.FindFirst(System.Security.Claims.ClaimTypes.GivenName)?.Value + +
+ +
+ +
+
\ No newline at end of file diff --git a/Views/Account/Register.cshtml b/Views/Account/Register.cshtml new file mode 100644 index 0000000..df8c8a3 --- /dev/null +++ b/Views/Account/Register.cshtml @@ -0,0 +1,51 @@ +@model RegisterViewModel +@{ + ViewData["Title"] = "Регистрация"; +} + +
+ Аккаунт +

Регистрация

+

Создайте аккаунт, чтобы оформлять заказы.

+
+ +
+
+
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + + +

+ Уже есть аккаунт? + Войти +

+
+
+ +@section Scripts { + +} \ No newline at end of file diff --git a/Views/Cart/Index.cshtml b/Views/Cart/Index.cshtml new file mode 100644 index 0000000..62fd29c --- /dev/null +++ b/Views/Cart/Index.cshtml @@ -0,0 +1,85 @@ +@model List +@{ + ViewData["Title"] = "Корзина"; + var total = (decimal?)ViewData["Total"] ?? 0m; +} + +
+ Корзина +

Корзина

+

@(Model.Count == 0 ? "Ваша корзина пуста" : Model.Count + (Model.Count == 1 ? " позиция" : Model.Count < 5 ? " позиции" : " позиций"))

+
+ +@if (TempData["OrderSuccess"] != null) +{ + +} + +@if (Model.Count == 0) +{ +
+ +

В корзине пока пусто

+

Загляните в каталог и выберите то, что нравится.

+ Перейти в каталог +
+} +else +{ +
+ @foreach (var item in Model) + { +
+
+
+
@item.Product.Name
+

@item.Product.Specs

+
+ @item.Product.Price.ToString("N0") ₽ + @if (item.Quantity > 1) + { + = @item.Subtotal.ToString("N0") ₽ + } +
+
+ +
+ + + +
+ +
+ + +
+
+ } +
+ +
+
+ Итого: @total.ToString("N0") ₽ +
+ +
+
+ +
+ +
+ +
+
+
+} \ No newline at end of file diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml index 5b95df9..b22a83f 100644 --- a/Views/Shared/_Layout.cshtml +++ b/Views/Shared/_Layout.cshtml @@ -11,6 +11,8 @@ } } +@inject TechnoWorld.Services.CartService CartService + @@ -104,15 +106,32 @@ ☾ - - - - Войти - + @if (User.Identity?.IsAuthenticated == true) + { + + + @User.Identity.Name + +
+ +
+ } + else + { + + + Войти + + } - - - + + + + @CartService.GetCount() + Корзина diff --git a/Views/Shared/_ProductCard.cshtml b/Views/Shared/_ProductCard.cshtml index 4250eb3..147b25a 100644 --- a/Views/Shared/_ProductCard.cshtml +++ b/Views/Shared/_ProductCard.cshtml @@ -13,7 +13,10 @@ \ No newline at end of file diff --git a/wwwroot/css/site.css b/wwwroot/css/site.css index 54572d4..511aba0 100644 --- a/wwwroot/css/site.css +++ b/wwwroot/css/site.css @@ -81,6 +81,12 @@ body { flex-direction: column; min-height: 100vh; overflow-x: hidden; + padding: 0 clamp(0.5rem, 1.6vw, 1.75rem); +} + +.container { + padding-right: max(var(--bs-gutter-x, 1.5rem) * 0.5, 0.75rem); + padding-left: max(var(--bs-gutter-x, 1.5rem) * 0.5, 0.75rem); } .site-main { @@ -92,6 +98,9 @@ body { background: var(--ink); color: var(--ink-text); font-size: 0.82rem; + margin-top: clamp(0.5rem, 1.2vw, 1.25rem); + border-radius: var(--radius); + box-shadow: 0 2px 10px rgba(20, 30, 55, 0.06); } .topbar__inner { @@ -99,7 +108,7 @@ body { align-items: center; justify-content: space-between; gap: 1.5rem; - padding: 0.45rem 0; + padding: 0.45rem 1rem; } .topbar__item { @@ -130,13 +139,15 @@ body { background: var(--bg-surface); border-bottom: 1px solid var(--border); box-shadow: 0 2px 10px rgba(20, 30, 55, 0.05); + border-radius: var(--radius); + margin-top: 0.5rem; } .header-main { display: flex; align-items: center; gap: 1.25rem; - padding: 0.75rem 0; + padding: 0.75rem 1rem; } .navbar-brand { @@ -272,6 +283,8 @@ body { border-bottom: 1px solid var(--border); font-size: 0.92rem; font-weight: 600; + border-radius: var(--radius); + margin-top: 0.5rem; } .category-nav__inner { @@ -281,6 +294,7 @@ body { overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; + padding: 0 0.75rem; } .category-nav__inner::-webkit-scrollbar { @@ -826,6 +840,9 @@ body { color: var(--ink-text); padding: 2.25rem 0 0; margin-top: 1rem; + margin-bottom: clamp(0.5rem, 1.2vw, 1.25rem); + border-radius: var(--radius); + overflow: hidden; } .footer-logo { @@ -1103,6 +1120,177 @@ html[data-theme="dark"] .btn-primary:hover { margin-bottom: 1.2rem; } +.header-action__badge-wrap { + position: relative; + display: inline-flex; +} + +.header-action__count { + position: absolute; + top: -6px; + right: -10px; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 999px; + background: var(--accent); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +.header-logout { + margin: 0; +} + +.header-logout button { + background: none; + border: 0; + cursor: pointer; +} + +.auth-panel { + max-width: 460px; + margin: 0 auto; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.75rem; +} + +.auth-switch { + margin: 1rem 0 0; + text-align: center; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.profile-row { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); +} + +.profile-row:last-of-type { + border-bottom: 0; +} + +.profile-row__label { + color: var(--text-secondary); +} + +.profile-row__value { + font-weight: 600; + color: var(--text-primary); +} + +.cart-table { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + margin-bottom: 1rem; +} + +.cart-row { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); +} + +.cart-row:last-child { + border-bottom: 0; +} + +.cart-row__media { + width: 52px; + height: 52px; + flex: 0 0 52px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + background: var(--bg-soft); + color: var(--accent); + font-size: 1.5rem; +} + +.cart-row__info { + flex: 1 1 auto; + min-width: 0; +} + +.cart-row__name { + margin: 0; + font-size: 1rem; +} + +.cart-row__specs { + margin: 0.15rem 0 0; + color: var(--text-secondary); + font-size: 0.85rem; +} + +.cart-row__price { + margin-top: 0.25rem; + font-weight: 700; +} + +.cart-row__subtotal { + color: var(--text-secondary); + font-weight: 400; +} + +.cart-row__qty { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.cart-row__qty input { + width: 72px; +} + +.btn-remove { + background: none; + border: 0; + color: var(--text-secondary); + font-size: 1.1rem; + cursor: pointer; + padding: 0.35rem; +} + +.btn-remove:hover { + color: var(--danger); +} + +.cart-summary { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.25rem; +} + +.cart-summary__total { + font-size: 1.1rem; +} + +.cart-summary__actions { + display: flex; + gap: 0.6rem; +} + @media (max-width: 992px) { .header-main {