ASP.NET Core MVC

Практичний проєкт: Каталог товарів з HTMX

Наскрізний проєкт ProductHub від dotnet new до завершеного каталогу товарів. Live-search з debounce, фільтрація по категоріям, infinite scroll, inline edit, модальне вікно створення товару, кошик з OOB swaps, toast-сповіщення. AntiForgery інтеграція. ASP.NET Core MVC + HTMX + Bootstrap 5.

Практичний проєкт: Каталог товарів з HTMX

Попередні дві статті заклали фундамент: атрибути HTMX, серверна інтеграція з MVC, патерни live-search, lazy loading та inline edit. Тепер настав час об'єднати весь цей арсенал в єдиний живий застосунок.

ProductHub — це повнофункціональний каталог товарів, де кожна інтерактивна функція реалізована через HTMX без жодного JavaScript-фреймворку. Цей проєкт є синтезом усього вивченого в статтях 12–13, представленим у вигляді покрокової інструкції від dotnet new до завершеного застосунку. Кожен крок є само-достатнім: ви виконуєте його — і бачите результат у браузері.

Що ми побудуємо

Застосунок складатиметься з двох розділів: публічного каталогу та адмін-панелі. Кожен розділ демонструє окремий набір HTMX-можливостей:

Каталог товарів

Live-search із debounce 300мс та hx-indicator-спіннером. Фільтрація по категоріям через бокову панель з активним станом. Infinite scroll з sentinel-елементом (hx-trigger="revealed"). Картки товарів з кнопкою «Додати до кошика».

Кошик та сповіщення

Лічильник кошика в навбарі — оновлюється через HX-Trigger: cartUpdated. Toast-сповіщення через OOB swaps при додаванні товару. Сторінка кошика з видаленням через hx-delete.

Адмін-панель

Inline edit ціни та назви — три стани рядка (view / edit / saved). Модальне вікно створення нового товару через HTMX. Підтвердження видалення через діалог-фрагмент.

Технологічний стек:

  • ASP.NET Core MVC (.NET 9)
  • HTMX 2.0
  • Bootstrap 5.3
  • In-memory репозиторій (без БД — фокус на HTMX)

Крок 1: Створення проєкту

Ініціалізація

dotnet new mvc -n ProductHub --framework net9.0
cd ProductHub

Підключення HTMX та Bootstrap Icons

Відкрийте Views/Shared/_Layout.cshtml і додайте у <head>:

Views/Shared/_Layout.cshtml — head
@* Bootstrap (вже є у шаблоні) *@
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css">
@* Bootstrap Icons *@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
@* Наші стилі *@
<link rel="stylesheet" href="~/css/site.css">

@* AntiForgery meta-тег для HTMX-кнопок поза формами *@
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
<meta name="RequestVerificationToken"
      content="@Antiforgery.GetAndStoreTokens(Context).RequestToken">

Перед закриваючим </body> замінити Bootstrap JS на:

Views/Shared/_Layout.cshtml — body end
@* HTMX *@
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
@* Єдиний JS-файл: AntiForgery + глобальна обробка помилок *@
<script src="~/js/htmx-setup.js"></script>
@* Bootstrap JS (для Modal) *@
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>

Налаштування AntiForgery та DI

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();
builder.Services.AddAntiforgery(options =>
    options.HeaderName = "RequestVerificationToken");

// Реєстрація репозиторію (наступний крок)
builder.Services.AddSingleton<IProductRepository, InMemoryProductRepository>();
builder.Services.AddSingleton<ICartRepository, InMemoryCartRepository>();

var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Run();

Структура проєкту


Крок 2: Модель даних та репозиторій

Модель Product

Models/Product.cs
namespace ProductHub.Models;

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Category { get; set; } = "";
    public decimal Price { get; set; }
    public string Description { get; set; } = "";
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

public record ProductUpdateDto(string Name, decimal Price);
public record ProductCreateDto(string Name, string Category, decimal Price, string Description);

In-Memory репозиторій

Repositories/InMemoryProductRepository.cs
using ProductHub.Models;

namespace ProductHub.Repositories;

