Вход/регистрация (cookie-auth) + корзина (сессия) + отступы от краёв экрана

This commit is contained in:
Triton 2026-09-14 03:41:27 +05:00
parent 27f9495a65
commit 6e9c6e796c
17 changed files with 883 additions and 13 deletions

View File

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<Claim>
{
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
});
}
}

View File

@ -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));
}
}

51
Data/UserStore.cs Normal file
View File

@ -0,0 +1,51 @@
using System.Security.Cryptography;
using TechnoWorld.Models;
namespace TechnoWorld.Data;
public static class UserStore
{
private static readonly List<AppUser> 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);
}
}

8
Models/AppUser.cs Normal file
View File

@ -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;
}

10
Models/CartItem.cs Normal file
View File

@ -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;
}

19
Models/LoginViewModel.cs Normal file
View File

@ -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; }
}

View File

@ -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;
}

View File

@ -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<CartService>();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
@ -21,8 +42,12 @@ app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseSession();
app.UseStatusCodePagesWithReExecute("/Home/Error", "?statusCode={0}");
app.MapControllerRoute(

View File

@ -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)

115
Services/CartService.cs Normal file
View File

@ -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<CartItem> GetItems()
{
var ids = GetIds();
var items = new List<CartItem>();
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<int, int> GetIds()
{
var session = _http.HttpContext?.Session;
if (session == null)
{
return new Dictionary<int, int>();
}
var json = session.GetString(SessionKey);
if (string.IsNullOrEmpty(json))
{
return new Dictionary<int, int>();
}
try
{
return JsonSerializer.Deserialize<Dictionary<int, int>>(json) ?? new Dictionary<int, int>();
}
catch
{
return new Dictionary<int, int>();
}
}
private void Save(Dictionary<int, int> ids)
{
var session = _http.HttpContext?.Session;
if (session == null)
{
return;
}
session.SetString(SessionKey, JsonSerializer.Serialize(ids));
}
}

View File

@ -0,0 +1,47 @@
@model LoginViewModel
@{
ViewData["Title"] = "Вход";
var returnUrl = (string?)ViewData["ReturnUrl"];
}
<div class="page-head">
<span class="section-overline">Аккаунт</span>
<h1 class="page-title">Вход в личный кабинет</h1>
<p class="page-subtitle">Войдите, чтобы оформлять заказы быстрее.</p>
</div>
<div class="auth-panel">
<form asp-action="Login" asp-controller="Account" method="post">
<input type="hidden" name="returnUrl" value="@returnUrl" />
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<div class="mb-3">
<label asp-for="Login" class="form-label"></label>
<input asp-for="Login" class="form-control" autocomplete="username" placeholder="Введите логин" />
<span asp-validation-for="Login" class="field-validation-error"></span>
</div>
<div class="mb-3">
<label asp-for="Password" class="form-label"></label>
<input asp-for="Password" class="form-control" autocomplete="current-password" placeholder="Введите пароль" />
<span asp-validation-for="Password" class="field-validation-error"></span>
</div>
<div class="form-check mb-3">
<input asp-for="RememberMe" class="form-check-input" />
<label asp-for="RememberMe" class="form-check-label"></label>
</div>
<button type="submit" class="btn btn-primary w-100">Войти</button>
<p class="auth-switch">
Нет аккаунта?
<a asp-action="Register" asp-controller="Account">Зарегистрироваться</a>
</p>
</form>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,26 @@
@{
ViewData["Title"] = "Мой аккаунт";
}
<div class="page-head">
<span class="section-overline">Аккаунт</span>
<h1 class="page-title">Мой аккаунт</h1>
<p class="page-subtitle">Привет, @User.Identity?.Name! Вы вошли в личный кабинет.</p>
</div>
<div class="auth-panel">
<div class="profile-row">
<span class="profile-row__label">Логин</span>
<span class="profile-row__value">@User.Identity?.Name</span>
</div>
<div class="profile-row">
<span class="profile-row__label">Имя</span>
<span class="profile-row__value">
@User.FindFirst(System.Security.Claims.ClaimTypes.GivenName)?.Value
</span>
</div>
<form asp-action="Logout" asp-controller="Account" method="post" class="mt-3">
<button type="submit" class="btn btn-outline-danger">Выйти из аккаунта</button>
</form>
</div>

View File

@ -0,0 +1,51 @@
@model RegisterViewModel
@{
ViewData["Title"] = "Регистрация";
}
<div class="page-head">
<span class="section-overline">Аккаунт</span>
<h1 class="page-title">Регистрация</h1>
<p class="page-subtitle">Создайте аккаунт, чтобы оформлять заказы.</p>
</div>
<div class="auth-panel">
<form asp-action="Register" asp-controller="Account" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<div class="mb-3">
<label asp-for="Name" class="form-label"></label>
<input asp-for="Name" class="form-control" autocomplete="name" placeholder="Иван Иванов" />
<span asp-validation-for="Name" class="field-validation-error"></span>
</div>
<div class="mb-3">
<label asp-for="Login" class="form-label"></label>
<input asp-for="Login" class="form-control" autocomplete="username" placeholder="ivan" />
<span asp-validation-for="Login" class="field-validation-error"></span>
</div>
<div class="mb-3">
<label asp-for="Password" class="form-label"></label>
<input asp-for="Password" class="form-control" autocomplete="new-password" placeholder="Минимум 4 символа" />
<span asp-validation-for="Password" class="field-validation-error"></span>
</div>
<div class="mb-3">
<label asp-for="ConfirmPassword" class="form-label"></label>
<input asp-for="ConfirmPassword" class="form-control" autocomplete="new-password" placeholder="Повторите пароль" />
<span asp-validation-for="ConfirmPassword" class="field-validation-error"></span>
</div>
<button type="submit" class="btn btn-primary w-100">Создать аккаунт</button>
<p class="auth-switch">
Уже есть аккаунт?
<a asp-action="Login" asp-controller="Account">Войти</a>
</p>
</form>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

