ASP.NET Core MVC Full Project - Part 7: Building a Professional Product Details Page

In this part, we transition from the product grid to the Product Details Page. You will learn how to use Attribute Routing to fetch data by ID, display technical specifications dynamically, and design a modern e-commerce UI with Bootstrap 5. Perfect for developers building a real-world .NET 8 or .NET 9 web application.

Step 1: Creating the ProductDetailViewModel

When a user clicks on a mobile phone, we don't just want to show that one phone. We want to show Related Products to increase sales and check if the user has already added this item to their Wishlist. To pass all this different data to a single view, we create a specialized ViewModel.

The Code: ViewModels/ProductDetailViewModel.cs


C# / ViewModels/ProductDetailViewModel.cs

using MobileShop.Models;
using System.Collections.Generic;

namespace MobileShop.ViewModels
{
    public class ProductDetailViewModel
    {
        // The main product being viewed
        public Product Product { get; set; } = null!;

        // A list of similar products (e.g., from the same brand or category)
        public List<Product> RelatedProducts { get; set; } = new List<Product>();

        // Used to toggle the heart icon if the user has already saved this item
        public bool IsInWishlist { get; set; }
    }
}

Detailed Step-by-Step Explanation

1. The Core Product Object

public Product Product { get; set; } = null!; This holds all the main details of the phone the user is currently viewing, such as the Name, Price, Description, and Specifications.

  • The null! (Null-forgiving operator): This tells the compiler that while the property is technically nullable, we expect it to be populated before the view is rendered.

2. Related Products List

public List<Product> RelatedProducts { get; set; } = new List<Product>(); This is a list of other products from the same Category or Brand.

  • Why? Showing "Users also viewed" or "Similar phones" is a standard e-commerce feature that improves user engagement and SEO dwell time (how long a user stays on your site).

3. The Wishlist Status

public bool IsInWishlist { get; set; } This is a simple true/false flag.

  • The Logic: When the page loads, the controller will check the database to see if the logged-in user has already saved this phone.

  • The UI Benefit: If true, we can change the "Heart" icon color to red, showing the user they have already saved it.

Step 2: Expanding the IProductService Interface

We are adding two distinct methods to our interface. One fetches the specific phone the user wants, and the other finds "similar" phones to keep the user shopping.

The Code: IProductService.cs

C# / Interfaces/IProductService.cs (Expanded)

using MobileShop.Models;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace MobileShop.Services
{
    public interface IProductService
    {
        // Fetches a single product by its primary key
        Task<Product?> GetProductByIdAsync(int id);

        // Fetches a list of similar products based on Category or Brand
        // Default count is set to 4 for a standard UI row
        Task<List<Product>> GetRelatedProductsAsync(int productId, int count = 4);
    }
}

Detailed Step-by-Step Explanation

1. GetProductByIdAsync(int id)

As we discussed, this retrieves the main product. Using the ? (nullable) is your first line of defense against "404 Not Found" errors if a user enters an invalid ID in the URL.

2. GetRelatedProductsAsync(int productId, int count = 4)

This is where the "Advanced" part of your tutorial comes in.

  • The productId Parameter: We pass the current product's ID so we can tell the database: "Find phones like this one, but exclude the one the user is already looking at."

  • The count = 4 Parameter: This is a Optional Parameter. By default, it will fetch 4 items (perfect for a Bootstrap row), but it gives you the flexibility to change it to 3 or 8 later without changing the interface.

3. Why Separate These Methods?

Following the Single Responsibility Principle, we keep these tasks separate. This makes your code easier to test and allows you to reuse the "Related Products" logic in other places, like the Shopping Cart or Checkout page.

Step 3: Implementing GetProductByIdAsync with Eager Loading

In ASP.NET Core MVC, your database tables are connected via Foreign Keys. If you simply fetch a product, these connected tables (like Brand or Category) will be empty (null). To fix this, we use the .Include() method.