public interface IProductRepository
{
    IReadOnlyList<Product> GetAll();
    IReadOnlyList<Product> Search(string? q, string? category);
    IReadOnlyList<Product> GetPage(int page, int pageSize, string? category = null);
    bool HasMore(int page, int pageSize, string? category = null);
    Product? GetById(int id);
    Product Add(ProductCreateDto dto);
    Product? Update(int id, ProductUpdateDto dto);
    bool Delete(int id);
    IReadOnlyList<string> GetCategories();
}

public class InMemoryProductRepository : IProductRepository
{
    private static readonly List<Product> _products =
    [
        new() { Id=1, Name="MacBook Pro 14\"", Category="Ноутбуки", Price=89999, Description="Apple M4 Pro" },
        new() { Id=2, Name="ASUS ROG Strix G15", Category="Ноутбуки", Price=54999, Description="Ігровий ноутбук" },
        new() { Id=3, Name="iPhone 16 Pro", Category="Смартфони", Price=49999, Description="Флагман Apple" },
        new() { Id=4, Name="Samsung Galaxy S25", Category="Смартфони", Price=39999, Description="Android флагман" },
        new() { Id=5, Name="Sony WH-1000XM5", Category="Навушники", Price=12999, Description="ANC навушники" },
        new() { Id=6, Name="Apple AirPods Pro", Category="Навушники", Price=10999, Description="True wireless" },
        new() { Id=7, Name="iPad Pro 12.9\"", Category="Планшети", Price=44999, Description="Chip M4" },
        new() { Id=8, Name="Samsung Galaxy Tab S9", Category="Планшети", Price=29999, Description="Android планшет" },
        new() { Id=9, Name="Logitech MX Master 3S", Category="Аксесуари", Price=4999, Description="Бездротова миша" },
        new() { Id=10, Name="Keychron K2 Pro", Category="Аксесуари", Price=5999, Description="Механічна клавіатура" },
        new() { Id=11, Name="LG 27GP850-B", Category="Монітори", Price=18999, Description="IPS 165Hz" },
        new() { Id=12, Name="Samsung Odyssey G7", Category="Монітори", Price=21999, Description="QLED 240Hz" },
    ];
    private static int _nextId = 13;
    private static readonly object _lock = new();

    public IReadOnlyList<Product> GetAll() => _products.AsReadOnly();

    public IReadOnlyList<Product> Search(string? q, string? category)
    {
        var query = _products.AsQueryable();
        if (!string.IsNullOrWhiteSpace(category))
            query = query.Where(p => p.Category == category);
        if (!string.IsNullOrWhiteSpace(q))
            query = query.Where(p =>
                p.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
                p.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
        return query.ToList().AsReadOnly();
    }

    public IReadOnlyList<Product> GetPage(int page, int pageSize, string? category = null)
    {
        var items = string.IsNullOrWhiteSpace(category)
            ? _products
            : _products.Where(p => p.Category == category).ToList();
        return items.Skip((page - 1) * pageSize).Take(pageSize).ToList().AsReadOnly();
    }

    public bool HasMore(int page, int pageSize, string? category = null)
    {
        var total = string.IsNullOrWhiteSpace(category)
            ? _products.Count
            : _products.Count(p => p.Category == category);
        return page * pageSize < total;
    }

    public Product? GetById(int id) => _products.FirstOrDefault(p => p.Id == id);

    public Product Add(ProductCreateDto dto)
    {
        lock (_lock)
        {
            var product = new Product
            {
                Id = _nextId++, Name = dto.Name, Category = dto.Category,
                Price = dto.Price, Description = dto.Description
            };
            _products.Add(product);
            return product;
        }
    }

    public Product? Update(int id, ProductUpdateDto dto)
    {
        lock (_lock)
        {
            var product = _products.FirstOrDefault(p => p.Id == id);
            if (product is null) return null;
            product.Name = dto.Name;
            product.Price = dto.Price;
            return product;
        }
    }

    public bool Delete(int id)
    {
        lock (_lock) { return _products.RemoveAll(p => p.Id == id) > 0; }
    }

    public IReadOnlyList<string> GetCategories()
        => _products.Select(p => p.Category).Distinct().OrderBy(c => c).ToList().AsReadOnly();
}

lock(_lock) забезпечує потокобезпеку при паралельних запитах до статичного списку в AddSingleton-сервісі.

htmx-setup.js

wwwroot/js/htmx-setup.js
// AntiForgery: передавати токен у заголовку для POST/PUT/PATCH/DELETE
document.addEventListener("htmx:configRequest", (event) => {
    const method = event.detail.verb.toUpperCase();
    if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
        const token = document.querySelector('meta[name="RequestVerificationToken"]')
            ?.getAttribute("content");
        if (token) event.detail.headers["RequestVerificationToken"] = token;
    }
});

