Каталог из данных: фильтр по категориям, поиск, пустое состояние

This commit is contained in:
Triton 2026-09-14 03:31:20 +05:00
parent fb076b21d6
commit 27f9495a65
10 changed files with 422 additions and 184 deletions

View File

@ -1,18 +1,23 @@

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
using TechnoWorld.Data;
using TechnoWorld.Models;
namespace TechnoWorld.Controllers;
public class CatalogController : Controller
{
public IActionResult Index()
[HttpGet]
public IActionResult Index(string? category = null, string? q = null)
{
ViewData["Title"] = "Каталог";
return View();
var products = ProductStore.GetByFilter(category, q);
ViewData["Categories"] = ProductStore.Categories;
ViewData["CurrentCategory"] = category;
ViewData["SearchQuery"] = q;
return View(products);
}
}

View File

@ -1,15 +1,14 @@

using System.Diagnostics;
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using TechnoWorld.Data;
using TechnoWorld.Models;
namespace TechnoWorld.Controllers;
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
public HomeController(ILogger<HomeController> logger)
@ -19,39 +18,30 @@ public class HomeController : Controller
public IActionResult Index()
{
ViewData["Title"] = "Главная";
ViewData["Hits"] = ProductStore.GetHits();
return View();
}
public IActionResult About()
{
ViewData["Title"] = "О системе";
return View();
}
[HttpGet]
public IActionResult Contacts()
{
ViewData["Title"] = "Контакты";
return View();
}
[HttpPost]
public IActionResult Contacts(ContactViewModel model)
{
if (ModelState.IsValid)
{
TempData["ContactSuccess"] = "Ваше сообщение принято! Мы свяжемся с вами в ближайшее время.";
return RedirectToAction(nameof(Contacts));
}
@ -60,14 +50,11 @@ public class HomeController : Controller
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error(int? statusCode = null)
{
var model = new ErrorViewModel
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier,
StatusCode = statusCode
};

197
Data/ProductStore.cs Normal file
View File

@ -0,0 +1,197 @@
using TechnoWorld.Models;
namespace TechnoWorld.Data;
public static class ProductStore
{
public static readonly List<Product> Products =
[
new Product
{
Id = 1,
Name = "Ноутбук Pro 15",
Category = "Ноутбуки",
Price = 89990m,
Specs = "Экран 15.6\u2033 · 8 ГБ RAM · SSD 512 ГБ",
Icon = "bi-laptop",
Badge = "badge-hit",
BadgeText = "Хит продаж"
},
new Product
{
Id = 2,
Name = "Ноутбук Air 13",
Category = "Ноутбуки",
Price = 64990m,
Specs = "Экран 13.3\u2033 · 16 ГБ RAM · SSD 1 ТБ",
Icon = "bi-laptop",
Badge = "badge-new",
BadgeText = "Новинка"
},
new Product
{
Id = 3,
Name = "Ноутбук Gaming X17",
Category = "Ноутбуки",
Price = 119990m,
Specs = "Экран 17.3\u2033 · 32 ГБ RAM · RTX 4060 · SSD 1 ТБ",
Icon = "bi-laptop",
Badge = "badge-hot",
BadgeText = "Выгодно"
},
new Product
{
Id = 4,
Name = "Смартфон Nova X",
Category = "Смартфоны",
Price = 29990m,
Specs = "Экран 6.7\u2033 · Камера 108 Мп · 128 ГБ",
Icon = "bi-phone",
Badge = "badge-hit",
BadgeText = "Хит продаж"
},
new Product
{
Id = 5,
Name = "Смартфон Vega Plus",
Category = "Смартфоны",
Price = 45990m,
Specs = "Экран 6.5\u2033 · 256 ГБ · 5G",
Icon = "bi-phone",
Badge = "badge-new",
BadgeText = "Новинка"
},
new Product
{
Id = 6,
Name = "Смартфон Neo SE",
Category = "Смартфоны",
Price = 17990m,
Specs = "Экран 6.1\u2033 · 64 ГБ · NFC",
Icon = "bi-phone",
Badge = ""
},
new Product
{
Id = 7,
Name = "Планшет Tab Lite",
Category = "Планшеты",
Price = 18490m,
Specs = "Экран 11\u2033 · 4 ГБ RAM · 128 ГБ",
Icon = "bi-tablet",
Badge = "badge-new",
BadgeText = "Новинка"
},
new Product
{
Id = 8,
Name = "Планшет Tab Pro 12",
Category = "Планшеты",
Price = 42990m,
Specs = "Экран 12.4\u2033 · 8 ГБ RAM · 256 ГБ",
Icon = "bi-tablet",
Badge = "badge-hot",
BadgeText = "Выгодно"
},
new Product
{
Id = 9,
Name = "Наушники SoundMax",
Category = "Наушники",
Price = 3990m,
Specs = "Bluetooth 5.3 · ANC · 40 ч автономной работы",
Icon = "bi-headphones",
Badge = "badge-hit",
BadgeText = "Хит продаж"
},
new Product
{
Id = 10,
Name = "Наушники AirBuds Mini",
Category = "Наушники",
Price = 2490m,
Specs = "Bluetooth 5.2 · 25 ч · зарядный кейс",
Icon = "bi-headphones",
Badge = "badge-new",
BadgeText = "Новинка"
},
new Product
{
Id = 11,
Name = "Наушники Studio Over",
Category = "Наушники",
Price = 7990m,
Specs = "Проводные · студийные · 32 Ом",
Icon = "bi-headphones",
Badge = ""
},
new Product
{
Id = 12,
Name = "Клавиатура Mech RGB",
Category = "Аксессуары",
Price = 5490m,
Specs = "Механическая · RGB-подсветка · RU",
Icon = "bi-keyboard",
Badge = "badge-hot",
BadgeText = "Выгодно"
},
new Product
{
Id = 13,
Name = "Мышь Ergo Pro",
Category = "Аксессуары",
Price = 1990m,
Specs = "Беспроводная · 6000 DPI · тихие кнопки",
Icon = "bi-mouse",
Badge = ""
},
new Product
{
Id = 14,
Name = "Монитор View 27",
Category = "Аксессуары",
Price = 24990m,
Specs = "27\u2033 · QHD · 144 Гц · IPS",
Icon = "bi-display",
Badge = "badge-hit",
BadgeText = "Хит продаж"
}
];
public static readonly List<string> Categories =
[
"Ноутбуки",
"Смартфоны",
"Планшеты",
"Наушники",
"Аксессуары"
];
public static IEnumerable<Product> GetHits()
{
return Products
.Where(p => p.BadgeText == "Хит продаж")
.Take(4);
}
public static IEnumerable<Product> GetByFilter(string? category, string? search)
{
IEnumerable<Product> query = Products;
if (!string.IsNullOrWhiteSpace(category))
{
query = query.Where(p =>
p.Category.Equals(category, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(p =>
p.Name.Contains(search, StringComparison.OrdinalIgnoreCase) ||
p.Specs.Contains(search, StringComparison.OrdinalIgnoreCase));
}
return query.ToList();
}
}

13
Models/Product.cs Normal file
View File

@ -0,0 +1,13 @@
namespace TechnoWorld.Models;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public decimal Price { get; set; }
public string Specs { get; set; } = string.Empty;
public string Icon { get; set; } = "bi-box";
public string Badge { get; set; } = "";
public string BadgeText { get; set; } = "";
}

View File

@ -14,12 +14,20 @@ ASP.NET Core MVC проект интернет-магазина техники.
| Страница | Описание |
|----------------|-----------------------------------------------------------------|
| **Главная** | Промо-баннер, преимущества магазина, хиты продаж, CTA |
| **Каталог** | 4 карточки товаров с характеристиками и кнопкой «Купить» |
| **Главная** | Промо-баннер, преимущества магазина, хиты продаж (из данных), CTA |
| **Каталог** | 14 товаров из данных, фильтр по 5 категориям, поиск |
| **О системе** | Описание сервиса, роли пользователей, план развития |
| **Контакты** | Контактная информация, форма обратной связи с валидацией |
| **404** | Страница ошибки с кнопкой возврата на главную |
### Каталог и данные
- Модель `Product` и хранилище `Data/ProductStore` с 14 товарами по 5 категориям
- Категории: Ноутбуки, Смартфоны, Планшеты, Наушники, Аксессуары
- Фильтр по категории: `Catalog/Index?category=...`
- Поиск по названию и характеристикам: `Catalog/Index?q=...`
- Общая частичная карточка `_ProductCard.cshtml` для каталога и хитов
### Технический стек
- **ASP.NET Core MVC** (.NET 10)

View File

@ -1,85 +1,58 @@
@{
@model IEnumerable<TechnoWorld.Models.Product>
@{
ViewData["Title"] = "Каталог";
var categories = (List<string>?)ViewData["Categories"] ?? [];
var currentCategory = (string?)ViewData["CurrentCategory"];
var searchQuery = (string?)ViewData["SearchQuery"];
var count = Model.Count();
}
<div class="page-head">
<span class="section-overline">Каталог</span>
<h1 class="page-title">Каталог техники</h1>
<p class="page-subtitle">4 товара · Ноутбуки, смартфоны, планшеты, наушники</p>
<p class="page-subtitle">@(count == 0 ? "Ничего не найдено" : count + (count == 1 ? " товар" : count < 5 ? " товара" : " товаров"))</p>
</div>
<div class="row g-3 g-md-4">
<div class="col-lg-3 col-md-6 col-12">
<article class="product-card">
<span class="product-card__badge badge-hit">Хит продаж</span>
<div class="product-card__media"><i class="bi bi-laptop"></i></div>
<div class="product-card__body">
<h5 class="product-card__name">Ноутбук Pro 15</h5>
<div class="product-card__specs">
<span>Экран 15.6" · 8 ГБ RAM · SSD 512 ГБ</span>
</div>
<div class="product-card__footer">
<span class="product-card__price">89 990 &#8381;</span>
<a href="#" class="btn btn-primary btn-sm product-card__btn">Купить</a>
</div>
</div>
</article>
</div>
<div class="col-lg-3 col-md-6 col-12">
<article class="product-card">
<span class="product-card__badge badge-hit">Хит продаж</span>
<div class="product-card__media"><i class="bi bi-phone"></i></div>
<div class="product-card__body">
<h5 class="product-card__name">Смартфон Nova X</h5>
<div class="product-card__specs">
<span>Экран 6.7" · Камера 108 Мп · 128 ГБ</span>
</div>
<div class="product-card__footer">
<span class="product-card__price">29 990 &#8381;</span>
<a href="#" class="btn btn-primary btn-sm product-card__btn">Купить</a>
</div>
</div>
</article>
</div>
<div class="col-lg-3 col-md-6 col-12">
<article class="product-card">
<span class="product-card__badge badge-new">Новинка</span>
<div class="product-card__media"><i class="bi bi-tablet"></i></div>
<div class="product-card__body">
<h5 class="product-card__name">Планшет Tab Lite</h5>
<div class="product-card__specs">
<span>Экран 11" · 4 ГБ RAM · 128 ГБ</span>
</div>
<div class="product-card__footer">
<span class="product-card__price">18 490 &#8381;</span>
<a href="#" class="btn btn-primary btn-sm product-card__btn">Купить</a>
</div>
</div>
</article>
</div>
<div class="col-lg-3 col-md-6 col-12">
<article class="product-card">
<span class="product-card__badge badge-hot">Выгодно</span>
<div class="product-card__media"><i class="bi bi-headphones"></i></div>
<div class="product-card__body">
<h5 class="product-card__name">Наушники SoundMax</h5>
<div class="product-card__specs">
<span>Bluetooth 5.3 · ANC · 40 ч автономной работы</span>
</div>
<div class="product-card__footer">
<span class="product-card__price">3 990 &#8381;</span>
<a href="#" class="btn btn-primary btn-sm product-card__btn">Купить</a>
</div>
</div>
</article>
<div class="catalog-toolbar">
<div class="catalog-filter">
<a class="catalog-filter__link @(string.IsNullOrEmpty(currentCategory) ? "is-active" : "")"
asp-controller="Catalog" asp-action="Index">Все</a>
@foreach (var category in categories)
{
<a class="catalog-filter__link @(category == currentCategory ? "is-active" : "")"
asp-controller="Catalog" asp-action="Index"
asp-route-category="@category">@category</a>
}
</div>
<form class="catalog-search" asp-controller="Catalog" asp-action="Index" method="get">
<input class="catalog-search__input" type="search" name="q" value="@searchQuery"
placeholder="Поиск по каталогу..." aria-label="Поиск по каталогу" />
<button class="catalog-search__btn" type="submit" aria-label="Искать">
<i class="bi bi-search"></i>
</button>
</form>
</div>
@if (Model.Any())
{
<div class="row g-3 g-md-4">
@foreach (var product in Model)
{
<div class="col-lg-3 col-md-6 col-12">
<partial name="_ProductCard" model="product" />
</div>
}
</div>
}
else
{
<div class="catalog-empty">
<span class="catalog-empty__icon"><i class="bi bi-search"></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>
}

View File

@ -65,80 +65,12 @@
</div>
<div class="row g-3 g-md-4">
<div class="col-lg-3 col-md-6 col-12">
<article class="product-card">
<span class="product-card__badge badge-hit">Хит продаж</span>
<div class="product-card__media"><i class="bi bi-laptop"></i></div>
<div class="product-card__body">
<h5 class="product-card__name">Ноутбук Pro 15</h5>
<div class="product-card__specs">
<span>Экран 15.6" · 8 ГБ RAM · SSD 512 ГБ</span>
</div>
<div class="product-card__footer">
<span class="product-card__price">89 990 &#8381;</span>
<a href="#" class="btn btn-primary btn-sm product-card__btn">Купить</a>
</div>
</div>
</article>
</div>
<div class="col-lg-3 col-md-6 col-12">
<article class="product-card">
<span class="product-card__badge badge-hit">Хит продаж</span>
<div class="product-card__media"><i class="bi bi-phone"></i></div>
<div class="product-card__body">
<h5 class="product-card__name">Смартфон Nova X</h5>
<div class="product-card__specs">
<span>Экран 6.7" · Камера 108 Мп · 128 ГБ</span>
</div>
<div class="product-card__footer">
<span class="product-card__price">29 990 &#8381;</span>
<a href="#" class="btn btn-primary btn-sm product-card__btn">Купить</a>
</div>
</div>
</article>
</div>
<div class="col-lg-3 col-md-6 col-12">
<article class="product-card">
<span class="product-card__badge badge-new">Новинка</span>
<div class="product-card__media"><i class="bi bi-tablet"></i></div>
<div class="product-card__body">
<h5 class="product-card__name">Планшет Tab Lite</h5>
<div class="product-card__specs">
<span>Экран 11" · 4 ГБ RAM · 128 ГБ</span>
</div>
<div class="product-card__footer">
<span class="product-card__price">18 490 &#8381;</span>
<a href="#" class="btn btn-primary btn-sm product-card__btn">Купить</a>
</div>
</div>
</article>
</div>
<div class="col-lg-3 col-md-6 col-12">
<article class="product-card">
<span class="product-card__badge badge-hot">Выгодно</span>
<div class="product-card__media"><i class="bi bi-headphones"></i></div>
<div class="product-card__body">
<h5 class="product-card__name">Наушники SoundMax</h5>
<div class="product-card__specs">
<span>Bluetooth 5.3 · ANC · 40 ч</span>
</div>
<div class="product-card__footer">
<span class="product-card__price">3 990 &#8381;</span>
<a href="#" class="btn btn-primary btn-sm product-card__btn">Купить</a>
</div>
</div>
</article>
</div>
@foreach (var product in (IEnumerable<TechnoWorld.Models.Product>)ViewData["Hits"]!)
{
<div class="col-lg-3 col-md-6 col-12">
<partial name="_ProductCard" model="product" />
</div>
}
</div>
</section>

View File

@ -129,11 +129,11 @@
<i class="bi bi-list"></i> Все товары
</a>
<a class="category-nav__link" asp-controller="Catalog" asp-action="Index">Ноутбуки</a>
<a class="category-nav__link" asp-controller="Catalog" asp-action="Index">Смартфоны</a>
<a class="category-nav__link" asp-controller="Catalog" asp-action="Index">Планшеты</a>
<a class="category-nav__link" asp-controller="Catalog" asp-action="Index">Наушники</a>
<a class="category-nav__link" asp-controller="Catalog" asp-action="Index">Аксессуары</a>
<a class="category-nav__link" asp-controller="Catalog" asp-action="Index" asp-route-category="Ноутбуки">Ноутбуки</a>
<a class="category-nav__link" asp-controller="Catalog" asp-action="Index" asp-route-category="Смартфоны">Смартфоны</a>
<a class="category-nav__link" asp-controller="Catalog" asp-action="Index" asp-route-category="Планшеты">Планшеты</a>
<a class="category-nav__link" asp-controller="Catalog" asp-action="Index" asp-route-category="Наушники">Наушники</a>
<a class="category-nav__link" asp-controller="Catalog" asp-action="Index" asp-route-category="Аксессуары">Аксессуары</a>
</div>
</nav>
@ -176,11 +176,11 @@
<div class="col-6 col-lg-3">
<h6 class="footer-heading">Каталог</h6>
<ul class="footer-links">
<li><a asp-controller="Catalog" asp-action="Index">Ноутбуки</a></li>
<li><a asp-controller="Catalog" asp-action="Index">Смартфоны</a></li>
<li><a asp-controller="Catalog" asp-action="Index">Планшеты</a></li>
<li><a asp-controller="Catalog" asp-action="Index">Наушники</a></li>
<li><a asp-controller="Catalog" asp-action="Index">Аксессуары</a></li>
<li><a asp-controller="Catalog" asp-action="Index" asp-route-category="Ноутбуки">Ноутбуки</a></li>
<li><a asp-controller="Catalog" asp-action="Index" asp-route-category="Смартфоны">Смартфоны</a></li>
<li><a asp-controller="Catalog" asp-action="Index" asp-route-category="Планшеты">Планшеты</a></li>
<li><a asp-controller="Catalog" asp-action="Index" asp-route-category="Наушники">Наушники</a></li>
<li><a asp-controller="Catalog" asp-action="Index" asp-route-category="Аксессуары">Аксессуары</a></li>
</ul>
</div>

View File

@ -0,0 +1,19 @@
@model TechnoWorld.Models.Product
<article class="product-card">
@if (!string.IsNullOrEmpty(Model.Badge))
{
<span class="product-card__badge @Model.Badge">@Model.BadgeText</span>
}
<div class="product-card__media"><i class="bi @Model.Icon"></i></div>
<div class="product-card__body">
<h5 class="product-card__name">@Model.Name</h5>
<div class="product-card__specs">
<span>@Model.Specs</span>
</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>
</div>
</div>
</article>

View File

@ -999,6 +999,110 @@ html[data-theme="dark"] .btn-primary:hover {
box-shadow: 0 4px 10px rgba(255, 122, 69, 0.25);
}
.catalog-toolbar {
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: 0.75rem 1rem;
margin-bottom: 1.5rem;
}
.catalog-filter {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.catalog-filter__link {
display: inline-block;
padding: 0.4rem 0.8rem;
border-radius: 999px;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-soft);
transition: background var(--transition), color var(--transition);
}
.catalog-filter__link:hover {
color: var(--accent);
text-decoration: none;
}
.catalog-filter__link.is-active {
color: #fff;
background: var(--accent);
}
.catalog-search {
display: flex;
min-width: 240px;
max-width: 340px;
flex: 1 1 240px;
}
.catalog-search__input {
flex: 1;
border: 1px solid var(--border);
border-right: 0;
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.9rem;
background: var(--bg-body);
color: var(--text-primary);
}
.catalog-search__input:focus {
outline: none;
border-color: var(--accent);
}
.catalog-search__btn {
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
padding: 0 0.9rem;
cursor: pointer;
}
.catalog-search__btn:hover {
background: var(--accent-hover);
}
.catalog-empty {
text-align: center;
padding: 3rem 1rem;
}
.catalog-empty__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--bg-soft);
color: var(--text-secondary);
font-size: 1.6rem;
margin-bottom: 1rem;
}
.catalog-empty__title {
font-size: 1.15rem;
margin-bottom: 0.4rem;
}
.catalog-empty__text {
color: var(--text-secondary);
margin-bottom: 1.2rem;
}
@media (max-width: 992px) {
.header-main {