85
Views/Cart/Index.cshtml Normal file
View File

@ -0,0 +1,85 @@
@model List<CartItem>
@{
ViewData["Title"] = "Корзина";
var total = (decimal?)ViewData["Total"] ?? 0m;
}
<div class="page-head">
<span class="section-overline">Корзина</span>
<h1 class="page-title">Корзина</h1>
<p class="page-subtitle">@(Model.Count == 0 ? "Ваша корзина пуста" : Model.Count + (Model.Count == 1 ? " позиция" : Model.Count < 5 ? " позиции" : " позиций"))</p>
</div>
@if (TempData["OrderSuccess"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@TempData["OrderSuccess"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
</div>
}
@if (Model.Count == 0)
{
<div class="catalog-empty">
<span class="catalog-empty__icon"><i class="bi bi-cart3"></i></span>
<h2 class="catalog-empty__title">В корзине пока пусто</h2>
<p class="catalog-empty__text">Загляните в каталог и выберите то, что нравится.</p>
<a class="btn btn-primary" asp-controller="Catalog" asp-action="Index">Перейти в каталог</a>
</div>
}
else
{
<div class="cart-table">
@foreach (var item in Model)
{
<div class="cart-row">
<div class="cart-row__media"><i class="bi @item.Product.Icon"></i></div>
<div class="cart-row__info">
<h5 class="cart-row__name">@item.Product.Name</h5>
<p class="cart-row__specs">@item.Product.Specs</p>
<div class="cart-row__price">
@item.Product.Price.ToString("N0") &#8381;
@if (item.Quantity > 1)
{
<span class="cart-row__subtotal">=&nbsp;@item.Subtotal.ToString("N0") &#8381;</span>
}
</div>
</div>
<form class="cart-row__qty" asp-action="Update" asp-controller="Cart" method="post">
<input type="hidden" name="productId" value="@item.ProductId" />
<input type="number" name="quantity" value="@item.Quantity" min="1" max="99"
class="form-control form-control-sm" aria-label="Количество" />
<button type="submit" class="btn btn-outline-secondary btn-sm">Обновить</button>
</form>
<form asp-action="Remove" asp-controller="Cart" method="post">
<input type="hidden" name="productId" value="@item.ProductId" />
<button type="submit" class="btn-remove" title="Удалить" aria-label="Удалить">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
}
</div>
<div class="cart-summary">
<div class="cart-summary__total">
Итого: <strong>@total.ToString("N0") &#8381;</strong>
</div>
<div class="cart-summary__actions">
<form asp-action="Clear" asp-controller="Cart" method="post">
<button type="submit" class="btn btn-outline-secondary">
<i class="bi bi-trash"></i> Очистить корзину
</button>
</form>
<form asp-action="Checkout" asp-controller="Cart" method="post">
<button type="submit" class="btn btn-primary">
<i class="bi bi-bag-check"></i> Оформить заказ
</button>
</form>
</div>
</div>
}

View File

@ -11,6 +11,8 @@
}
}
@inject TechnoWorld.Services.CartService CartService
<!DOCTYPE html>
<html lang="ru">
<head>
@ -104,15 +106,32 @@
<span id="themeIcon">☾</span>
</button>
@if (User.Identity?.IsAuthenticated == true)
{
<a asp-controller="Account" asp-action="Profile" class="header-action">
<i class="bi bi-person header-action__icon"></i>
<span class="header-action__label">@User.Identity.Name</span>
</a>
<form asp-controller="Account" asp-action="Logout" method="post" class="header-logout">
<button type="submit" class="header-action" title="Выйти" aria-label="Выйти">
<i class="bi bi-box-arrow-right header-action__icon"></i>
<span class="header-action__label">Выйти</span>
</button>
</form>
}
else
{
<a asp-controller="Account" asp-action="Login" class="header-action">
<i class="bi bi-person header-action__icon"></i>
<span class="header-action__label">Войти</span>
</a>
}
<a href="#" class="header-action">
<i class="bi bi-person header-action__icon"></i>
<span class="header-action__label">Войти</span>
</a>
<a href="#" class="header-action">
<i class="bi bi-cart3 header-action__icon"></i>
<a asp-controller="Cart" asp-action="Index" class="header-action">
<span class="header-action__badge-wrap">
<i class="bi bi-cart3 header-action__icon"></i>
<span class="header-action__count">@CartService.GetCount()</span>
</span>
<span class="header-action__label">Корзина</span>
</a>
</div>

View File

@ -13,7 +13,10 @@
</div>
<div class="product-card__footer">
<span class="product-card__price">@Model.Price.ToString("N0") &#8381;</span>
<a href="#" class="btn btn-primary btn-sm product-card__btn">Купить</a>
<form class="product-card__form" asp-action="Add" asp-controller="Cart" method="post">
<input type="hidden" name="productId" value="@Model.Id" />
<button type="submit" class="btn btn-primary btn-sm product-card__btn">Купить</button>
</form>
</div>
</div>
</article>

View File

@ -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 {