// Глобальна обробка HTTP-помилок
document.addEventListener("htmx:responseError", (event) => {
    const status = event.detail.xhr.status;
    if (status === 403) alert("Доступ заборонено");
    else if (status >= 500) alert("Помилка сервера. Спробуйте пізніше.");
});

Крок 3: Публічний каталог — Live-Search, Фільтрація, Infinite Scroll

ProductController (публічний)

Controllers/ProductController.cs
using Microsoft.AspNetCore.Mvc;
using ProductHub.Models;
using ProductHub.Repositories;

namespace ProductHub.Controllers;

public class ProductController : Controller
{
    private readonly IProductRepository _products;
    private const int PageSize = 4; // невелике число для демонстрації infinite scroll

    public ProductController(IProductRepository products) => _products = products;

    // GET /product — повна сторінка каталогу
    public IActionResult Index(string? category)
    {
        var page = _products.GetPage(1, PageSize, category);
        var hasMore = _products.HasMore(1, PageSize, category);
        var categories = _products.GetCategories();
        ViewBag.Categories = categories;
        ViewBag.ActiveCategory = category;
        return View((page, hasMore));
    }

    // GET /product/search?q=ноутбук&category=Ноутбуки — HTMX live-search
    [HttpGet]
    public IActionResult Search(string? q, string? category)
    {
        var results = _products.Search(q, category);
        return PartialView("_ProductGrid", results);
    }

    // GET /product/page?page=2&category=Смартфони — HTMX infinite scroll
    [HttpGet]
    public IActionResult Page(int page, string? category)
    {
        var items = _products.GetPage(page, PageSize, category);
        var hasMore = _products.HasMore(page, PageSize, category);
        return PartialView("_ProductPage", (items, page, hasMore, category));
    }
}

Views: Index.cshtml

Views/Product/Index.cshtml
@model (IReadOnlyList<ProductHub.Models.Product> Page, bool HasMore)
@{ ViewData["Title"] = "Каталог товарів"; var categories = ViewBag.Categories as IReadOnlyList<string>; }

<div class="container-fluid mt-3">
    <div class="row">

        @* === Бокова панель фільтрів === *@
        <aside class="col-md-2">
            <h6 class="text-uppercase text-muted fw-bold small mb-3">Категорії</h6>
            <div class="d-grid gap-1">
                @* Кнопка "Всі" *@
                <button class="btn @(ViewBag.ActiveCategory == null ? "btn-primary" : "btn-outline-secondary") btn-sm text-start"
                        hx-get="/product/search"
                        hx-target="#product-container"
                        hx-swap="innerHTML"
                        hx-include="#search-input">
                    Всі категорії
                </button>
                @* Кнопки категорій *@
                @foreach (var cat in categories!)
                {
                    <button class="btn @(ViewBag.ActiveCategory == cat ? "btn-primary" : "btn-outline-secondary") btn-sm text-start"
                            hx-get="/product/search?category=@Uri.EscapeDataString(cat)"
                            hx-target="#product-container"
                            hx-swap="innerHTML"
                            hx-include="#search-input">
                        @cat
                    </button>
                }
            </div>
        </aside>

        @* === Основна зона контенту === *@
        <main class="col-md-10">
            @* Поле пошуку з debounce *@
            <div class="mb-4 position-relative">
                <input id="search-input" type="search" name="q"
                       class="form-control form-control-lg shadow-sm"
                       placeholder="&#x1F50D; Пошук товарів..."
                       hx-get="/product/search"
                       hx-target="#product-container"
                       hx-trigger="input changed delay:300ms"
                       hx-indicator="#search-spinner"
                       autocomplete="off">
                <div id="search-spinner" class="htmx-indicator position-absolute top-50 end-0 translate-middle-y me-3">
                    <div class="spinner-border spinner-border-sm text-secondary"></div>
                </div>
            </div>

            @* Контейнер результатів — infinite scroll всередині *@
            <div id="product-container">
                @* Перша порція карток *@
                <div class="row row-cols-1 row-cols-md-4 g-4 mb-3">
                    @foreach (var p in Model.Page)
                    {
                        <partial name="_ProductCard" model="p"/>
                    }
                </div>
                @* Sentinel для infinite scroll *@
                @if (Model.HasMore)
                {
                    <div hx-get="/product/page?page=2"
                         hx-trigger="revealed"
                         hx-swap="outerHTML"
                         class="d-flex justify-content-center py-3">
                        <div class="spinner-border text-primary"></div>
                    </div>
                }
            </div>
        </main>
    </div>
