Part 13: Product Comparison System Matrix | ASP.NET Core MVC Full Project Tutorial

 


Part 13: Product Comparison System Matrix

To build a clean dynamic grid where products form the columns and specifications form the rows, we need a specialized data structure. In Step 1, we create a dedicated ViewModel to hold this grouped data.

Step 1: Creating the CompareProductsViewModel

This ViewModel acts as the data engine for your comparison matrix page, aggregating all the selected products and their distinct feature sets into two clean collections.

C# / Models/ViewModels/CompareProductsViewModel.cs
public class CompareProductsViewModel
{
    public List<Product> Products { get; set; } = new();
    public List<string> SpecificationNames { get; set; } = new();
}

Core Properties & Logic Breakdown

  • The Products Collection (public List<Product> Products { get; set; } = new();): This property stores the full database objects of the specific mobile phones the user has chosen to compare. Because it loads the complete Product entities, your Razor view will have instant access to the core details of each phone—such as images, prices, titles, and brand names—to render at the top of the comparison columns.

  • The Specification Names Master List (public List<string> SpecificationNames { get; set; } = new();): This is the secret to building a dynamic comparison grid. Instead of hardcoding row names, this list holds a distinct, compiled collection of all unique technical attribute names (e.g., "Battery Capacity", "RAM", "Camera Megapixels") across all the selected items. It tells your frontend view exactly how many rows it needs to draw.

  • C# Auto-Initialization Property Pattern (= new();): By utilizing the target-typed new expression (= new();), you ensure that these lists are never null when the ViewModel is instantiated. This defensive programming practice protects your application from crashing with a NullReferenceException if a user lands on the comparison page with no items selected.

Step 2: Implementing the Compare Action Method

This method processes an array of incoming product IDs, cleanses the data, ensures it adheres to business limits, and maps out the flat specification row headers dynamically.

C# / Controllers/ProductController.cs (Compare Action)
public async Task<IActionResult> Compare(int[] ids)
{
    if (ids == null || ids.Length < 2)
    {
        TempData["Error"] = "Please select at least 2 products to compare.";
        return RedirectToAction(nameof(Index));
    }

    var products = new List<Product>();

    foreach (var id in ids.Distinct().Take(4))
    {
        var product = await _productService.GetProductByIdAsync(id);

        if (product != null)
        {
            products.Add(product);
        }
    }

    if (products.Count < 2)
    {
        TempData["Error"] = "Compare requires minimum 2 valid products.";
        return RedirectToAction(nameof(Index));
    }

    var specificationNames = products
        .SelectMany(p => p.Specifications)
        .Select(s => s.Name)
        .Distinct()
        .ToList();

    var viewModel = new CompareProductsViewModel
    {
        Products = products,
        SpecificationNames = specificationNames
    };

    ViewBag.CartItemCount = await _cartService.GetCartItemCountAsync();

    return View(viewModel);
}

Core Components & LINQ Logic Breakdown

  • Initial Boundary Guard Checking: if (ids == null || ids.Length < 2) This instantly protects the server resource from invalid state traffic. A comparison grid makes no sense with fewer than two items, so if this baseline rule isn't met, the request is broken off and the user is redirected safely with an error alert.

  • Deduplication and Cap Optimization: ids.Distinct().Take(4) This is a brilliant snippet for keeping your screen layout clean. By calling .Distinct(), you prevent a user from sending the same phone ID twice. Using .Take(4) clamps the input to a maximum of four phones, which perfectly matches a responsive layout boundary (4 grid columns fit comfortably across desktop views).

  • Dynamic Specification Extraction via SelectMany:

    products.SelectMany(p => p.Specifications).Select(s => s.Name).Distinct()

    This is the highlight of the method. Each product has a child list of specifications. Instead of returning nested lists, .SelectMany() "flattens" every key-value pair across all selected products into a single long sequence of data. Then, .Select(s => s.Name).Distinct() filters that list down into unique, individual row titles (e.g., ensuring "RAM" or "Battery" only appears as one clean header entry).

  • Contextual View Persistence Integration: ViewBag.CartItemCount = await _cartService.GetCartItemCountAsync(); Just like your earlier wishlist features, keeping your infrastructure methods updated means your site headers will remain beautifully operational and consistent while the customer reads down through their spec matrix grid.

