Вход/регистрация (cookie-auth) + корзина (сессия) + отступы от краёв экрана
This commit is contained in:
parent
27f9495a65
commit
6e9c6e796c
113
Controllers/AccountController.cs
Normal file
113
Controllers/AccountController.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
65
Controllers/CartController.cs
Normal file
65
Controllers/CartController.cs
Normal 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
51
Data/UserStore.cs
Normal 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
8
Models/AppUser.cs
Normal 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
10
Models/CartItem.cs
Normal 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
19
Models/LoginViewModel.cs
Normal 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; }
|
||||
}
|
||||
28
Models/RegisterViewModel.cs
Normal file
28
Models/RegisterViewModel.cs
Normal 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;
|
||||
}
|
||||
27
Program.cs
27
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<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(
|
||||
|
|
|
|||
17
README.md
17
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)
|
||||
|
|
|
|||
115
Services/CartService.cs
Normal file
115
Services/CartService.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
47
Views/Account/Login.cshtml
Normal file
47
Views/Account/Login.cshtml
Normal 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" />
|
||||
}
|
||||
26
Views/Account/Profile.cshtml
Normal file
26
Views/Account/Profile.cshtml
Normal 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>
|
||||
51
Views/Account/Register.cshtml
Normal file
51
Views/Account/Register.cshtml
Normal 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
85
Views/Cart/Index.cshtml
Normal 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") ₽
|
||||
@if (item.Quantity > 1)
|
||||
{
|
||||
<span class="cart-row__subtotal">= @item.Subtotal.ToString("N0") ₽</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") ₽</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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@
|
|||
</div>
|
||||
<div class="product-card__footer">
|
||||
<span class="product-card__price">@Model.Price.ToString("N0") ₽</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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user