</div>

_ProductCard.cshtml

Views/Product/_ProductCard.cshtml
@model ProductHub.Models.Product

<div class="col">
    <div class="card h-100 shadow-sm border-0">
        <div class="card-body d-flex flex-column">
            <h5 class="card-title fw-semibold">@Model.Name</h5>
            <p class="text-muted small mb-1">@Model.Category</p>
            <p class="card-text text-muted small flex-grow-1">@Model.Description</p>
            <div class="d-flex align-items-center justify-content-between mt-3">
                <span class="fs-5 fw-bold text-primary">@Model.Price.ToString("N0") ₴</span>
                <button class="btn btn-sm btn-outline-primary"
                        hx-post="/cart/add/@Model.Id"
                        hx-swap="none"
                        title="Додати до кошика">
                    <i class="bi bi-cart-plus"></i>
                </button>
            </div>
        </div>
    </div>
</div>

_ProductGrid.cshtml (результати live-search та фільтра)

Views/Product/_ProductGrid.cshtml
@model IReadOnlyList<ProductHub.Models.Product>

@if (!Model.Any())
{
    <div class="alert alert-info rounded-3">
        <i class="bi bi-search me-2"></i>За вашим запитом нічого не знайдено.
    </div>
}
else
{
    <div class="row row-cols-1 row-cols-md-4 g-4">
        @foreach (var p in Model)
        {
            <partial name="_ProductCard" model="p"/>
        }
    </div>
}

_ProductPage.cshtml (infinite scroll порція)

Views/Product/_ProductPage.cshtml
@model (IReadOnlyList<ProductHub.Models.Product> Items, int Page, bool HasMore, string? Category)

@* Картки нової порції *@
<div class="row row-cols-1 row-cols-md-4 g-4 mb-3">
    @foreach (var p in Model.Items)
    {
        <partial name="_ProductCard" model="p"/>
    }
</div>

@* Наступний sentinel або кінець списку *@
@if (Model.HasMore)
{
    var nextPage = Model.Page + 1;
    var catParam = Model.Category != null ? $"&category={Uri.EscapeDataString(Model.Category)}" : "";
    <div hx-get="/product/page?page=@nextPage@catParam"
         hx-trigger="revealed"
         hx-swap="outerHTML"
         class="d-flex justify-content-center py-3">
        <div class="spinner-border text-primary"></div>
    </div>
}
else
{
    <p class="text-center text-muted py-3 small">Всі товари завантажено ✓</p>
}

Крок 4: Кошик та Toast-сповіщення (OOB Swaps)

CartRepository та CartController

Repositories/InMemoryCartRepository.cs
namespace ProductHub.Repositories;

public interface ICartRepository
{
    void AddItem(int productId, string productName, decimal price);
    void RemoveItem(int productId);
    IReadOnlyDictionary<int, (string Name, decimal Price, int Qty)> GetItems();
    int GetCount();
}

public class InMemoryCartRepository : ICartRepository
{
    // Session-агностичний in-memory кошик для демонстрації
    private static readonly Dictionary<int, (string Name, decimal Price, int Qty)> _items = new();
    private static readonly object _lock = new();

    public void AddItem(int productId, string productName, decimal price)
    {
        lock (_lock)
        {
            if (_items.TryGetValue(productId, out var existing))
                _items[productId] = (existing.Name, existing.Price, existing.Qty + 1);
            else
                _items[productId] = (productName, price, 1);
        }
    }