The Code: ProductService.cs

C# / Services/ProductService.cs (Implementation)

public async Task<Product?> GetProductByIdAsync(int id)
{
    // Fetches the product with all its related details
    return await _context.Products
        .Include(p => p.Brand)
        .Include(p => p.Category)
        .Include(p => p.ProductImages)
        .Include(p => p.Specifications)
        .Include(p => p.Reviews)
            .ThenInclude(r => r.User) // Deep loading the user who wrote the review
        .FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
}

Detailed Step-by-Step Explanation

1. The Importance of .Include()

By default, EF Core uses "Lazy Loading" (it doesn't load related data automatically). By using .Include(), we tell SQL Server to perform a JOIN operation. This ensures that when the page loads, the Brand name, Category name, and all Product Images are ready to be displayed immediately.

2. Multi-Level Loading with .ThenInclude()

ThenInclude(r => r.User) is a professional touch.

  • The Logic: The Review table has a UserId. To show the actual name of the person who wrote the review, we have to go one level deeper.

  • The Result: You can now display: "Reviewed by: Ilyas" instead of just a User ID number.

3. Security & Logic: IsActive Check

We don't just search by Id == id; we also check p.IsActive.

  • Why? If you "soft delete" or disable a product in your admin panel, users should not be able to view its detail page even if they have the direct link. This is a crucial rule for e-commerce integrity.

4. FirstOrDefaultAsync

This method returns the first product that matches the ID. If no product is found, it returns null. This is why we defined our return type as Product? (nullable) in the interface—it allows the controller to handle missing products gracefully.

This implementation is a masterclass in Recommendation Logic for e-commerce. By fetching related products, you aren't just showing a page; you are creating a "web" of products that keeps users clicking, which is great for both User Engagement and SEO Ranking.

Here is the step-by-step breakdown for your Code With Ilyasoft tutorial.


Step 4: Implementing the Related Products Logic

The goal of this method is to find products that are similar to the one the user is currently viewing. We do this by looking for items in the same Category or from the same Brand.

The Code: ProductService.cs


C# / Services/ProductService.cs (Related Products)

public async Task<List<Product>> GetRelatedProductsAsync(int productId, int count = 4)
{
    // 1. Fetch the base product to get its Brand and Category
    var product = await _context.Products.FindAsync(productId);
    
    // Return empty list if product doesn't exist
    if (product == null) return new List<Product>();

    // 2. Query for similar products
    return await _context.Products
        .Include(p => p.Brand)
        .Where(p => p.Id != productId &&      // Exclude the current product
                    (p.BrandId == product.BrandId || p.CategoryId == product.CategoryId) && 
                    p.IsActive)               // Only show active items
        .OrderBy(x => Guid.NewGuid())        // Optional: Randomize the results
        .Take(count)                          // Limit the result set
        .ToListAsync();
}


Detailed Step-by-Step Explanation

1. Safety First (The Null Check)

Before we can find "related" items, we need to know what the "current" item is. We use FindAsync(productId) to get the Brand and Category. If the product doesn't exist, we return an empty list new List<Product>() to prevent the code from crashing.

2. Avoiding Duplication

p.Id != productId This is a small but vital line. Without it, the "Related Products" section would show the exact same phone the user is already looking at! We must exclude the current ID from the results.

3. The Recommendation Algorithm (OR Logic)

We use a logical OR (||) operator here:

  • Matches the Same Brand (e.g., other Samsung phones).

  • Matches the Same Category (e.g., other Smartphones). This ensures that even if you don't have many phones from the same brand, the user will still see other phones from the same category.

4. Performance Tuning with .Take()

Take(count) In a real shop, you might have 500 smartphones. We don't want to load all 500! We only want enough to fill our UI (usually 4 items). Using .Take() ensures the SQL query is fast and efficient.

Step 5: Creating the Product Details Action

In this step, we connect everything we have built so far. This action will handle the incoming request (e.g., Products/Details/10), fetch all the necessary data, and prepare the UI for the user.

The Code: Controllers/ProductsController.cs

C# / Controllers/ProductsController.cs (Details Action)

/// <summary>
/// Displays the detailed information for a specific product
/// </summary>
public async Task<IActionResult> Details(int id)
{
    // 1. Fetch the main product details from the service
    var product = await _productService.GetProductByIdAsync(id);
    
    // 2. Safety Check: If product doesn't exist or is inactive, return 404
    if (product == null)
    {
        return NotFound();
    }

    // 3. Fetch related products (upselling)
    var relatedProducts = await _productService.GetRelatedProductsAsync(id);
    
    // 4. Placeholder for Wishlist logic (can be expanded later)
    bool isInWishlist = false;

    // 5. Build the ViewModel
    var viewModel = new ProductDetailViewModel
    {
        Product = product,
        RelatedProducts = relatedProducts,
        IsInWishlist = isInWishlist
    };

    return View(viewModel);
}

Detailed Step-by-Step Explanation

1. The "NotFound" Guard Clause

The first thing we do is check if product == null. This is vital for Technical SEO. If a user or a search engine bot hits a link that doesn't exist, we must return a 404 status code. This tells Google to remove that dead link from its search results, keeping your site's SEO healthy.

2. Coordinating Multiple Service Calls

A single Controller action is now handling two separate database operations:

  • Getting the specific product (with all its images and specs).

  • Getting the related products list. Because we used await, these calls are non-blocking, ensuring your server stays fast.

3. The ViewModel Bridge

Instead of passing the Product model directly, we use ProductDetailViewModel. This is the "Container" that holds everything. By passing this to the view, we give our frontend access to the main product, the related items, and the wishlist status all at once.

4. Wishlist Placeholder

bool isInWishlist = false; Explain to your viewers that this is a "placeholder." In a future tutorial, we will add the logic to check the User.Identity to see if the current logged-in user has saved this product.

Step 6: Building the Product Details UI with Bootstrap 5

In this final step of Part 7, we take the data from our ProductDetailViewModel and display it. This view features an interactive image gallery, a stock status tracker, technical specification tabs, and a "Related Products" section.

The Code: Views/Products/Details.cshtml


Razor / Views/Products/Details.cshtml

@using MobileShop.ViewModels
@model ProductDetailViewModel
@{
    ViewData["Title"] = Model.Product.Name;
}

<div class="container py-4">
    <!-- Breadcrumb for SEO and Navigation -->
    <nav aria-label="breadcrumb">
        <ol class="breadcrumb">
            <li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
            <li class="breadcrumb-item"><a asp-controller="Products" asp-action="Index">Products</a></li>
            <li class="breadcrumb-item active">@Model.Product.Name</li>
        </ol>
    </nav>

    <div class="row">
        <!-- Column 1: Product Images & Gallery -->
        <div class="col-lg-5">
            <div class="card shadow-sm">
                <img src="@(Model.Product.MainImageUrl ?? "https://via.placeholder.com/500x500?text=No+Image")"
                     class="card-img-top img-fluid" alt="@Model.Product.Name" id="mainImage"
                     style="cursor: zoom-in;" onclick="openLightbox(this.src)" />
                @if (Model.Product.ProductImages.Any())
                {
                    <div class="card-footer">
                        <div class="row g-2">
                            @foreach (var image in Model.Product.ProductImages)
                            {
                                <div class="col-3">
                                    <img src="@image.ImageUrl" class="img-thumbnail"
                                         style="height: 60px; object-fit: cover; cursor: pointer;"
                                         onclick="document.getElementById('mainImage').src=this.src" />
                                </div>
                            }
                        </div>
                    </div>
                }
            </div>
        </div>

        <!-- Column 2: Product Pricing and Selection -->
        <div class="col-lg-7">
            <div class="ps-lg-4">
                <p class="text-muted mb-1">@Model.Product.Brand?.Name</p>
                <h2>@Model.Product.Name</h2>
                <p class="text-muted">@Model.Product.Model</p>

                <!-- Dynamic Rating Stars -->
                <div class="mb-3">
                    @if (Model.Product.AverageRating > 0)
                    {
                        <span class="text-warning fs-5">
                            @for (int i = 1; i <= 5; i++)
                            {
                                if (i <= Model.Product.AverageRating) { <i class="bi bi-star-fill"></i> }
                                else if (i - 0.5 <= Model.Product.AverageRating) { <i class="bi bi-star-half"></i> }
                                else { <i class="bi bi-star"></i> }
                            }
                        </span>
                        <span class="ms-2">@Model.Product.AverageRating.ToString("F1") (@Model.Product.ReviewCount reviews)</span>
                    }
                </div>

                <!-- Pricing with Discount Badge -->
                <div class="mb-3">
                    @if (Model.Product.OriginalPrice > Model.Product.SalePrice)
                    {
                        <del class="text-muted fs-4">RS @Model.Product.OriginalPrice.ToString("N0")</del>
                        <span class="badge bg-danger ms-2">-@Model.Product.DiscountPercentage% OFF</span>
                    }
                    <h3 class="text-primary">RS @Model.Product.SalePrice.ToString("N0")</h3>
                </div>

                <!-- Stock Management -->
                <div class="mb-3">
                    @if (Model.Product.StockQuantity > 0)
                    {
                        <span class="badge bg-success"><i class="bi bi-check-circle"></i> In Stock</span>
                    }
                    else
                    {
                        <span class="badge bg-danger"><i class="bi bi-x-circle"></i> Out of Stock</span>
                    }
                </div>

                <!-- Action Buttons -->
                <div class="d-flex gap-3 mb-4">
                    <div class="input-group" style="width: 130px;">
                        <button type="button" class="btn btn-outline-secondary" onclick="this.parentNode.querySelector('input').stepDown()"><i class="bi bi-dash"></i></button>
                        <input type="number" value="1" min="1" class="form-control text-center" />
                        <button type="button" class="btn btn-outline-secondary" onclick="this.parentNode.querySelector('input').stepUp()"><i class="bi bi-plus"></i></button>
                    </div>
                    <button class="btn btn-primary btn-lg" @(Model.Product.StockQuantity <= 0 ? "disabled" : "")>
                        <i class="bi bi-cart-plus"></i> Add to Cart
                    </button>
                </div>
            </div>
        </div>
    </div>

    <!-- Info Tabs -->
    <div class="row mt-5">
        <div class="col-12">
            <ul class="nav nav-tabs" role="tablist">
                <li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#description">Description</button></li>
                <li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#specifications">Specifications</button></li>
            </ul>
            <div class="tab-content p-4 border border-top-0 rounded-bottom">
                <div class="tab-pane fade show active" id="description">
                    @Html.Raw(Model.Product.Description?.Replace("\n", "<br/>"))
                </div>
                <div class="tab-pane fade" id="specifications">
                    <!-- Logic for grouping and displaying specs goes here -->
                </div>
            </div>
        </div>
    </div>
</div>

@section Scripts {
    <script>
        function openLightbox(src) {
            document.getElementById('lightboxImage').src = src;
            new bootstrap.Modal(document.getElementById('lightboxModal')).show();
        }
    </script>
}


Detailed Step-by-Step Explanation

1. Interactive Image Gallery

We use a Main Image and a row of Thumbnails.

  • The Logic: A simple JavaScript onclick event swaps the src of the main image when a thumbnail is clicked.

  • Lightbox Feature: We included a Bootstrap Modal at the bottom so users can click the main image to see it in full size—a must-have for mobile shoppers who want to see details.

2. Dynamic Stock & Rating System

The UI gives immediate feedback based on the database values:

  • Star Ratings: We use a @for loop to render bi-star-fill, bi-star-half, or bi-star icons based on the AverageRating.

  • Stock Badges: Using if/else, we change the badge color: Green for "In Stock," Orange for "Low Stock," and Red for "Out of Stock."

3. Advanced Specification Tabs

Instead of a long, messy list, we use Bootstrap Tabs.

  • Grouping Logic: We use @Model.Product.Specifications.GroupBy(s => s.GroupName) to organize technical data (e.g., "Camera," "Battery," "Display") into clean sub-tables. This makes technical data much easier to read.

4. Quantity Toggle & Add to Cart

We added a "Quantity" input with Plus/Minus buttons.

  • UX Detail: Notice the @(Model.Product.StockQuantity <= 0 ? "disabled" : "") logic on the Add to Cart button. This prevents users from trying to buy items that aren't available.

5. Reusing Components (Partial Views)

For the "Related Products" section, we don't rewrite the card code. We use: <partial name="_ProductCard" model="product" /> This maintains a consistent look across your entire website.

Step 7: Linking the Product Card to the Details Page

To allow users to click on a product and see its details, we use the asp-controller and asp-action tag helpers within our _ProductCard.cshtml partial view.

The Code: Views/Shared/_ProductCard.cshtml

Razor / Link to Product Details

<!-- Wrap the product name in a link to its details page -->
<a asp-controller="Products" 
   asp-action="Details" 
   asp-route-id="@Model.Id" 
   class="text-decoration-none text-dark fw-bold">
    @Model.Name
</a>

Detailed Breakdown of the Tag Helpers

1. asp-controller="Products"

This tells the framework to look for the ProductsController.cs file. It automatically handles the "Controller" suffix for you.

2. asp-action="Details"

This points specifically to the public async Task<IActionResult> Details(int id) method we wrote in Step 5.

3. asp-route-id="@Model.Id"

This is the most important part. The asp-route- prefix allows you to pass parameters to your action method.

  • Because our action method expects a parameter named id, we use asp-route-id.

  • If the Product ID is 10, the generated URL will look like: /Products/Details/10.

4. Styling with Bootstrap

  • text-decoration-none: Removes the default blue underline from the link.

  • text-dark: Ensures the product name uses the standard text color rather than the link blue, making it look more like a professional store.

To wrap up Part 7, we need to explain how the user actually reaches the Details page. This simple looking anchor tag is the "link in the chain" that connects your product grid to the full details view we just built.

In ASP.NET Core MVC, we use Tag Helpers to generate URLs dynamically. This is much better than hardcoding links because if you ever change your routing logic, these links will update automatically.


Step 7: Linking the Product Card to the Details Page

To allow users to click on a product and see its details, we use the asp-controller and asp-action tag helpers within our _ProductCard.cshtml partial view.

The Code: Views/Shared/_ProductCard.cshtml

HTML
<a asp-controller="Products" asp-action="Details" asp-route-id="@Model.Id" class="text-decoration-none text-dark">
    @Model.Name
</a>

Detailed Breakdown of the Tag Helpers

1. asp-controller="Products"

This tells the framework to look for the ProductsController.cs file. It automatically handles the "Controller" suffix for you.

2. asp-action="Details"

This points specifically to the public async Task<IActionResult> Details(int id) method we wrote in Step 5.

3. asp-route-id="@Model.Id"

This is the most important part. The asp-route- prefix allows you to pass parameters to your action method.

  • Because our action method expects a parameter named id, we use asp-route-id.

  • If the Product ID is 10, the generated URL will look like: /Products/Details/10.

4. Styling with Bootstrap

  • text-decoration-none: Removes the default blue underline from the link.

  • text-dark: Ensures the product name uses the standard text color rather than the link blue, making it look more like a professional store.


Conclusion of Part 7

Congratulations! You have now completed the entire Product Details flow. Your users can now:

  1. Browse products in a list.

  2. Click a product name or image.

  3. View deep details including images, specs, and related items.


Comments