Step 3: Creating the Dynamic HTML Comparison Grid View

This Razor file builds a highly interactive comparison interface that handles horizontal scrolling on smaller screens gracefully using modern Bootstrap utilities and standard CSS.

Razor / Views/Product/Compare.cshtml
@model CompareProductsViewModel
@{
    ViewData["Title"] = "Compare Products";
}
<div class="container py-4">
    <div class="d-flex justify-content-between align-items-center mb-4">
        <div>
            <h2 class="fw-bold mb-1">Compare Products</h2>
            <p class="text-muted mb-0">Compare mobile specifications, pricing, ratings and features.</p>
        </div>
        <a asp-controller="Products"
           asp-action="Index"
           class="btn btn-outline-primary">
            <i class="bi bi-arrow-left"></i> Continue Shopping
        </a>
    </div>
    <div class="card shadow-sm border-0">
        <div class="table-responsive">
            <table class="table table-bordered align-middle compare-table mb-0">
                <thead class="table-light">
                    <tr>
                        <th style="width: 220px;">Feature</th>
                        @foreach (var product in Model.Products)
                        {
                            <th class="text-center">
                                <div class="p-2">
                                    <div class="position-relative p-2">
                                        <button type="button"
                                                class="btn btn-danger btn-sm position-absolute top-0 end-0"
                                                onclick="removeCompareAndReload(@product.Id)">
                                            <i class="bi bi-x-lg"></i>
                                        </button>
                                        <a asp-controller="Products"
                                           asp-action="Details"
                                           asp-route-id="@product.Id">
                                            <img src="@(product.MainImageUrl ?? "/images/no-image.png")"
                                                 class="img-fluid rounded mb-3"
                                                 style="height:180px; object-fit:cover;" />
                                        </a>
                                        <h6 class="fw-bold">@product.Name</h6>
                                    </div>
                                    <p class="text-muted small mb-2">
                                        @product.Brand?.Name
                                    </p>
                                    <div class="mb-2">
                                        @if (product.AverageRating > 0)
                                        {
                                            <span class="text-warning">
                                                @for (int i = 1; i <= 5; i++)
                                                {
                                                    if (i <= product.AverageRating)
                                                    {
                                                        <i class="bi bi-star-fill"></i>
                                                    }
                                                    else if (i - 0.5 <= product.AverageRating)
                                                    {
                                                        <i class="bi bi-star-half"></i>
                                                    }
                                                    else
                                                    {
                                                        <i class="bi bi-star"></i>
                                                    }
                                                }
                                            </span>
                                        }
                                    </div>
                                    <h5 class="text-primary fw-bold mb-3">
                                        ₹@product.SalePrice.ToString("N0")
                                    </h5>
                                    <div class="d-grid gap-2">
                                        <form asp-controller="ShoppingCart"
                                              asp-action="AddToCart"
                                              method="post">
                                            <input type="hidden" name="productId" value="@product.Id" />
                                            <button type="submit"
                                                    class="btn btn-primary btn-sm w-100"
                                                    @(product.StockQuantity <= 0 ? "disabled" : "")>
                                                <i class="bi bi-cart-plus"></i> Add to Cart
                                            </button>
                                        </form>
                                        <a asp-controller="Products"
                                           asp-action="Details"
                                           asp-route-id="@product.Id"
                                           class="btn btn-outline-secondary btn-sm">
                                            View Details
                                        </a>
                                   </div>
                               </div>
                            </th>
                        }
                    </tr>
                </thead>
                <tbody>
                    <tr>
                        <td class="fw-bold bg-light">Model</td>
                        @foreach (var product in Model.Products)
                        {
                            <td>@product.Model</td>
                        }
                    </tr>
                    <tr>
                        <td class="fw-bold bg-light">Category</td>
                        @foreach (var product in Model.Products)
                        {
                            <td>@product.Category?.Name</td>
                        }
                    </tr>
                    <tr>
                        <td class="fw-bold bg-light">Stock</td>
                        @foreach (var product in Model.Products)
                        {
                            <td>
                                @if (product.StockQuantity > 0)
                                {
                                    <span class="badge bg-success">In Stock</span>
                                }
                                else
                                {
                                    <span class="badge bg-danger">Out of Stock</span>
                                }
                            </td>
                        }
                    </tr>
                    <tr>
                        <td class="fw-bold bg-light">Short Description</td>
                        @foreach (var product in Model.Products)
                        {
                            <td>@product.ShortDescription</td>
                        }
                    </tr>
                    @foreach (var specName in Model.SpecificationNames)
                    {
                        <tr>
                            <td class="fw-bold bg-light">@specName</td>
                            @foreach (var product in Model.Products)
                            {
                                var specification = product.Specifications
                                    .FirstOrDefault(s => s.Name == specName);

                                <td>
                                    @(specification?.Value ?? "-")
                                </td>
                            }
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    </div>
</div>
<style>
    .compare-table th,
    .compare-table td {
        min-width: 240px;
        vertical-align: middle;
    }
    .compare-table td:first-child,
    .compare-table th:first-child {
        min-width: 220px;
        position: sticky;
        left: 0;
        z-index: 2;
        background: #fff;
    }
    .compare-table tbody td:first-child {
        background: #f8f9fa;
    }
</style>

Core UI & Razor Matrix Logic Breakdown

  • The Transposed Matrix Design: The table is engineered with a transposed layout. Instead of products being rows, your products are mapped out as columns inside the <thead> element. This lets shoppers compare multiple mobile screen widths, prices, and imagery effortlessly side-by-side.

  • Nested Contextual Collection Traversal:

    @foreach (var specName in Model.SpecificationNames) { ...
        @foreach (var product in Model.Products) { ... }
    }
    

    This is the highlight of the frontend engineering. The outer loop draws a row header for every individual specification name in the system. The inner loop then scans across each product column to check if that phone contains that specific attribute using .FirstOrDefault(s => s.Name == specName). If it's found, it renders the metric (e.g., "5000 mAh"); if not, it outputs a clean dash (-).

  • Accurate Fractional Star Calculations: The layout includes half-star evaluation logic utilizing custom condition gates (else if (i - 0.5 <= product.AverageRating)). This ensures a phone with a 4.5-star rating displays four full stars and one half-star icon perfectly.

  • CSS Sticky Horizontal Scroller Engine: The inline styles use a brilliant CSS technique: position: sticky; left: 0; z-index: 2;. When a visitor checks out the matrix on a mobile viewport, they can swipe sideways across columns while the leftmost "Feature" column remains completely locked in place. This provides an excellent mobile user experience.

Step 4: Integrating the Compare Trigger into the Shared Product Card

This snippet injects a compact, floating action button onto the lower section of your product thumbnail card layout, routing the specific model identity into your upcoming client-side application array.

Razor / Views/Shared/_ProductCard.cshtml (Compare Button)
<button class="btn btn-light btn-sm position-absolute bottom-0 start-0 m-2 compare-btn" 
        onclick="addToCompare(@Model.Id)">
    <i class="bi bi-arrow-left-right"></i>
</button>

Core UI & Event Logic Breakdown

  • Consistent Utility Layout Layering (position-absolute bottom-0 start-0 m-2): By utilizing Bootstrap 5 absolute positioning utility tokens, you cleanly anchor this action button into the bottom-left corner of the card's frame. This balances perfectly with your existing bottom-right elements, ensuring that the main product image and metadata text remain completely unobstructed.

  • Contextual Visual Indicator (bi-arrow-left-right): The choice of the Bootstrap Icons "arrow-left-right" glyph is an excellent UX decision. It uses a universally recognized symbol for horizontal sorting and feature matching, telling your shoppers what the button does without requiring bulky text labels.

  • Explicit Model Parameter Passing (onclick="addToCompare(@Model.Id)"): This event handler links your server-side Razor domain context directly into your client-side scripting domain. By injecting @Model.Id directly into the method call parameter slot on compilation, each individual card generated by your loops becomes uniquely serialized to track its respective database record entry point.

  • Clean Adaptive Sizing (btn-light btn-sm): The btn-sm sizing and muted btn-light surface treatment ensure that the button stays subtle and unobtrusive. It looks like an elegant, native browser component that pops up softly when hovered, matching the design style of premium retail platforms.

Step 5: Implementing Client-Side State with LocalStorage

Adding this script to _Layout.cshtml ensures that your comparison state persists across pages as the user browses different categories, models, or details screens.

JavaScript / assets/js/compare.js
<script>
    function addToCompare(productId) {
        let compareList = JSON.parse(localStorage.getItem('compareProducts')) || [];
        if (compareList.includes(productId)) {
            alert('Product already added for comparison.');
            return;
        }
        if (compareList.length >= 4) {
            alert('Maximum 4 products allowed for comparison.');
            return;
        }
        compareList.push(productId);
        localStorage.setItem('compareProducts', JSON.stringify(compareList));
        updateCompareBar();
        alert('Product added to compare list.');
    }
    
    function removeFromCompare(productId) {
        let compareList = JSON.parse(localStorage.getItem('compareProducts')) || [];
        compareList = compareList.filter(id => id !== productId);
        localStorage.setItem('compareProducts', JSON.stringify(compareList));
        updateCompareBar();
    }

    function removeCompareAndReload(productId) {
        removeFromCompare(productId);
        let compareList = JSON.parse(localStorage.getItem('compareProducts')) || [];
        if (compareList.length < 2) {
            window.location.href = '/Products';
            return;
        }
        const query = compareList.map(id => `ids=${id}`).join('&');
        window.location.href = `/Products/Compare?${query}`;
    }

    function updateCompareBar() {
        let compareList = JSON.parse(localStorage.getItem('compareProducts')) || [];
        const compareCount = document.getElementById('compareCount');
        const compareBar = document.getElementById('compareBar');
        if (compareCount) {
            compareCount.innerText = compareList.length;
        }
        if (compareBar) {
            compareBar.style.display = compareList.length > 0 ? 'flex' : 'none';
        }
    }

    function goToCompare() {
        let compareList = JSON.parse(localStorage.getItem('compareProducts')) || [];
        if (compareList.length < 2) {
            alert('Please add at least 2 products to compare.');
            return;
        }
        const query = compareList.map(id => `ids=${id}`).join('&');
        window.location.href = `/Products/Compare?${query}`;
    }
    function clearCompareItems() {     localStorage.removeItem('compareProducts');     updateCompareBar();     window.location.href = '/Products';     } document.addEventListener('DOMContentLoaded', function () { updateCompareBar(); }); </script>

Core Functionality & Logic Breakdown

  • Persistent Browser Memory State (localStorage): Instead of using server-side sessions, the code utilizes localStorage.getItem('compareProducts'). This keeps the active data saved inside the user's browser storage. If they close their tab and return later, their choices are perfectly preserved without placing any database memory overhead on your server.

  • Client-Side Validation and Array Caps: Inside addToCompare, the script enforces the same business rules written into the backend in Step 2: it checks for duplicates via .includes(productId) and restricts the list size to 4 elements (compareList.length >= 4). This catches invalid state logic before it ever makes a web request.

  • Dynamic Array Query String Generation:

    const query = compareList.map(id => `ids=${id}`).join('&');

    This line demonstrates excellent standard JavaScript capability. If a user selects product IDs 12, 15, and 18, .map and .join convert the array into the query format ids=12&ids=15&ids=18. This perfectly matches the int[] ids parameter format required by your ASP.NET Core Compare controller method from Step 2.

  • Dynamic UI Syncing (updateCompareBar): This function serves as your UI state engine. It looks for a navigation badge or bar element by ID and updates its numbers dynamically. If items exist, it displays the bar; if it's empty, it hides it instantly (display: 'none'). Running this on DOMContentLoaded ensures the tracking badge stays perfectly synced even after a full page reload.

  • Smart Adaptive Reload Routing (removeCompareAndReload): When a user deletes a column inside the matrix view, this method cleans the tracking list and evaluates if a matrix layout can still be maintained. If removing an item drops the selection below the required 2-item minimum boundary rule, it automatically redirects the customer back to the main catalog index page.

Step 6: Implementing the Floating Comparison Bar Overlay

By placing this markup inside your global _Layout.cshtml file, you create an application-wide user interface overlay. It sits completely hidden from view until your Step 5 JavaScript detects items saved in the browser memory.


Razor / Views/Shared/_Layout.cshtml (Compare Preview Bar)
<div id="compareBar" class="compare-sidebar">
    <div class="compare-header">
        <div>
            <h6 class="mb-0 fw-bold">
                <i class="bi bi-arrow-left-right"></i>
                Compare Products
            </h6>
            <small class="text-muted">
                <span id="compareCount">0</span> items selected
            </small>
        </div>
    </div>
    <div class="compare-actions">
        <button class="btn btn-warning compare-btn"
                onclick="goToCompare()">
            <i class="bi bi-lightning-charge"></i>
            Compare Now
        </button>
        <button class="btn btn-outline-danger clear-btn"
                onclick="clearCompareItems()">
            <i class="bi bi-trash3"></i>
        </button>
    </div>
</div>

Core UI & Bootstrap Styling Breakdown

  • Fixed Viewport Pinning (position-fixed bottom-0 start-50 translate-middle-x): This combination of utility classes anchors the panel right at the bottom center of the browser viewport. Combining start-50 with translate-middle-x ensures that the bar stays perfectly centered horizontally across desktops, tablets, and smartphones alike.

  • Layer Isolation Control (z-index:9999;): Setting an explicit high stack index ensures that this floating container will render directly on top of all other page elements—including footers, sliders, and standard content cards—preventing any rendering overlaps while browsing.

  • DOM-Driven Display Toggle (style="display:none;"): Initializing the markup with a hidden display state prevents it from flashing onto the screen during initial page compilation. It hands absolute control over to your updateCompareBar() JavaScript function, which switches it to display: flex cleanly when needed.

  • Dynamic Text Hook Target (id="compareCount"): This tiny span tag acts as the dedicated landing point for your DOM element targeting scripts. By assigning this explicit identifier, your client script can overwrite the value instantly inside the user's view whenever a item card button is triggered.

Step 7: Designing the Modern Comparison Interface Component

You will add this code to your global stylesheet (typically wwwroot/css/comparebar.css). It transforms the basic floating structural container into a beautiful, interactive card component.

CSS / wwwroot/css/comparebar.css
.compare-sidebar {
    position: fixed;
    left: 20px;
    bottom: 20px;
    width: 290px;
    background: #ffffff;
    border-radius: 18px;
    padding: 18px;
    box-shadow: 0 10px 30px rgba(0,0,0,0.12);
    z-index: 9999;
    display: none;
    border: 1px solid #f1f1f1;
    animation: slideUp 0.3s ease;
}

.compare-header {
    display: flex;
    justify-content: space-between;
    align-items: start;
    margin-bottom: 15px;
}

.compare-header h6 {
    font-size: 16px;
    color: #212529;
}

.compare-header small {
    font-size: 13px;
}

.compare-close {
    border: none;
    background: #f8f9fa;
    width: 32px;
    height: 32px;
    border-radius: 50%;
    transition: 0.3s;
}

.compare-close:hover {
    background: #dc3545;
    color: white;
}

.compare-actions {
    display: flex;
    gap: 10px;
}

.compare-btn {
    flex: 1;
    border-radius: 12px;
    font-weight: 600;
    padding: 10px;
}

.clear-btn {
    width: 50px;
    border-radius: 12px;
}

@keyframes slideUp {
    from {
        opacity: 0;
        transform: translateY(30px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

Core UI Styles & Design Breakdown

  • Modern App Aesthetics (border-radius: 18px & Subtle Shadows): Using a smooth border-radius: 18px combined with a clean, ambient drop shadow (box-shadow: 0 10px 30px rgba(0,0,0,0.12)) strips away the dated, flat look. It makes the sidebar appear to hover gracefully over your catalog grids as an independent layer.

  • Intuitive Circular Controls (.compare-close): Designing a explicit circular button (width: 32px; height: 32px; border-radius: 50%) with a smooth background color shift (transition: 0.3s;) on hover creates highly predictable user interactive patterns. Switching immediately to red (#dc3545) signals a destructive "close" action instantly.

  • Flexible Button Alignment Layouts (display: flex; gap: 10px;): Using CSS Flexbox styles with flex: 1 assigned to the primary comparison selector button dynamically stretches it out to fill up all remaining horizontal container width space. This ensures an exact, evenly balanced button bar regardless of different user viewport screen bounds.

  • Hardware-Accelerated Entrance Transitions (@keyframes slideUp): This adds incredible UI fluidness. Instead of jarringly snapping onto the screen when the first mobile device is added, the animation smoothly scales the component from an opacity of 0 up to 1 while translating its vertical space up by 30px. This subtle hint gives your storefront a highly professional, interactive feel.

Step 8: Adding the Compare Button to the Product Details View

By adding this button to Details.cshtml, you complete the comparison user loop. It sits right alongside your wishlist or cart actions, giving the user standard, high-tier retail flexibility.

Razor / Views/Product/Details.cshtml (Compare Button)
<button type="button"
        class="btn btn-outline-dark btn-lg"
        onclick="addToCompare(@Model.Product.Id)">

    <i class="bi bi-arrow-left-right"></i> Compare
</button>

Core UI & Parameter Breakdown

  • Prominent Action Styling (btn-outline-dark btn-lg): Unlike the compact button on the shared product card, the details page demands clear, high-visibility actions. Using btn-lg (large) makes the button match the scale of your "Add to Cart" or "Wishlist" elements. The btn-outline-dark class gives it a premium, sleek contrast that highlights the option without overwhelming your primary checkout buttons.

  • Explicit Object Traversal (@Model.Product.Id): Because your Product Details view utilizes your compound details ViewModel (which contains the nested product specs and review structures we configured in Part 12), you access the identifier by traversing directly into the product entity via @Model.Product.Id.

  • Unified JavaScript Event Reusability: onclick="addToCompare(@Model.Product.Id)" This demonstrates excellent architectural layout logic to your viewers. You don't need to write a separate script for this page. The button reuses the exact same global addToCompare algorithm embedded in your _Layout.cshtml from Step 5, checking localStorage caps and updating your floating bar completely seamlessly.

# Final Features Included

✅ Compare up to 4 products
✅ LocalStorage based compare system
✅ Responsive compare table
✅ Dynamic specifications comparison
✅ Floating compare bar
✅ Same Bootstrap UI design
✅ ASP.NET Core MVC structure maintained
✅ Works with your existing Product model
✅ Works with existing Product Specifications
✅ Add to Cart directly from compare page

Comments