    public void RemoveItem(int productId) { lock (_lock) { _items.Remove(productId); } }
    public IReadOnlyDictionary<int, (string Name, decimal Price, int Qty)> GetItems() => _items;
    public int GetCount() => _items.Values.Sum(i => i.Qty);
}
Controllers/CartController.cs
using Microsoft.AspNetCore.Mvc;
using ProductHub.Repositories;

namespace ProductHub.Controllers;

public class CartController : Controller
{
    private readonly ICartRepository _cart;
    private readonly IProductRepository _products;

    public CartController(ICartRepository cart, IProductRepository products)
    { _cart = cart; _products = products; }

    // POST /cart/add/{productId} — додати товар; відповідь з OOB swaps
    [HttpPost, ValidateAntiForgeryToken]
    public IActionResult Add(int productId)
    {
        var product = _products.GetById(productId);
        if (product is null) return NotFound();

        _cart.AddItem(product.Id, product.Name, product.Price);

        // HX-Trigger: сигналізує navbar-лічильнику оновитися
        Response.Headers["HX-Trigger"] = "cartUpdated";
        // hx-swap="none" на кнопці → тіло відповіді ігнорується,
        // але OOB-фрагменти у відповіді все одно обробляються
        return PartialView("_CartOob", (product.Name, _cart.GetCount()));
    }

    // DELETE /cart/remove/{productId}
    [HttpDelete, ValidateAntiForgeryToken]
    public IActionResult Remove(int productId)
    {
        _cart.RemoveItem(productId);
        Response.Headers["HX-Trigger"] = "cartUpdated";
        return Ok();
    }

    public IActionResult Index()
    {
        var items = _cart.GetItems();
        return View(items);
    }
}

_CartOob.cshtml — OOB toast + лічильник одночасно

Views/Cart/_CartOob.cshtml
@model (string ProductName, int Count)

@* OOB #1: Toast-сповіщення — з'являється у #toast-container у _Layout *@
<div id="toast-container" hx-swap-oob="innerHTML">
    <div class="toast show align-items-center text-bg-success border-0 position-fixed
                bottom-0 end-0 m-4" role="alert" style="z-index:9999"
         hx-on::load="setTimeout(()=>this.classList.remove('show'),3000)">
        <div class="d-flex">
            <div class="toast-body">
                <i class="bi bi-check-circle me-1"></i>
                «@Model.ProductName» додано до кошика!
            </div>
        </div>
    </div>
</div>

@* OOB #2: лічильник у навбарі *@
<span id="cart-count" hx-swap-oob="innerHTML">@Model.Count</span>

Додайте у navbar:

Views/Shared/_Layout.cshtml — navbar
<a class="nav-link position-relative" asp-controller="Cart" asp-action="Index">
    <i class="bi bi-cart3 fs-5"></i>
    <span id="cart-count" class="position-absolute top-0 start-100 translate-middle
                                  badge rounded-pill bg-danger">0</span>
</a>

@* Toast-контейнер — пустий при завантаженні *@
<div id="toast-container"></div>
hx-swap="none" на кнопці кошика означає: основна відповідь ігнорується — DOM не змінюється. Але HTMX все одно парсить тіло відповіді на наявність hx-swap-oob елементів і обробляє їх. Це дає можливість оновити toast і лічильник без hx-target.

Крок 5: Адмін-панель — Inline Edit та Модальне вікно

Admin ProductController

Controllers/Admin/ProductController.cs
using Microsoft.AspNetCore.Mvc;
using ProductHub.Models;
using ProductHub.Repositories;

namespace ProductHub.Controllers.Admin;

[Route("admin/products")]
public class ProductController : Controller
{
    private readonly IProductRepository _products;
    public ProductController(IProductRepository products) => _products = products;

    [HttpGet("")] public IActionResult Index() => View(_products.GetAll());

    // Три стани рядка: view / edit / update
    [HttpGet("{id}/row")]
    public IActionResult Row(int id) => PartialView("_ProductRow", _products.GetById(id));

    [HttpGet("{id}/edit-row")]
    public IActionResult EditRow(int id) => PartialView("_ProductEditRow", _products.GetById(id));

    [HttpPatch("{id}"), ValidateAntiForgeryToken]
    public IActionResult Update(int id, ProductUpdateDto dto)
    {
        if (!ModelState.IsValid) { Response.StatusCode = 422; return PartialView("_ProductEditRow", _products.GetById(id)); }
        var updated = _products.Update(id, dto);
        return updated is null ? NotFound() : PartialView("_ProductRow", updated);
    }

    // Модальне вікно створення
    [HttpGet("create-modal")]
    public IActionResult CreateModal() => PartialView("_CreateModal");

    [HttpPost(""), ValidateAntiForgeryToken]
    public IActionResult Create(ProductCreateDto dto)
    {
        if (!ModelState.IsValid) { Response.StatusCode = 422; return PartialView("_CreateModal", dto); }
        var product = _products.Add(dto);
        Response.Headers["HX-Trigger"] = "productCreated";
        return PartialView("_ProductRow", product);
    }

    [HttpDelete("{id}"), ValidateAntiForgeryToken]
    public IActionResult Delete(int id)
    {
        _products.Delete(id);
        return Ok();
    }
}

Admin Views

Views/Admin/Product/Index.cshtml
@model IReadOnlyList<ProductHub.Models.Product>
@{ ViewData["Title"] = "Адмін: Товари"; }

<div class="container mt-4">
    <div class="d-flex justify-content-between align-items-center mb-4">
        <h1>Управління товарами</h1>
        @* Кнопка відкриває модаль через HTMX *@
        <button class="btn btn-primary"
                hx-get="/admin/products/create-modal"
                hx-target="#modal-container"
                hx-swap="innerHTML"
                data-bs-toggle="modal"
                data-bs-target="#product-modal">
            <i class="bi bi-plus-lg me-1"></i>Додати товар
        </button>
    </div>

    <table class="table table-hover">
        <thead class="table-dark">
            <tr><th>Назва</th><th>Категорія</th><th>Ціна</th><th>Дії</th></tr>
        </thead>
        <tbody id="products-tbody"
               hx-trigger="productCreated from:body"
               hx-get="/admin/products"
               hx-swap="innerHTML"
               hx-select="tbody#products-tbody">
            @foreach (var p in Model)
            {
                <partial name="_ProductRow" model="p"/>
            }
        </tbody>
    </table>
</div>

@* Bootstrap Modal — порожній; HTMX заповнить через hx-get *@
<div class="modal fade" id="product-modal" tabindex="-1">
    <div class="modal-dialog">
        <div id="modal-container"></div>
    </div>
</div>
Views/Admin/Product/_ProductRow.cshtml
@model ProductHub.Models.Product
<tr id="row-@Model.Id">
    <td>@Model.Name</td>
    <td><span class="badge bg-secondary">@Model.Category</span></td>
    <td class="fw-bold">@Model.Price.ToString("N0") ₴</td>
    <td>
        <button class="btn btn-sm btn-outline-primary me-1"
                hx-get="/admin/products/@Model.Id/edit-row"
                hx-target="#row-@Model.Id" hx-swap="outerHTML">✏️</button>
        <button class="btn btn-sm btn-outline-danger"
                hx-delete="/admin/products/@Model.Id"
                hx-target="#row-@Model.Id" hx-swap="outerHTML"
                hx-confirm="Видалити «@Model.Name»?">🗑️</button>
    </td>
</tr>
Views/Admin/Product/_ProductEditRow.cshtml
@model ProductHub.Models.Product
<tr id="row-@Model.Id">
    <td><input name="Name" value="@Model.Name" class="form-control form-control-sm" required></td>
    <td><span class="badge bg-secondary">@Model.Category</span></td>
    <td><input name="Price" value="@Model.Price" type="number" step="0.01"
               class="form-control form-control-sm" style="width:120px" required></td>
    <td>
        <button class="btn btn-sm btn-success me-1"
                hx-patch="/admin/products/@Model.Id"
                hx-target="#row-@Model.Id" hx-swap="outerHTML"
                hx-include="closest tr"></button>
        <button class="btn btn-sm btn-outline-secondary"
                hx-get="/admin/products/@Model.Id/row"
                hx-target="#row-@Model.Id" hx-swap="outerHTML"></button>
    </td>
</tr>
Views/Admin/Product/_CreateModal.cshtml
<div class="modal-content">
    <div class="modal-header">
        <h5 class="modal-title">Новий товар</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
    </div>
    <form hx-post="/admin/products"
          hx-target="tbody#products-tbody"
          hx-swap="beforeend"
          hx-on::after-request="if(event.detail.successful) bootstrap.Modal.getInstance(document.getElementById('product-modal')).hide()">
        <div class="modal-body">
            <div class="mb-3">
                <label class="form-label">Назва</label>
                <input name="Name" class="form-control" required>
            </div>
            <div class="mb-3">
                <label class="form-label">Категорія</label>
                <input name="Category" class="form-control" required>
            </div>
            <div class="mb-3">
                <label class="form-label">Ціна (₴)</label>
                <input name="Price" type="number" step="0.01" class="form-control" required>
            </div>
            <div class="mb-3">
                <label class="form-label">Опис</label>
                <textarea name="Description" class="form-control" rows="2"></textarea>
            </div>
        </div>
        <div class="modal-footer">
            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Скасувати</button>
            <button type="submit" class="btn btn-primary">
                <span class="htmx-indicator spinner-border spinner-border-sm me-1"></span>
                Зберегти
            </button>
        </div>
    </form>
</div>

hx-on::after-request закриває модаль Bootstrap після успішного збереження. HX-Trigger: productCreated з сервера змушує <tbody> перезавантажитись — новий рядок з'являється у таблиці і без beforeend, гарантуючи актуальність даних.


Практичні завдання

Рівень 1 — Базовий

Завдання 1.1. Додайте у каталог сортування: кнопки «За ціною ↑» та «За ціною ↓» у боковій панелі. Кожна кнопка включає поточний пошуковий запит через hx-include="#search-input". Rozширте IProductRepository.Search() параметром string? sort.

Завдання 1.2. Додайте на сторінку кошика (Views/Cart/Index.cshtml) кнопку «Видалити» для кожного товару через hx-delete="/cart/remove/{id}". Після видалення рядок зникає (hx-swap="outerHTML"), а лічильник у навбарі оновлюється через HX-Trigger: cartUpdated.

Рівень 2 — Логіка

Завдання 2.1. Реалізуйте «Кількість» у кошику: кнопки + і для зміни кількості через hx-patch="/cart/update/{id}?qty=2". Рядок замінюється оновленим фрагментом. Загальна сума рядка і підсумок внизу перераховуються через OOB swaps.

Завдання 2.2. Додайте до адмін-панелі live-search по таблиці товарів: <input hx-get="/admin/products/search" hx-target="tbody#products-tbody" hx-trigger="input changed delay:200ms" name="q">. Поверніть рядки що відповідають запиту.

Рівень 3 — Архітектура

Завдання 3.1. Замініть in-memory CartRepository на session-based: підключіть AddDistributedMemoryCache() + AddSession() у Program.cs, серіалізуйте кошик у JSON у HttpContext.Session. Кожен браузерний сеанс матиме власний окремий кошик.


Резюме проєкту

Ви побудували повнофункціональний HTMX+MVC застосунок, де кожна інтерактивна функція реалізована декларативно — через HTML-атрибути:

ФункціяHTMX-інструмент
Live-searchhx-get + hx-trigger="input changed delay:300ms" + hx-indicator
Фільтрація по категоріямhx-get + hx-include для передачі поточного пошуку
Infinite scrollhx-trigger="revealed" + sentinel з hx-swap="outerHTML"
Кошик + лічильникHX-Trigger: cartUpdated + слухач from:body
Toast-сповіщенняOOB swaps hx-swap-oob з _CartOob.cshtml
Inline editТри PartialView стани + hx-include="closest tr"
Модальне вікноhx-get заповнює Bootstrap Modal + hx-on::after-request закриває
AntiForgeryМета-тег + htmx:configRequest у єдиному htmx-setup.js
Підтвердження видаленняhx-confirm — нативний браузерний діалог

Весь цей функціонал побудований без жодного JavaScript-фреймворку — лише HTMX як клієнтська бібліотека та ~20 рядків htmx-setup.js для налаштування безпеки.

У наступній статті — Завантаження файлів: IFormFile, валідація MIME та розміру, збереження у wwwroot та поза ним, streaming для великих файлів, FileResult та захист завантажень.