• Home
  • About
    • Hanna's Blog photo

      Hanna's Blog

      I wanna be a global developer.

    • Learn More
    • Email
    • LinkedIn
    • Github
  • Posts
    • All Posts
    • All Tags
  • Projects

[.Net 6] Blazor Ecommerce

08 Jun 2022

Reading time ~57 minutes

Building a Walking Skeleton

Create Project

  • Click Blazor WebAssembly
  • Click ASP.NET Core hosted. It will create Client, Server and Shared Projects.
Create Project

Model

Create Product

  • [Column(TypeName = "decimal(18,2)")] is for a decimal error on database.
  • When you use the decimal type in your variable, you should set a scale of decimal.
  • Because the flotting point can make a misunderstand in memory.

  • BlazorEcommerce.Shared/Product.cs
public class Product
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public string ImageUrl { get; set; } = string.Empty;
    [Column(TypeName = "decimal(18,2)")]
    public decimal Price { get; set; }
}

Database

Packages in Server

  • Download Microsoft.EntityFrameworkCore, Microsoft.EntityFrameworkCore.Design and Microsoft.EntityframeworkCore.Sqlserver from Nuget Packages.
  • Install dotnet ef with a command dotnet tool install --global dotnet-ef.
Packages in Server

Data Context

  • BlazorEcommerce.Server/Data/DataContext.cs
public class DataContext : DbContext
{
    public DataContext(DbContextOptions<DataContext> options) : base(options){ }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>().HasData(
            new Product
            {
                Id = 1,
                Title = "The Hitchhiker's Guide to the Galaxy",
                Description = "The Hitchhiker's Guide to the Galaxy[note 1] (sometimes referred to as HG2G,[1] HHGTTG,[2] H2G2,[3] or tHGttG) is a comedy science fiction franchise created by Douglas Adams. Originally a 1978 radio comedy broadcast on BBC Radio 4, it was later adapted to other formats, including stage shows, novels, comic books, a 1981 TV series, a 1984 text-based computer game, and 2005 feature film.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/en/b/bd/H2G2_UK_front_cover.jpg",
                Price = 9.99m
            },
            new Product
            {
                Id = 2,
                Title = "Ready Player One",
                Description = "Ready Player One is a 2011 science fiction novel, and the debut novel of American author Ernest Cline. The story, set in a dystopia in 2045, follows protagonist Wade Watts on his search for an Easter egg in a worldwide virtual reality game, the discovery of which would lead him to inherit the game creator's fortune. Cline sold the rights to publish the novel in June 2010, in a bidding war to the Crown Publishing Group (a division of Random House).[1] The book was published on August 16, 2011.[2] An audiobook was released the same day; it was narrated by Wil Wheaton, who was mentioned briefly in one of the chapters.[3][4]Ch. 20 In 2012, the book received an Alex Award from the Young Adult Library Services Association division of the American Library Association[5] and won the 2011 Prometheus Award.[6]",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/en/a/a4/Ready_Player_One_cover.jpg",
                Price = 7.99m
            },
            new Product
            {
                Id = 3,
                Title = "Nineteen Eighty-Four",
                Description = "Nineteen Eighty-Four (also stylised as 1984) is a dystopian social science fiction novel and cautionary tale written by English writer George Orwell. It was published on 8 June 1949 by Secker & Warburg as Orwell's ninth and final book completed in his lifetime. Thematically, it centres on the consequences of totalitarianism, mass surveillance and repressive regimentation of people and behaviours within society.[2][3] Orwell, a democratic socialist, modelled the totalitarian government in the novel after Stalinist Russia and Nazi Germany.[2][3][4] More broadly, the novel examines the role of truth and facts within politics and the ways in which they are manipulated.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/commons/c/c3/1984first.jpg",
                Price = 6.99m
            });
    }

    public DbSet<Product> Products { get; set; }
}

Set Database

  • BlazorEcommerce.Server/appsettings.json
{
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=BlazorEcommerce;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"
  },
  ...
  • Add information about database to server program.

  • BlazorEcommerce.Server/Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<DataContext>(options =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
}); 
...

Update Database

  • In console, tpye dotnet ef migrations add CreateInitial and dotnet ef database update.
Update Database

Controller

  • You can your own web Api on Controller.
  • Send and retuen data is json type.
  • When you create new controller, click API Controller - Empty.
Update Database
  • BlazorEcommerce.Server/Controllers/ProductController.cs
[Route("api/[controller]")]
[ApiController]
public class ProductController : ControllerBase
{
    private readonly DataContext _context;

    public ProductController(DataContext context)
    {
        _context = context;
    }

    [HttpGet]
    public async Task<ActionResult<List<Product>>> GetProduct()
    {
        var products = await _context.Products.ToListAsync();
        return Ok(products);
    }
}

Swagger

Package in Server

  • Download Swashbuckle.AspNetCore from Nuget Packages.
Package in Server

Set

  • BlazorEcommerce.Server/Program.cs
...
builder.Services.AddRazorPages();

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
...
var app = builder.Build();

app.UseSwaggerUI();
app.UseSwagger();
...

Run

  • Change Url to .../swagger/index.html.
Run

Show Products

Create ProductList Component

  • Razor file is for view.
  • BlazorEcommerce.Client/Shared/ProductList.razor
<ul class="list-unstyled">
    @foreach(var product in Products)
    {
        <li class="media my-3">
            <div class="media-img-wrapper mr-2">
                <a href="#">
                    <img class="media-img" src="@product.ImageUrl" alt="@product.Title"/>
                </a>
            </div>
            <div class="media-body">
                <a href="#">
                    <h4 class="mb-0">@product.Title</h4>
                </a>
                <p>@product.Description</p>
                <h5 class="price">
                    $@product.Price
                </h5>
            </div>
        </li>
    }
</ul>
  • cs file is for code.
  • BlazorEcommerce.Client/Shared/ProductList.razor.cs
public partial class ProductList
{
    [Inject]
    public HttpClient Http { get; set; }

    private static List<Product> Products = new List<Product>();

    protected override async Task OnInitializedAsync()
    {
        var result = await Http.GetFromJsonAsync<List<Product>>("api/product");
        if (result != null)
            Products = result;
    }
}
  • css file is for design.
  • BlazorEcommerce.Client/Shared/ProductList.razor.css
.media-img {
    max-height: 200px;
    max-width: 200px;
    border-radius: 6px;
    margin-bottom: 10px;
    transition: transform .2s;
}

    .media-img:hover {
        transform: scale(1.1);
    }

.media {
    display: flex;
    align-items: flex-start;
}

.media-body{
    flex: 1;
}

.media-img-wrapper {
    width: 200px;
    text-align: center;
}

.price {
    color: green;
}

@media (max-width:1023.98px) {
    .media {
        flex-direction: column;
    }

    .media-img-wrapper{
        width: 100%;
        height: auto;
    }
}

Add ProductList Component on Index

  • BlazorEcommerce.Client/Shared/ProductList.razor
@page "/"

<PageTitle>My Shop</PageTitle>

<ProductList/>

Run

Run

Naming Options

  • Click Tools-Options-Text Editor-C#-Code Style-Naming.
  • To create a new naming Style, click Manage naming styles.
Run

Products

Models for Product Service

Models

  • BlazorEcommerce.Shared/Product.cs
public class Product
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public string ImageUrl { get; set; } = string.Empty;
    [Column(TypeName = "decimal(18,2)")]
    public Category? Category { get; set; }
    public int CategoryId { get; set; }
    public bool Featured { get; set; } = false;
    public List<ProductVariant> Variants { get; set; } = new List<ProductVariant>();
}
  • BlazorEcommerce.Shared/Category.cs
public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Url { get; set; } = string.Empty;
}
  • BlazorEcommerce.Shared/ProductVariant.cs
public class ProductVariant
{
    [JsonIgnore]
    public Product Product { get; set; }
    public int ProductId { get; set; }
    public ProductType ProductType { get; set; }
    public int ProductTypeId { get; set; }
    [Column(TypeName="decimal(18,2)")]
    public decimal Price { get; set; }
    [Column(TypeName="decimal(18,2)")]
    public decimal OriginalPrice { get; set; }
}
  • JsonIgnore means this property is excluded from Json Serialization.

  • BlazorEcommerce.Shared/ProductVariant.cs

public class ProductType
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}
  • BlazorEcommerce.Shared/ProductSearchResult.cs
public class ProductSearchResult
{
    public List<Product> Products { get; set; } = new List<Product>();
    public int Pages { get; set; }
    public int CurrentPage { get; set; }

}

Seeds

  • BlazorEcommerce.Server/Data/DataContext.cs
public class DataContext : DbContext
{
    public DataContext(DbContextOptions<DataContext> options) : base(options)
    {

    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ProductVariant>()
            .HasKey(p => new { p.ProductId, p.ProductTypeId });

        modelBuilder.Entity<ProductType>().HasData(
            new ProductType { Id = 1, Name = "Default" },
            new ProductType { Id = 2, Name = "Paperback" },
            new ProductType { Id = 3, Name = "E-Book" },
            new ProductType { Id = 4, Name = "Audiobook" },
            new ProductType { Id = 5, Name = "Stream" },
            new ProductType { Id = 6, Name = "Blu-ray" },
            new ProductType { Id = 7, Name = "VHS" },
            new ProductType { Id = 8, Name = "PC" },
            new ProductType { Id = 9, Name = "PlayStation" },
            new ProductType { Id = 10, Name = "Xbox" });

        modelBuilder.Entity<Category>().HasData(
            new Category
            {
                Id = 1,
                Name = "Books",
                Url = "books"
            },
            new Category
            {
                Id = 2,
                Name = "Movies",
                Url = "movies"
            },
            new Category
            {
                Id = 3,
                Name = "Video Games",
                Url = "video-games"
            });

        modelBuilder.Entity<Product>().HasData(
            new Product
            {
                Id = 1,
                CategoryId = 1,
                Title = "The Hitchhiker's Guide to the Galaxy",
                Description = "The Hitchhiker's Guide to the Galaxy[note 1] (sometimes referred to as HG2G,[1] HHGTTG,[2] H2G2,[3] or tHGttG) is a comedy science fiction franchise created by Douglas Adams. Originally a 1978 radio comedy broadcast on BBC Radio 4, it was later adapted to other formats, including stage shows, novels, comic books, a 1981 TV series, a 1984 text-based computer game, and 2005 feature film.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/en/b/bd/H2G2_UK_front_cover.jpg",
                Featured = true
            },
            new Product
            {
                Id = 2,
                CategoryId = 1,
                Title = "Ready Player One",
                Description = "Ready Player One is a 2011 science fiction novel, and the debut novel of American author Ernest Cline. The story, set in a dystopia in 2045, follows protagonist Wade Watts on his search for an Easter egg in a worldwide virtual reality game, the discovery of which would lead him to inherit the game creator's fortune. Cline sold the rights to publish the novel in June 2010, in a bidding war to the Crown Publishing Group (a division of Random House).[1] The book was published on August 16, 2011.[2] An audiobook was released the same day; it was narrated by Wil Wheaton, who was mentioned briefly in one of the chapters.[3][4]Ch. 20 In 2012, the book received an Alex Award from the Young Adult Library Services Association division of the American Library Association[5] and won the 2011 Prometheus Award.[6]",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/en/a/a4/Ready_Player_One_cover.jpg"
            },
            new Product
            {
                Id = 3,
                CategoryId = 1,
                Title = "Nineteen Eighty-Four",
                Description = "Nineteen Eighty-Four (also stylised as 1984) is a dystopian social science fiction novel and cautionary tale written by English writer George Orwell. It was published on 8 June 1949 by Secker & Warburg as Orwell's ninth and final book completed in his lifetime. Thematically, it centres on the consequences of totalitarianism, mass surveillance and repressive regimentation of people and behaviours within society.[2][3] Orwell, a democratic socialist, modelled the totalitarian government in the novel after Stalinist Russia and Nazi Germany.[2][3][4] More broadly, the novel examines the role of truth and facts within politics and the ways in which they are manipulated.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/commons/c/c3/1984first.jpg"
            },
            new Product
            {
                Id = 4,
                CategoryId = 2,
                Title = "The Matrix",
                Description = "The Matrix is a 1999 science fiction action film written and directed by the Wachowskis, and produced by Joel Silver. Starring Keanu Reeves, Laurence Fishburne, Carrie-Anne Moss, Hugo Weaving, and Joe Pantoliano, and as the first installment in the Matrix franchise, it depicts a dystopian future in which humanity is unknowingly trapped inside a simulated reality, the Matrix, which intelligent machines have created to distract humans while using their bodies as an energy source. When computer programmer Thomas Anderson, under the hacker alias \"Neo\", uncovers the truth, he \"is drawn into a rebellion against the machines\" along with other people who have been freed from the Matrix.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/en/c/c1/The_Matrix_Poster.jpg"
            },
            new Product
            {
                Id = 5,
                CategoryId = 2,
                Title = "Back to the Future",
                Description = "Back to the Future is a 1985 American science fiction film directed by Robert Zemeckis. Written by Zemeckis and Bob Gale, it stars Michael J. Fox, Christopher Lloyd, Lea Thompson, Crispin Glover, and Thomas F. Wilson. Set in 1985, the story follows Marty McFly (Fox), a teenager accidentally sent back to 1955 in a time-traveling DeLorean automobile built by his eccentric scientist friend Doctor Emmett \"Doc\" Brown (Lloyd). Trapped in the past, Marty inadvertently prevents his future parents' meeting—threatening his very existence—and is forced to reconcile the pair and somehow get back to the future.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/en/d/d2/Back_to_the_Future.jpg",
                Featured = true
            },
            new Product
            {
                Id = 6,
                CategoryId = 2,
                Title = "Toy Story",
                Description = "Toy Story is a 1995 American computer-animated comedy film produced by Pixar Animation Studios and released by Walt Disney Pictures. The first installment in the Toy Story franchise, it was the first entirely computer-animated feature film, as well as the first feature film from Pixar. The film was directed by John Lasseter (in his feature directorial debut), and written by Joss Whedon, Andrew Stanton, Joel Cohen, and Alec Sokolow from a story by Lasseter, Stanton, Pete Docter, and Joe Ranft. The film features music by Randy Newman, was produced by Bonnie Arnold and Ralph Guggenheim, and was executive-produced by Steve Jobs and Edwin Catmull. The film features the voices of Tom Hanks, Tim Allen, Don Rickles, Wallace Shawn, John Ratzenberger, Jim Varney, Annie Potts, R. Lee Ermey, John Morris, Laurie Metcalf, and Erik von Detten. Taking place in a world where anthropomorphic toys come to life when humans are not present, the plot focuses on the relationship between an old-fashioned pull-string cowboy doll named Woody and an astronaut action figure, Buzz Lightyear, as they evolve from rivals competing for the affections of their owner, Andy Davis, to friends who work together to be reunited with Andy after being separated from him.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/en/1/13/Toy_Story.jpg"

            },
            new Product
            {
                Id = 7,
                CategoryId = 3,
                Title = "Half-Life 2",
                Description = "Half-Life 2 is a 2004 first-person shooter game developed and published by Valve. Like the original Half-Life, it combines shooting, puzzles, and storytelling, and adds features such as vehicles and physics-based gameplay.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/en/2/25/Half-Life_2_cover.jpg"

            },
            new Product
            {
                Id = 8,
                CategoryId = 3,
                Title = "Diablo II",
                Description = "Diablo II is an action role-playing hack-and-slash computer video game developed by Blizzard North and published by Blizzard Entertainment in 2000 for Microsoft Windows, Classic Mac OS, and macOS.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/en/d/d5/Diablo_II_Coverart.png"
            },
            new Product
            {
                Id = 9,
                CategoryId = 3,
                Title = "Day of the Tentacle",
                Description = "Day of the Tentacle, also known as Maniac Mansion II: Day of the Tentacle, is a 1993 graphic adventure game developed and published by LucasArts. It is the sequel to the 1987 game Maniac Mansion.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/en/7/79/Day_of_the_Tentacle_artwork.jpg",
                Featured = true
            },
            new Product
            {
                Id = 10,
                CategoryId = 3,
                Title = "Xbox",
                Description = "The Xbox is a home video game console and the first installment in the Xbox series of video game consoles manufactured by Microsoft.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/commons/4/43/Xbox-console.jpg"
            },
            new Product
            {
                Id = 11,
                CategoryId = 3,
                Title = "Super Nintendo Entertainment System",
                Description = "The Super Nintendo Entertainment System (SNES), also known as the Super NES or Super Nintendo, is a 16-bit home video game console developed by Nintendo that was released in 1990 in Japan and South Korea.",
                ImageUrl = "https://upload.wikimedia.org/wikipedia/commons/e/ee/Nintendo-Super-Famicom-Set-FL.jpg"
            });

        modelBuilder.Entity<ProductVariant>().HasData(
            new ProductVariant
            {
                ProductId = 1,
                ProductTypeId = 2,
                Price = 9.99m,
                OriginalPrice = 19.99m
            },
            new ProductVariant
            {
                ProductId = 1,
                ProductTypeId = 3,
                Price = 7.99m
            },
            new ProductVariant
            {
                ProductId = 1,
                ProductTypeId = 4,
                Price = 19.99m,
                OriginalPrice = 29.99m
            },
            new ProductVariant
            {
                ProductId = 2,
                ProductTypeId = 2,
                Price = 7.99m,
                OriginalPrice = 14.99m
            },
            new ProductVariant
            {
                ProductId = 3,
                ProductTypeId = 2,
                Price = 6.99m
            },
            new ProductVariant
            {
                ProductId = 4,
                ProductTypeId = 5,
                Price = 3.99m
            },
            new ProductVariant
            {
                ProductId = 4,
                ProductTypeId = 6,
                Price = 9.99m
            },
            new ProductVariant
            {
                ProductId = 4,
                ProductTypeId = 7,
                Price = 19.99m
            },
            new ProductVariant
            {
                ProductId = 5,
                ProductTypeId = 5,
                Price = 3.99m,
            },
            new ProductVariant
            {
                ProductId = 6,
                ProductTypeId = 5,
                Price = 2.99m
            },
            new ProductVariant
            {
                ProductId = 7,
                ProductTypeId = 8,
                Price = 19.99m,
                OriginalPrice = 29.99m
            },
            new ProductVariant
            {
                ProductId = 7,
                ProductTypeId = 9,
                Price = 69.99m
            },
            new ProductVariant
            {
                ProductId = 7,
                ProductTypeId = 10,
                Price = 49.99m,
                OriginalPrice = 59.99m
            },
            new ProductVariant
            {
                ProductId = 8,
                ProductTypeId = 8,
                Price = 9.99m,
                OriginalPrice = 24.99m,
            },
            new ProductVariant
            {
                ProductId = 9,
                ProductTypeId = 8,
                Price = 14.99m
            },
            new ProductVariant
            {
                ProductId = 10,
                ProductTypeId = 1,
                Price = 159.99m,
                OriginalPrice = 299m
            },
            new ProductVariant
            {
                ProductId = 11,
                ProductTypeId = 1,
                Price = 79.99m,
                OriginalPrice = 399m
            });
    }

    public DbSet<Product> Products { get; set; }
    public DbSet<Category> Categories { get; set; }
    public DbSet<ProductType> ProductTypes { get; set; }
    public DbSet<ProductVariant> ProductVariants { get; set; }
}

Datebase

  • Enter dotnet ef migrations add Products and dotnet ef update database in CLI.

Service Responce with Generics

  • BlazorEcommerce.Shared/ServiceResponse.cs
public class ServiceResponse<T>
{
    public T? Data { get; set; }
    public bool Success { get; set; } = true;
    public string Message { get; set; } = string.Empty;
}
  • On T every variable types can be posited.

Product Service in Server

Interface

  • BlazorEcommerce.Server/Services/ProductService/IProductService.cs
public interface IProductService
{
    Task<ServiceResponse<List<Product>>> GetProductsAsync();
    Task<ServiceResponse<Product>> GetProductAsync(int productId);
    Task<ServiceResponse<List<Product>>> GetProductsByCategory(string categoryUrl);
    Task<ServiceResponse<ProductSearchResult>> SearchProducts(string searchText, int page);
    Task<ServiceResponse<List<string>>> GetProductSearchSuggestions(string searchText);
    Task<ServiceResponse<List<Product>>> GetFeaturedProducts();
}

Implement

  • BlazorEcommerce.Server/Services/ProductService/ProductService.cs
public class ProductService : IProductService
{
    private readonly DataContext _context;

    public ProductService(DataContext context)
    {
        _context = context;
    }

    public async Task<ServiceResponse<List<Product>>> GetProductsAsync()
    {
        var response = new ServiceResponse<List<Product>>
        {
            Data = await _context.Products.Include(p => p.Variants).ToListAsync()
        };

        return response;
    }

    public async Task<ServiceResponse<Product>> GetProductAsync(int productId)
    {
        var response = new ServiceResponse<Product>();
        var product = await _context.Products
            .Include(p => p.Variants)
            .ThenInclude(v => v.ProductType)
            .FirstOrDefaultAsync(p => p.Id == productId);
        if(product == null)
        {
            response.Success = false;
            response.Message = "Sorry, but this product does not exist.";
        }
        else
        {
            response.Data = product;
        }

        return response;
    }

    public async Task<ServiceResponse<List<Product>>> GetProductsByCategory(string categoryUrl)
    {
        var response = new ServiceResponse<List<Product>>
        {
            Data = await _context.Products
                .Where(p => p.Category.Url.ToLower().Equals(categoryUrl.ToLower()))
                .Include(p => p.Variants)
                .ToListAsync()
        };

        return response;
    }

    public async Task<ServiceResponse<ProductSearchResult>> SearchProducts
            (string searchText, int page)
    {
        var pageResults = 2f;
        var pageCount 
                = Math.Ceiling((await FindProductsBySearchText(searchText)).Count / pageResults);
        var products = await _context.Products
                            .Where(p => p.Title.ToLower().Equals(searchText.ToLower())
                            || p.Description.ToLower().Contains(searchText.ToLower()))
                            .Include(p => p.Variants)
                            .Skip((page - 1) * (int)pageResults)
                            .Take((int)pageResults)
                            .ToListAsync();

        var response = new ServiceResponse<ProductSearchResult>
        {
            Data = new ProductSearchResult
            {
                Products = products,
                CurrentPage = page,
                Pages = (int)pageCount
            }
        };

        return response;
    }

    private async Task<List<Product>> FindProductsBySearchText(string searchText)
    {
        return await _context.Products
                            .Where(p => p.Title.ToLower().Equals(searchText.ToLower())
                            || p.Description.ToLower().Contains(searchText.ToLower()))
                            .Include(p => p.Variants)
                            .ToListAsync();
    }

    public async Task<ServiceResponse<List<string>>> GetProductSearchSuggestions(string searchText)
    {
        var products = await FindProductsBySearchText(searchText);
        List<string> result = new List<string>();

        foreach(var product in products)
        {
            if(product.Title.Contains(searchText, StringComparison.OrdinalIgnoreCase))
                result.Add(product.Title);

            if(product.Description != null)
            {
                var punctuation = product.Description.Where(char.IsPunctuation)
                    .Distinct().ToArray();
                var words = product.Description.Split()
                    .Select(s => s.Trim(punctuation));

                foreach(var word in words)
                {
                    if(word.Contains(searchText, StringComparison.OrdinalIgnoreCase) 
                            && !result.Contains(word))
                        result.Add(word);
                }
            }
        }

        return new ServiceResponse<List<string>> { Data = result };
    }

    public async Task<ServiceResponse<List<Product>>> GetFeaturedProducts()
    {
        var response = new ServiceResponse<List<Product>>
        {
            Data = await _context.Products
                .Where(p => p.Featured)
                .Include(p => p.Variants)
                .ToListAsync()
        };

        return response;
    }
}
  • Include include overlapped data to return value.
  • ThenInclude is for multi level of Include.
  • Math.Ceiling round up the value.
  • Skip skips over the first n elements in the sequence and returns a new sequence containing the remaining elements after the first n elements.
  • Take extracts the first n elements from the beginning of the target sequence and returns a new sequence containing only the elements taken.

Controller

  • BlazorEcommerce.Server/Controllers/ProductController.cs
[Route("api/[controller]")]
[ApiController]
public class ProductController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpGet]
    public async Task<ActionResult<ServiceResponse<List<Product>>>> GetProduct()
    {
        var result = await _productService.GetProductsAsync();
        return Ok(result);
    }

    [HttpGet("{productId}")]
    public async Task<ActionResult<ServiceResponse<Product>>> GetProduct(int productId)
    {
        var result = await _productService.GetProductAsync(productId);
        return Ok(result);
    }

    [HttpGet("category/{categoryUrl}")]
    public async Task<ActionResult<ServiceResponse<List<Product>>>> GetProductsByCategory
            (string categoryUrl)
    {
        var result = await _productService.GetProductsByCategory(categoryUrl);
        return Ok(result);
    }

    [HttpGet("search/{searchText}/{page}")]
    public async Task<ActionResult<ServiceResponse<ProductSearchResult>>> SearchProducts
            (string searchText, int page = 1)
    {
        var result = await _productService.SearchProducts(searchText, page);
        return Ok(result);
    }

    [HttpGet("searchsuggestions/{searchText}")]
    public async Task<ActionResult<ServiceResponse<List<string>>>> GetProductSearchSuggestions
            (string searchText)
    {
        var result = await _productService.GetProductSearchSuggestions(searchText);
        return Ok(result);
    }

    [HttpGet("featured")]
    public async Task<ActionResult<ServiceResponse<List<Product>>>> GetFeaturedProduct()
    {
        var result = await _productService.GetFeaturedProducts();
        return Ok(result);
    }
}

Dependency Injection

  • BlazorEcommerce.Server/Program.cs
...
builder.Services.AddScoped<ICartService, CartService>();
...

Product Service in Client

Interface

  • BlazorEcommerce.Client/Services/ProductService/IProductService.cs
public interface IProductService
{
    event Action ProductsChanged;

    List<Product> Products { get; set; }
    string Message { get; set; }
    int CurrentPage { get; set; }
    int PageCount { get; set; }
    string LastSearchText { get; set; }
    Task GetProducts(string? categoryUrl = null);
    Task<ServiceResponse<Product>> GetProduct(int productId);
    Task SearchProducts(string searchText, int page);
    Task<List<string>> GetProductSearchSuggestions(string searchText);
}

Interface

  • BlazorEcommerce.Client/Services/ProductService/ProductService.cs
public class ProductService : IProductService
{
    private readonly HttpClient _http;

    public ProductService(HttpClient http)
    {
        _http = http;
    }

    public List<Product> Products { get; set; } = new List<Product>();
    public string Message { get; set; } = "Loading Products...";
    public int CurrentPage { get; set; } = 1;
    public int PageCount { get; set; } = 0;
    public string LastSearchText { get; set; } = string.Empty;

    public event Action ProductsChanged;
    public async Task<ServiceResponse<Product>> GetProduct(int productId)
    {
        var result = await _http.GetFromJsonAsync<ServiceResponse<Product>>
            ($"api/product/{productId}");
        return result;
    }

    public async Task GetProducts(string? categoryUrl = null)
    {
        var result = categoryUrl == null ?
            await _http.GetFromJsonAsync<ServiceResponse<List<Product>>>("api/product/featured") :
            await _http.GetFromJsonAsync<ServiceResponse<List<Product>>>
                ($"api/product/category/{categoryUrl}");
        if(result != null && result.Data != null)
            Products = result.Data;

        CurrentPage = 1;
        PageCount = 0;

        if (!Products.Any())
            Message = "No Product Found!";
        ProductsChanged.Invoke();
    }

    public async Task<List<string>> GetProductSearchSuggestions(string searchText)
    {
        var result = await _http.GetFromJsonAsync<ServiceResponse<List<string>>>
            ($"api/product/searchsuggestions/{searchText}");
        return result.Data;
    }

    public async Task SearchProducts(string searchText, int page)
    {
        LastSearchText = searchText;
        var result = await _http.GetFromJsonAsync<ServiceResponse<ProductSearchResult>>
            ($"api/product/search/{searchText}/{page}");
        if (result != null && result.Data != null)
        {
            Products = result.Data.Products;
            CurrentPage = result.Data.CurrentPage;
            PageCount = result.Data.Pages;
        }
        if (!Products.Any())
            Message = "No Products found.";
        ProductsChanged?.Invoke();
    }
}

Dependency Injection

  • BlazoreEcommerce.Client/Program.cs
...
builder.Services.AddScoped<IProductService, ProductService>();
...

Category

Category Service in Server

interface

  • BlazoreEcommerce.Server/Services/CategoryService/ICategoryService.cs
public interface ICategoryService
{
    Task<ServiceResponse<List<Category>>> GetCategories();
}

Implement

  • BlazoreEcommerce.Server/Services/CategoryService/CategoryService.cs
public class CategoryService : ICategoryService
{
    private readonly DataContext _context;

    public CategoryService(DataContext context)
    {
        _context = context;
    }

    public async Task<ServiceResponse<List<Category>>> GetCategories()
    {
        var categories = await _context.Categories.ToListAsync();
        return new ServiceResponse<List<Category>>
        {
            Data = categories
        };
    }
}

Controller

  • BlazoreEcommerce.Server/Controllers/CategoryController.cs
[Route("api/[controller]")]
[ApiController]
public class CategoryController : ControllerBase
{
    private readonly ICategoryService _categoryService;

    public CategoryController(ICategoryService categoryService)
    {
        _categoryService = categoryService;
    }

    [HttpGet]
    public async Task<ActionResult<ServiceResponse<List<Category>>>> GetCategories()
    {
        var result = await _categoryService.GetCategories();
        return Ok(result);
    }
}

Dependency Injection

  • BlazorEcommerce.Server/Program.cs
...
builder.Services.AddScoped<ICategoryService, CategoryService>();
...

Category Service in Client

Interface

  • BlazorEcommerce.Client/Services/CategoryService/ICategoryService.cs
public interface ICategoryService
{
    List<Category> Categories { get; set; }
    Task GetCategories();
}

Implement

  • BlazorEcommerce.Client/Services/CategoryService/CategoryService.cs
public class CategoryService : ICategoryService
{
    private readonly HttpClient _http;

    public CategoryService(HttpClient http)
    {
        _http = http;
    }

    public List<Category> Categories { get; set; } = new List<Category>();

    public async Task GetCategories()
    {
        var response = await _http.GetFromJsonAsync<ServiceResponse<List<Category>>>("api/category");
        if(response != null && response.Data != null)
            Categories = response.Data;
    }
}

Dependency Injection

  • BlazorEcommercs.Client/Program.cs
...
builder.Services.AddScoped<ICategoryService, CategoryService>();
...

Cart

Local Storage

  • To save cart data, you can use local storage in bkazor application.

Package in Client

  • Download Blazored.LocalStorage.
Package in Client

Set

  • BlazorEcommercs.Client/Program.cs
...
builder.Services.AddBlazoredLocalStorage();
...

Models

  • BlazorEcommerce.Shared/CartItem.cs
public class CartItem
{
    public int ProductId { get; set; }
    public int ProductTypeId { get; set; }
    public int Quantity { get; set; } = 1;
}
  • BlazorEcommerce.Shared/CartProductResponse.cs
public class CartProductResponse
{
    public int ProductId { get; set; }
    public string Title { get; set; } = string.Empty;
    public int ProductTypeId { get; set; }
    public string ProductType { get; set; } = string.Empty;
    public string ImageUrl { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int Quantity { get; set; }
}

Cart Service in Server

Interface

  • BlazorEcommerce.Server/Services/ICartService.cs
public interface ICartService
{
    Task<ServiceResponse<List<CartProductResponse>>> GetCartProducts(List<CartItem> cartItems);
}

Implement

  • BlazorEcommerce.Server/Services/CartService.cs
public class CartService : ICartService
{
    private DataContext _context;

    public CartService(DataContext context)
    {
        _context = context;
    }

    public async Task<ServiceResponse<List<CartProductResponse>>> GetCartProducts
            (List<CartItem> cartItems)
    {
        var result = new ServiceResponse<List<CartProductResponse>>()
        {
            Data = new List<CartProductResponse>()
        };

        foreach(var item in cartItems)
        {
            var product = await _context.Products
                .Where(p => p.Id == item.ProductId)
                .FirstOrDefaultAsync();

            if (product == null)
                continue;

            var productVariant = await _context.ProductVariants
                .Where(v => v.ProductId == item.ProductId
                && v.ProductTypeId == item.ProductTypeId)
                .Include(v => v.ProductType)
                .FirstOrDefaultAsync();

            if (productVariant == null)
                continue;

            var cartProduct = new CartProductResponse
            {
                ProductId = product.Id,
                Title = product.Title,
                ImageUrl = product.ImageUrl,
                Price = productVariant.Price,
                ProductType = productVariant.ProductType.Name,
                ProductTypeId = productVariant.ProductTypeId,
                Quantity = item.Quantity
            };
            
            result.Data.Add(cartProduct);
        }

        return result;
    }
}

Dependency Injection

  • BlazorEcommerce.Server/Program.cs
...
builder.Services.AddScoped<ICartService, CartService>();
...

Cart Service in Client

Interface

  • BlazorEcommerce.Client/Services/ICartService.cs
public interface ICartService
{
    event Action OnChange;
    Task AddToCart(CartItem cartItem);
    Task<List<CartItem>> GetCartItems();
    Task<List<CartProductResponse>> GetCartProducts();
    Task RemoveProductFromCart(int productId, int productTypeId);
    Task UpdateQuantity(CartProductResponse product);
}
  • BlazorEcommerce.Client/Services/CartService.cs
public class CartService : ICartService
{
    ILocalStorageService _localStorage;
    private readonly HttpClient _http;

    public CartService(ILocalStorageService localStorage, HttpClient http)
    {
        _localStorage = localStorage;
        _http = http;
    }

    public event Action OnChange;

    public async Task AddToCart(CartItem cartItem)
    {
        var cart = await _localStorage.GetItemAsync<List<CartItem>>("cart");
        if(cart == null)
            cart = new List<CartItem>();

        var sameItem = cart.Find(x => x.ProductId == cartItem.ProductId &&
            x.ProductTypeId == cartItem.ProductTypeId);
        if(sameItem == null)
            cart.Add(cartItem);
        else
            sameItem.Quantity += cartItem.Quantity;

        await _localStorage.SetItemAsync("cart", cart);
        OnChange.Invoke();
    }

    public async Task<List<CartItem>> GetCartItems()
    {
        var cart = await _localStorage.GetItemAsync<List<CartItem>>("cart");
        if (cart == null)
            cart = new List<CartItem>();
        return cart;
    }

    public async Task<List<CartProductResponse>> GetCartProducts()
    {
        var cartItems = await _localStorage.GetItemAsync<List<CartItem>>("cart");
        var response = await _http.PostAsJsonAsync("api/cart/products", cartItems);
        var cartProducts =
            await response.Content.ReadFromJsonAsync<ServiceResponse<List<CartProductResponse>>>();
        return cartProducts.Data;
    }

    public async Task RemoveProductFromCart(int productId, int productTypeId)
    {
        var cart = await _localStorage.GetItemAsync<List<CartItem>>("cart");
        if (cart == null)
            return;

        var cartItem = cart.Find(x => x.ProductId == productId
        && x.ProductTypeId == productTypeId);
        if (cartItem != null)
        {
            cart.Remove(cartItem);
            await _localStorage.SetItemAsync("cart", cart);
            OnChange.Invoke();
        }
    }

    public async Task UpdateQuantity(CartProductResponse product)
    {
        var cart = await _localStorage.GetItemAsync<List<CartItem>>("cart");
        if (cart == null)
            return;

        var cartItem = cart.Find(x => x.ProductId == product.ProductId
        && x.ProductTypeId == product.ProductTypeId);
        if (cartItem != null)
        {
            cartItem.Quantity = product.Quantity;
            await _localStorage.SetItemAsync("cart", cart);
        }
    }
}
  • GetItemAsync get data from local storage with key word.
  • SetItemAsync set data to local storage with key word.
  • api/cart/products is a post method in server. It will return data enveloped in Content.

Dependency Injection

  • BlazorEcommerce.Client/Program.cs
...
builder.Services.AddScoped<ICartService, CartService>();
...

View

Layout

Layout

  • BlazorEcommerce.Client/Shared/ShopLayout.razor
<div class="page">
    <main>
        <div class="top-row px-2">
            <HomeButton/>
            <Search/>
            <CartCounter/>
        </div>

        <div class="nav-menu">
            <ShopNavMenu />
        </div>

        <article class="content px-2">
            @Body
        </article>
    </main>
</div>
  • BlazorEcommerce.Client/Shared/ShopLayout.razor.cs
public partial class ShopLayout : ComponentBase
{
    [Parameter]
    public RenderFragment? Body { get; set; }
}
  • BlazorEcommerce.Client/Shared/ShopLayout.razor.css
.page {
    position: relative;
    display: flex;
    flex-direction: column;
}

main {
    flex: 1;
}

.nav-menu {
    background-image: linear-gradient(180deg, rgb(44, 62, 80) 0%, rgb(52, 73, 94) 70%);
}

.top-row {
    background-color: #f7f7f7;
    border-bottom: 1px solid #d6d5d5;
    justify-content: flex-end;
    height: 3.5rem;
    display: flex;
    align-items: center;
}

    .top-row ::deep a, .top-row ::deep .btn-link {
        white-space: nowrap;
        margin-left: .5rem;
        text-decoration: none;
    }

    .top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
        text-decoration: underline;
    }

    .top-row ::deep a:first-child {
        overflow: hidden;
        text-overflow: ellipsis;
    }

@media (max-width: 640.98px) {
    .top-row.auth {
        justify-content: space-between;
    }

    .top-row ::deep a, .top-row ::deep .btn-link {
        margin-left: 0;
    }
}

@media (min-width: 641px) {
    .page {
        flex-direction: row;
    }

    .top-row {
        position: sticky;
        top: 0;
        z-index: 1;
    }

    .top-row.auth ::deep a:first-child {
        flex: 1;
        text-align: right;
        width: 0;
    }

    .top-row, article {
        padding-left: 2rem !important;
        padding-right: 1.5rem !important;
    }
}

Set Layout in App

  • BlazorEcommerce.Client/App.razor
<Router AppAssembly="@typeof(App).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(ShopLayout)" />
        <FocusOnNavigate RouteData="@routeData" Selector="h1" />
    </Found>
    <NotFound>
        <PageTitle>Not found</PageTitle>
        <LayoutView Layout="@typeof(ShopLayout)">
            <p role="alert">Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

Home

Home Button

  • BlazorEcommerce.Client/Shared/HomeButton.razor
<button @onclick="GoToHome"
    class="btn btn-outline-primary home-button">
    My Shop
</button>
  • BlazorEcommerce.Client/Shared/HomeButton.razor.cs
public partial class HomeButton
{
    [Inject]
    public NavigationManager NavigationManager { get; set; }

    private void GoToHome()
    {
        NavigationManager.NavigateTo("");
    }
}
  • BlazorEcommerce.Client/Shared/HomeButton.razor.css
.home-button {
    white-space: nowrap;
    margin: 10px;
    transform: rotate(-5deg);
}

Search

  • BlazorEcommerce.Client/Shared/Search.razor
<div class="input-group">
    <input @bind-value="searchText" 
           @bind-value:event="oninput"
           type="search"
           list="products"
           @onkeyup="HandleSearch"
           class="form-control"
           placeholder="Search..."
           @ref="searchInput" />
    <datalist id="products">
        @foreach(var suggestion in suggestions)
        {
            <option>@suggestion</option>
        }
    </datalist>
    <div class="input-group-append">
        <button class="btn btn-primary" @onclick="SearchProducts">
            <span class="oi oi-magnifying-glass"></span>
        </button>
    </div>
</div>
  • BlazorEcommerce.Client/Shared/Search.razor.cs
public partial class Search
{
    [Inject]
    public NavigationManager NavigationManager { get; set; }
    [Inject]
    public IProductService ProductService { get; set; }

    private string searchText = string.Empty;
    private List<string> suggestions = new List<string>();
    protected ElementReference searchInput;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if(firstRender)
            await searchInput.FocusAsync();
    }

    public void SearchProducts()
    {
        NavigationManager.NavigateTo($"search/{searchText}/1");
    }

    public async Task HandleSearch(KeyboardEventArgs args)
    {
        if (args.Key == null || args.Key.Equals("Enter"))
            SearchProducts();
        else if (searchText.Any())
            suggestions = await ProductService.GetProductSearchSuggestions(searchText);
    }
}

Cart Counter

  • BlazorEcommerce.Client/Shared/CartCounter.razor
<a href="cart" class="btn btn-info">
    <i class="oi oi-cart"></i>
    <span class="badge">@GetCartItemCount()</span>
</a>
  • BlazorEcommerce.Client/Shared/CartCounter.razor.cs
public partial class CartCounter : IDisposable
{
    [Inject]
    ICartService CartService { get; set; }
    
    [Inject]
    ISyncLocalStorageService LocalStorage { get; set; }

    private int GetCartItemCount()
    {
        var cart = LocalStorage.GetItem<List<CartItem>>("cart");
        return cart != null ? cart.Count : 0;
    }

    protected override void OnInitialized()
    {
        CartService.OnChange += StateHasChanged;
    }

    public void Dispose()
    {
        CartService.OnChange -= StateHasChanged;
    }
}
  • IDisposable implements to disubscreib the event.

Shop Nav Menu

  • BlazorEcommerce.Client/Shared/ShopNavMenu.razor
<div class="top-row ps-3 navbar navbar-dark navbar-toggler-wrapper">
    <div class="container-fluid">
        <a class="navbar-brand" href="">BlazorEcommerce</a>
        <button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
            <span class="navbar-toggler-icon"></span>
        </button>
    </div>
</div>

<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
    <nav class="flex-nav">
        <div class="nav-item px-2">
            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                Home
            </NavLink>
        </div>
        @foreach(var category in CategoryService.Categories)
        {
            <div class="nav-item px-2">
                <NavLink class="nav-link" href="@category.Url">
                    @category.Name
                </NavLink>
            </div>
        }
    </nav>
</div>
  • BlazorEcommerce.Client/Shared/ShopNavMenu.razor.cs
public partial class ShopNavMenu
{
    [Inject]
    public ICategoryService CategoryService { get; set; }

    protected override async Task OnInitializedAsync()
    {
        await CategoryService.GetCategories();
    }

    private bool collapseNavMenu = true;

    private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;

    private void ToggleNavMenu()
    {
        collapseNavMenu = !collapseNavMenu;
    }
}
  • BlazorEcommerce.Client/Shared/ShopNavMenu.razor.css
.navbar-toggler {
    color: rgba(255, 255, 255, 0.1);
}

.top-row {
    height: 3.5rem;
    background-color: rgba(0,0,0,0.4);
}

.navbar-brand {
    font-size: 1.1rem;
}

.oi {
    width: 2rem;
    font-size: 1.1rem;
    vertical-align: text-top;
    top: -2px;
}

.nav-item {
    font-size: 0.9rem;
    padding-bottom: 0.5rem;
    padding-top: 0.5rem;
}

    .nav-item ::deep a {
        color: #d7d7d7;
        border-radius: 4px;
        height: 3rem;
        display: flex;
        align-items: center;
        line-height: 3rem;
    }

.nav-item ::deep a.active {
    background-color: rgba(255,255,255,0.25);
    color: white;
}

.nav-item ::deep a:hover {
    background-color: rgba(255,255,255,0.1);
    color: white;
}

.flex-nav{
    flex-direction: column;
}

@media (min-width: 641px) {
    .navbar-toggler, .navbar-toggler-wrapper {
        display: none;
    }

    .flex-nav {
        flex-direction: row;
        display: flex;
    }

    .collapse {
        /* Never collapse the sidebar for wide screens */
        display: block;
    }
}

Body

  • BlazorEcommerce.Client/Pages/Index.razor
@page "/"
@page "/{categoryUrl}"
@page "/search/{searchText}/{page:int}"

@if(SearchText == null && CategoryUrl == null)
{
    <FeaturedProducts/>
}
else
{
    <ProductList/>
}
  • BlazorEcommerce.Client/Pages/Index.razor.cs
public partial class Index
{
    [Inject]
    public IProductService ProductService { get; set; }

    [Parameter]
    public string? CategoryUrl { get; set; } = null;
    [Parameter]
    public string? SearchText { get; set; } = null;
    [Parameter]
    public int Page { get; set; } = 1;

    protected override async Task OnParametersSetAsync()
    {
        if(SearchText != null)
            await ProductService.SearchProducts(SearchText, Page);
        else
            await ProductService.GetProducts(CategoryUrl);
    }
}

Products

Featured Products

  • BlazorEcommerce.Client/Shared/FeaturedProducts.razor
<center><h2>Top Products of Today</h2></center>
@if (!ProductService.Products.Any())
{
    <span>@ProductService.Message</span>
}
else
{
    <div class="container">
        @foreach(var product in ProductService.Products)
        {
            @if (product.Featured)
            {
                <div class="featured-product">
                    <div>
                        <a href="product/@product.Id">
                            <img src="@product.ImageUrl">
                        </a>
                    </div>
                    <h4><a href="product/@product.Id">@product.Title</a></h4>
                    @if(product.Variants.Any())
                    {
                        <h5 class="price">
                            $@product.Variants[0].Price
                        </h5>
                    }
                </div>
            }
        }
    </div>
}
  • BlazorEcommerce.Client/Shared/FeaturedProducts.razor.cs
public partial class FeaturedProducts : IDisposable
{
    [Inject]
    public IProductService ProductService { get; set; }

    protected override void OnInitialized()
    {
        ProductService.ProductsChanged += StateHasChanged;
    }

    public void Dispose()
    {
        ProductService.ProductsChanged -= StateHasChanged;
    }
}
  • BlazorEcommerce.Client/Shared/FeaturedProducts.razor.css
.container {
    display: flex;
    flex-direction: row;
    overflow-x: auto;
    justify-content: center;
}

img {
    max-width: 200px;
    max-height: 200px;
    border-radius: 6px;
    transition: transform .2s;
    margin-bottom: 10px;
}

    img:hover{
        transform: scale(1.1) rotate(5deg);
    }

.featured-product {
    margin: 10px;
    text-align: center;
    padding: 10px;
    border: 1px solid lightgray;
    border-radius: 10px;
    max-width: 200px;
}

@media (max-width: 1023.98px) {
    .container {
        justify-content: flex-start;
    }
}

Product List

  • BlazorEcommerce.Client/Shared/ProductList.razor
@if (!ProductService.Products.Any())
{
    <span>@ProductService.Message</span>
}
else
{
    <ul class="list-unstyled">
        @foreach(var product in ProductService.Products)
        {
            <li class="media my-3">
                <div class="media-img-wrapper mr-2">
                    <a href="/product/@product.Id">
                        <img class="media-img" src="@product.ImageUrl" alt="@product.Title"/>
                    </a>
                </div>
                <div class="media-body">
                    <a href="/product/@product.Id">
                        <h4 class="mb-0">@product.Title</h4>
                    </a>
                    <p>@product.Description</p>
                    <h5 class="price">
                        @GetPriceText(product)
                    </h5>
                </div>
            </li>
        }
    </ul>
    {
        for(var i = 1; i <= ProductService.PageCount; i++)
        {
            <a class="btn 
                      @(i == ProductService.CurrentPage ? "btn-info" : "btn-outline-info") 
                      page-selection" 
               href="/search/@ProductService.LastSearchText/@i">@i</a>
        }
    }
}
  • BlazorEcommerce.Client/Shared/ProductList.razor.cs
public partial class ProductList : IDisposable
{
    [Inject]
    public IProductService ProductService { get; set; }

    protected override void OnInitialized()
    {
        ProductService.ProductsChanged += StateHasChanged;
    }

    public void Dispose()
    {
        ProductService.ProductsChanged -= StateHasChanged;
    }

    private string GetPriceText(Product product) 
    {
        var variants = product.Variants;
        if (!variants.Any())
            return string.Empty;
        else if (variants.Count == 1)
            return $"${variants[0].Price}";
        decimal minPrice = variants.Min(v => v.Price);
        return $"Starting at ${minPrice}";
    }
}
  • BlazorEcommerce.Client/Shared/ProductList.razor.css
.media-img {
    max-height: 200px;
    max-width: 200px;
    border-radius: 6px;
    margin-bottom: 10px;
    transition: transform .2s;
}

    .media-img:hover {
        transform: scale(1.1);
    }

.media-img-wrapper {
    width: 200px;
    text-align: center;
}

.page-selection {
    margin-right: 15px;
    margin-bottom: 30px;
}

@media (max-width:1023.98px) {
    .media {
        flex-direction: column;
    }

    .media-img-wrapper{
        width: 100%;
        height: auto;
    }
}

Product Detail

  • BlazorEcommerce.Client/Pages/ProductDetails.razor
@page "/product/{id:int}"

@if(product == null)
{
    <span>@message</span>
}
else
{
    <div class="media">
        <div class="media-img-wrapper mr-2">
            <img class="media-img" src="@product.ImageUrl" alt="@product.Title"/>
        </div>
        <div class="media-body">
            <h2 class="mb-0">@product.Title</h2>
            <p>@product.Description</p>
            @if (product.Variants.Any())
            {
                <div class="mb-3">
                    <select class="form-select" @bind="currentTypeId">
                        @foreach(var variant in product.Variants)
                        {
                            <option value="@variant.ProductTypeId">@variant.ProductType.Name</option>
                        }
                    </select>
                </div>
            }
            @if(GetSelectedVariant() != null)
            {
                @if(GetSelectedVariant().OriginalPrice > GetSelectedVariant().Price)
                {
                    <h6 class="text-muted original-price">
                        $@GetSelectedVariant().OriginalPrice
                    </h6>
                }
                <h4 class="price">
                    $@GetSelectedVariant().Price
                </h4>
            }
            <button class="btn btn-primary" @onclick="AddToCart">
                <i class="oi oi-cart"></i>&nbsp;&nbsp;&nbsp;Add to Cart
            </button>
        </div>
    </div>
}
  • BlazorEcommerce.Client/Pages/ProductDetails.razor.cs
public partial class ProductDetails
{
    [Inject]
    public ICartService CartService { get; set; }

    [Inject]
    public IProductService ProductService { get; set; }

    private Product? product = null;
    private string message;
    private int currentTypeId = 1;

    [Parameter]
    public int Id { get; set; }

    protected override async Task OnParametersSetAsync()
    {
        message = "Loading roduct...";
        var result = await ProductService.GetProduct(Id);
        if (!result.Success)
            message = result.Message;
        else
        {
            product = result.Data;
            if (product.Variants.Any())
                currentTypeId = product.Variants[0].ProductTypeId;
        }
    }

    private ProductVariant GetSelectedVariant()
    {
        var variant = product.Variants.FirstOrDefault(v => v.ProductTypeId == currentTypeId);
        return variant;
    }

    private async Task AddToCart()
    {
        var productVariant = GetSelectedVariant();
        var cartItem = new CartItem
        {
            ProductId = productVariant.ProductId,
            ProductTypeId = productVariant.ProductTypeId
        };
        await CartService.AddToCart(cartItem);
    }
}
  • BlazorEcommerce.Client/Pages/ProductDetails.razor.css
.media-img {
    max-height: 500px;
    max-width: 500px;
    border-radius: 6px;
    margin: 0 10px 10px 10px;
}

.media-img-wrapper {
    max-width: 500px;
    max-height: 500px;
    text-align: center;
}

.original-price {
    text-decoration: line-through;
}

@media (max-width: 1023.98px) {
    .media {
        flex-direction: column;
    }

    .media-img {
        max-width: 300px;
    }

    .media-img-wrapper {
        width: 100%;
        height: auto;
    }
}

Cart

  • BlazorEcommerce.Client/Pages/Cart.razor
@page "/cart"

<h3>Shopping Cart</h3>

@if (!cartProducts.Any())
{
    <span>@message</span>
}
else
{
    <div>
        @foreach(var product in cartProducts)
        {
            <div class="container">
                <div class="image-wrapper">
                    <img src="@product.ImageUrl" class="image"/>
                </div>
                <div class="name">
                    <h5><a href="/product/@product.ProductId">@product.Title</a></h5>
                    <span>@product.ProductType</span><br/>
                    <input type="number" 
                           value="@product.Quantity" 
                           @onchange="@((ChangeEventArgs e) => UpdateQuantity(e, product))" 
                           class="form-control input-quantity" 
                           min="1"/>
                    <button class="btn-delete" 
                            @onclick="@(() 
                                => RemoveProductFromCart(product.ProductId, product.ProductTypeId))">
                            Delete</button>
                </div>
                <div class="cart-product-price">$@(product.Price * product.Quantity)</div>
            </div>
        }
        <div class="cart-product-price">
            Total (@cartProducts.Count): $@cartProducts.Sum(product => 
                @product.Price * product.Quantity)
        </div>
    </div>
}
  • BlazorEcommerce.Client/Pages/Cart.razor.cs
public partial class Cart
{
    [Inject]
    public ICartService CartService { get; set; }

    List<CartProductResponse> cartProducts = new List<CartProductResponse>();
    string message = "Loading cart...";

    protected override async Task OnInitializedAsync()
    {
        await LoadCart();
    }

    private async Task RemoveProductFromCart(int productId, int productTypeId)
    {
        await CartService.RemoveProductFromCart(productId, productTypeId);
        await LoadCart();
    }
    private async Task LoadCart()
    {
        if (!(await CartService.GetCartItems()).Any())
        {
            message = "Your cart is empty.";
            cartProducts = new List<CartProductResponse>();
        }
        else
        {
            cartProducts = await CartService.GetCartProducts();
        }
    }

    private async Task UpdateQuantity(ChangeEventArgs e, CartProductResponse product)
    {
        product.Quantity = int.Parse(e.Value.ToString());
        if (product.Quantity < 1)
            product.Quantity = 1;
        await CartService.UpdateQuantity(product);
    }
}
  • BlazorEcommerce.Client/Pages/Cart.razor.css
.container {
    display: flex;
    padding: 6px;
}

.image-wrapper {
    width: 150px;
    text-align: center;
}

.image {
    max-height: 150px;
    max-width: 150px;
    padding: 6px;
}

.name{
    flex-grow: 1;
    padding: 6px;
}

.cart-product-price {
    font-weight: 600;
    text-align: right;
}

.btn-delete {
    background: none;
    border: none;
    padding: 0px;
    color: red;
    font-size: 12px;
}

    .btn-delete:hover {
        text-decoration: underline;
    }

.input-quantity{
    width: 70px;
}

Show

Package in Client
Package in Client

Auth

Models

  • BlazorEcommerce.Shared/User.cs
public class User
{
    public int Id { get; set; }
    public string Email { get; set; } = string.Empty;
    public byte[] PasswordHash { get; set; }
    public byte[] PasswordSalt { get; set; }
    public DateTime DateCreated { get; set; } = DateTime.Now;
}
  • PasswordSalt is added characters in your original password.
  • For example, your original password is yellow. But, you know, others also can use yellow for password. To avoid same passwords, we add salt like yellow#1Gn% or yellow9j?L.
  • So hashed PasswordSalt is powerful than normal securities.

  • BlazorEcommerce.Shared/UserRegister.cs
public class UserRegister
{
    [Required, EmailAddress]
    public string Email { get; set; } = string.Empty;
    [Required, StringLength(100, MinimumLength = 6)]
    public string Password { get; set; } = string.Empty;
    [Compare("Password", ErrorMessage = "The passwords do not match.")]
    public string ConfirmPassword { get; set; } = string.Empty;
}
  • BlazorEcommerce.Shared/UserLogin.cs
public class UserLogin
{
    [Required]
    public string Email { get; set; } = string.Empty;
    [Required]
    public string Password { get; set; } = string.Empty;
}
  • BlazorEcommerce.Shared/UserLogin.cs
public class UserChangePassword
{
    [Required, StringLength(100, MinimumLength = 6)]
    public string Password { get; set; } = string.Empty;
    [Compare("Password", ErrorMessage = "The password do not match.")]
    public string ConfirmPassword { get; set; } = string.Empty;
}

Set

  • BlazorEcommerce.Server/Data/DataContext.cs
...
public DbSet<User> Users { get; set; }

Datebase

  • Enter dotnet ef migrations add Users and dotnet ef update database in CLI.

Auth Service in Server

Package in Server

  • Download Microsoft.AspNetCore.Authentication.JwtBearer.
Package in Server

Interface

  • BlazorEcommerce.Server/Services/AuthService/IAuthService.cs
public interface IAuthService
{
    Task<ServiceResponse<int>> Register(User user, string password);
    Task<bool> UserExists(string email);
    Task<ServiceResponse<string>> Login(string email, string password);
    Task<ServiceResponse<bool>> ChangePassword(int userId, string newPassword);
}

Implement

  • BlazorEcommerce.Server/Services/AuthService/AuthService.cs
public class AuthService : IAuthService
{
    private readonly DataContext _context;
    private readonly IConfiguration _configuration;

    public AuthService(DataContext context, IConfiguration configuration)
    {
        _context = context;
        _configuration = configuration;
    }
    
    public async Task<ServiceResponse<int>> Register(User user, string password)
    {
        if(await UserExists(user.Email))
            return new ServiceResponse<int> 
            { 
                Success = false, 
                Message = "User already exists." 
            };

        CreatePasswordHash(password, out byte[] passwordHash, out byte[] passwordSalt);

        user.PasswordHash = passwordHash;
        user.PasswordSalt = passwordSalt;

        _context.Users.Add(user);
        await _context.SaveChangesAsync();

        return new ServiceResponse<int> { Data = user.Id, Message = "Registration successful!" };
    }

    public async Task<ServiceResponse<string>> Login(string email, string password)
    {
        var response = new ServiceResponse<string>();
        var user = await _context.Users
            .FirstOrDefaultAsync(x => x.Email.ToLower().Equals(email.ToLower()));
        if(user == null)
        {
            response.Success = false;
            response.Message = "User not found.";
        }
        else if(!VerifyPasswordHash(password, user.PasswordHash, user.PasswordSalt))
        {
            response.Success = false;
            response.Message = "Wrong password.";
        }
        else
            response.Data = CreateToken(user);

        return response;
    }

    public async Task<bool> UserExists(string email)
    {
        if (await _context.Users.AnyAsync(user => user.Email.ToLower()
                .Equals(email.ToLower())))
            return true;
        return false;
    }

    private void CreatePasswordHash
        (string password, out byte[] passwordHash, out byte[] passwordSalt)
    {
        using(var hmac = new HMACSHA512())
        {
            passwordSalt = hmac.Key;
            passwordHash = hmac
                .ComputeHash(System.Text.Encoding.UTF8.GetBytes(password));
        }
    }

    private bool VerifyPasswordHash(string password, byte[] passwordHash, byte[] passwordSalt)
    {
        using (var hmac = new HMACSHA512(passwordSalt))
        {
            var computedHash = 
                hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(password));

            return computedHash.SequenceEqual(passwordHash);
        }
    }

    private string CreateToken(User user)
    {
        List<Claim> claims = new List<Claim>
        {
            new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new Claim(ClaimTypes.Name, user.Email)
        };

        var key = new SymmetricSecurityKey(System.Text.Encoding.UTF8
            .GetBytes(_configuration.GetSection("AppSettings:Token").Value));

        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha512Signature);

        var token = new JwtSecurityToken(
            claims: claims,
            expires: DateTime.Now.AddDays(1),
            signingCredentials: creds);

        var jwt = new JwtSecurityTokenHandler().WriteToken(token);

        return jwt;
    }

    public async Task<ServiceResponse<bool>> ChangePassword(int userId, string newPassword)
    {
        var user = await _context.Users.FindAsync(userId);
        if (user == null)
            return new ServiceResponse<bool>
            {
                Success = false,
                Message = "User not found."
            };

        CreatePasswordHash(newPassword, out byte[] passwordHash, out byte[] passwordSalt);

        user.PasswordHash = passwordHash;
        user.PasswordSalt = passwordSalt;

        await _context.SaveChangesAsync();

        return new ServiceResponse<bool> 
        { 
            Data = true, 
            Message = "Password has been changed." 
        };
    }
}
  • IConfiguration reads appsettings.json.
  • out reference object address but the variable should be setted.
  • ref reference object address also, but the variable should have value before.
  • out and ref are useful when you have more than two return values.
  • HMACSHA512 is a type of keyed hash algorithm that is constructed from the SHA-512 hash function and used as a Hash-based Message Authentication Code (HMAC).
  • Claim is a base of the common authorization approache and provides a way of sharing user information throughout the application in a consistent way.
  • SymmetricSecurityKey generates security key.
  • SigningCredentials specifies the signing key, signing key identifier, and security algorithms that are used by .NET to generate the digital signature for a SamlAssertion.

Controller

  • BlazorEcommerce.Server/Controllers/AuthController.cs
[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
    private readonly IAuthService _authService;

    public AuthController(IAuthService authService)
    {
        _authService = authService;
    }

    [HttpPost("register")]
    public async Task<ActionResult<ServiceResponse<int>>> Register(UserRegister request)
    {
        var response = await _authService.Register(new User
        {
            Email = request.Email
        }, 
        request.Password);

        if (!response.Success)
            return BadRequest(response);
        
        return Ok(response);
    } 

    [HttpPost("login")]
    public async Task<ActionResult<ServiceResponse<string>>> Login(UserLogin request)
    {
        var response = await _authService.Login(request.Email, request.Password);
        if (!response.Success)
            return BadRequest(response);
        return Ok(response);
    }

    [HttpPost("change-password"), Authorize]
    public async Task<ActionResult<ServiceResponse<bool>>> ChangePassword
        ([FromBody] string newPassword)
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var response = await _authService.ChangePassword(int.Parse(userId), newPassword);

        if (!response.Success)
        {
            return BadRequest(response);
        }

        return Ok(response);
    }
}

Dependency Injection

  • BlazorEcommerce.Server/Program.cs
...
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8
                .GetBytes(builder.Configuration.GetSection("AppSettings:Token").Value)),
            ValidateIssuer = false,
            ValidateAudience = false
        };
    });
...
app.UseAuthentication();
app.UseAuthorization();
...

Set

  • BlazorEcommerce.Server/appsettings.json
...
  "AppSettings": {
    "Token":  "my top secret key"
  },
...

Auth Service in Client

Interface

  • BlazorEcommerce.Client/Services/AuthService/IAuthService.cs
public interface IAuthService
{
    Task<ServiceResponse<int>> Register(UserRegister request);
    Task<ServiceResponse<string>> Login(UserLogin request);
    Task<ServiceResponse<bool>> ChangePassword(UserChangePassword request);
}

Implement

  • BlazorEcommerce.Client/Services/AuthService/AuthService.cs
public class AuthService : IAuthService
{
    private readonly HttpClient _http;

    public AuthService(HttpClient http)
    {
        _http = http;
    }

    public async Task<ServiceResponse<int>> Register(UserRegister request)
    {
        var result = await _http.PostAsJsonAsync("api/auth/register", request);
        return await result.Content.ReadFromJsonAsync<ServiceResponse<int>>();
    }

    public async Task<ServiceResponse<string>> Login(UserLogin request)
    {
        var result = await _http.PostAsJsonAsync("api/auth/login", request);
        return await result.Content.ReadFromJsonAsync<ServiceResponse<string>>();
    }

    public async Task<ServiceResponse<bool>> ChangePassword(UserChangePassword request)
    {
        var result = await _http.PostAsJsonAsync("api/auth/change-password", request.Password);
        return await result.Content.ReadFromJsonAsync<ServiceResponse<bool>>();
    }
}

Dependency Injection

  • BlazorEcommerce.Client/Program.cs
...
builder.Services.AddScoped<IAuthService, AuthService>();
...

Authorization

  • For authorization, I will use Claim, JSON Web Token and Local Storage.

Package for Authorization

  • Download Microsoft.AspNetCore.Components.Authorization and Microsoft.AspNetCore.WebUtilities in Client.
Authorization

Auth State Provider

  • BlazorEcommerce.Client/CustomAuthStateProvider.cs
public class CustomAuthStateProvider : AuthenticationStateProvider
{
    private readonly ILocalStorageService _localStorageService;
    private readonly HttpClient _http;

    public CustomAuthStateProvider(ILocalStorageService localStorageService, HttpClient http)
    {
        _localStorageService = localStorageService;
        _http = http;
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        string authToken = await _localStorageService.GetItemAsStringAsync("authToken");
        
        var identity = new ClaimsIdentity();
        _http.DefaultRequestHeaders.Authorization = null;

        if (!string.IsNullOrEmpty(authToken)) 
        {
            try 
            {
                identity = new ClaimsIdentity(ParseClaimsFromJwt(authToken), "jwt");
                _http.DefaultRequestHeaders.Authorization
                    = new AuthenticationHeaderValue("Bearer", authToken.Replace("\"", ""));
            } 
            catch 
            {
                await _localStorageService.RemoveItemAsync("authToken");
                identity = new ClaimsIdentity();
            }
        }

        var user = new ClaimsPrincipal(identity);
        var state = new AuthenticationState(user);

        NotifyAuthenticationStateChanged(Task.FromResult(state));

        return state;
    }

    private byte[] ParseBase64WithoutPadding(string base64)
    {
        switch(base64.Length % 4)
        {
            case 2: base64 += "=="; break;
            case 3: base64 += "="; break;
        }
        return Convert.FromBase64String(base64);
    }

    private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
    {
        var payload = jwt.Split('.')[1];
        var jsonBytes = ParseBase64WithoutPadding(payload);
        var keyValuePairs = JsonSerializer
            .Deserialize<Dictionary<string, object>>(jsonBytes);

        var claims = keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())); 

        return claims;
    }
}
  • AuthenticationStateProvider class provides information about the authentication state of the current user.
  • We have to split jwt because of HMACSHA512(base64UrlEncode(header) + "." + base64UrlEncode(payload)).
  • Bearer authentication (also called token authentication) is an HTTP authentication scheme that involves security tokens called bearer tokens. The name “Bearer authentication” can be understood as “give access to the bearer of this token.” The bearer token is a cryptic string, usually generated by the server in response to a login request. The client must send this token in the Authorization header when making requests to protected resources
  • ClaimsPrincipal means user and ClaimsIdentity means user information. So, we get user information from JWT first, and then create user with this user information.
  • And we will use AuthorizeView in our project, so AuthenticationState and NotifyAuthenticationStateChanged are for change authentication state.

Set

  • BlazorEcommerce.Client/Program.cs
...
builder.Services.AddOptions();
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
...
  • BlazorEcommerce.Client/_imports.cs
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization

View

Register

  • BlazorEcommerce.Client/Pages/Register.razor
@page "/register"

<EditForm Model="user" OnValidSubmit="HandleRegistration">
    <DataAnnotationsValidator />
    <div class="mb-3">
        <label for="email">Email</label>
        <InputText id="email" @bind-Value="user.Email" class="form-control" />
        <ValidationMessage For="@(() => user.Email)"/>
    </div>
    <div class="mb-3">
        <label for="password">Password</label>
        <InputText id="password" @bind-Value="user.Password" class="form-control" type="password" />
        <ValidationMessage For="@(() => user.Password)"/>
    </div>
    <div class="mb-3">
        <label for="confirmPassword">Confirm Password</label>
        <InputText id="confirmPassword" @bind-Value="user.ConfirmPassword" class="form-control" 
            type="password" />
        <ValidationMessage For="@(() => user.ConfirmPassword)"/>
    </div>
    <button type="submit" class="btn btn-primary">Register</button>
    <div class="@messageCssClass">
        <span>@message</span>
    </div>
</EditForm>
  • BlazorEcommerce.Client/Pages/Register.razor.cs
public partial class Register
{
    [Inject]
    public IAuthService AuthService { get; set; }

    UserRegister user = new UserRegister();

    string message = string.Empty;
    string messageCssClass = string.Empty;

    async Task HandleRegistration()
    {
        var result = await AuthService.Register(user);
        message = result.Message;
        if(result.Success)
            messageCssClass = "text-success";
        else
            messageCssClass = "text-danger";
    }
}

Login

  • BlazorEcommerce.Client/Pages/Login.razor
@page "/login"

<EditForm Model="user" OnValidSubmit="HandleLogin">
    <DataAnnotationsValidator />
    <div class="mb-3">
        <label for="email">Email</label>
        <InputText id="email" @bind-Value="user.Email" class="form-control" />
        <ValidationMessage For="@(() => user.Email)"/>
    </div>
    <div class="mb-3">
        <label for="password">Password</label>
        <InputText id="password" @bind-Value="user.Password" class="form-control" type="password" />
        <ValidationMessage For="@(() => user.Password)"/>
    </div>
    <button type="submit" class="btn btn-primary">Login</button>
</EditForm>

<div class="text-danger">
    <span>@errorMessage</span>
</div>
  • BlazorEcommerce.Client/Pages/Login.razor.cs
public partial class Login
{
    [Inject]
    public IAuthService AuthService { get; set; }
    [Inject]
    public ILocalStorageService LocalStorage { get; set; }
    [Inject]
    public NavigationManager NavigationManager { get; set; }
    [Inject]
    public AuthenticationStateProvider AuthenticationStateProvider { get; set; }

    private UserLogin user = new UserLogin();

    private string errorMessage = string.Empty;

    private string returnUrl = string.Empty;

    protected override void OnInitialized()
    {
        var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
        if(QueryHelpers.ParseQuery(uri.Query).TryGetValue("returnUrl", out var url))
            returnUrl = url;
    }

    private async Task HandleLogin()
    {
        var result = await AuthService.Login(user);
        if(result.Success)
        {
            errorMessage = string.Empty;

            await LocalStorage.SetItemAsync("authToken", result.Data);
            await AuthenticationStateProvider.GetAuthenticationStateAsync();
            NavigationManager.NavigateTo(returnUrl);
        }
        else
            errorMessage = result.Message;
    }
}
  • When you success the login, then you will return to returnUrl. QueryHelter is for this process.

Profile

  • BlazorEcommerce.Client/Pages/Profile.razor
@page "/profile"

<AuthorizeView>
    <h3>Hi! You're logged in with <i>@context.User.Identity.Name</i>.</h3>
</AuthorizeView>

<h5>Change Password</h5>

<EditForm Model="request" OnValidSubmit="ChangePassword">
    <DataAnnotationsValidator/>
    <div class="mb-3">
        <label for="password">New Password</label>
        <InputText id="password" @bind-Value="request.Password" class="form-control" 
            type="password"/>
        <ValidationMessage For="@(() => request.Password)"/>
    </div>
    <div class="mb-3">
        <label for="confirmPassword">Confirm New Password</label>
        <InputText id="confirmPassword" @bind-Value="request.ConfirmPassword" 
            class="form-control" type="password"/>
        <ValidationMessage For="@(() => request.ConfirmPassword)"/>
    </div>
    <button type="submit" class="btn btn-primary">Apply</button>
</EditForm>
@message
  • BlazorEcommerce.Client/Pages/Profile.razor.cs
public partial class Login
{
    [Authorize]
    public partial class Profile
    {
        [Inject]
        public IAuthService AuthService { get; set; }

        UserChangePassword request = new UserChangePassword();
        string message = string.Empty;

        private async Task ChangePassword()
        {
            var result = await AuthService.ChangePassword(request);
            message = result.Message;
        }
    }
}
  • [Authorize] means this page is for authorized user.

User Button

  • BlazorEcommerce.Client/Shared/UserButton.razor
<div class="dropdown">
    <button @onclick="ToggleUserMenu"
            @onfocusout="HideUserMenu"
            class="btn btn-secondary dropdown-toggle user-button">
            <i class="oi oi-person"></i>
        </button>
    <div class="dropdown-menu dropdown-menu-right @UserMenuCssClass">
        <AuthorizeView>
            <Authorized>
                <a href="profile" class="dropdown-item">Profile</a>
                <hr/>
                <button class="dropdown-item" @onclick="Logout">Logout</button>
            </Authorized>
            <NotAuthorized>
                <a href="login?returnUrl=@NavigationManager.ToBaseRelativePath(NavigationManager.Uri)" 
                    class="dropdown-item">Login</a>
                <a href="register" class="dropdown-item">Register</a>
            </NotAuthorized>
        </AuthorizeView>
    </div>
</div>
  • login?returnUrl=@NavigationManager.ToBaseRelativePath(NavigationManager.Uri) means save current url to returnUrl variable in hyperlink url but go to login.

  • BlazorEcommerce.Client/Shared/UserButton.razor.cs

ppublic partial class UserButton
{
    [Inject]
    public ILocalStorageService LocalStorage { get; set; }
    [Inject]
    public AuthenticationStateProvider AuthenticationStateProvider { get; set; }
    [Inject]
    public NavigationManager NavigationManager { get; set; }

    private bool showUserMenu = false;

    private string UserMenuCssClass => showUserMenu ? "show-menu" : null;

    private void ToggleUserMenu()
    {
        showUserMenu = !showUserMenu;
    }

    private async Task HideUserMenu()
    {
        await Task.Delay(200);
        showUserMenu = false;
    }

    private async Task Logout()
    {
        await LocalStorage.RemoveItemAsync("authToken");
        await AuthenticationStateProvider.GetAuthenticationStateAsync();
        NavigationManager.NavigateTo("");
    }
}
  • BlazorEcommerce.Client/Shared/UserButton.razor.css
.show-menu {
    display: block;
}

.user-button {
    margin-left: .5em;
}

.top-row a {
    margin-left: 0;
}

.dropdown-item:hover {
    background-color: white;
}
  • BlazorEcommerce.Client/App.razor
<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(ShopLayout)">
            <NotAuthorized>
                <h3>Whoops! You're not allowed to see this page.</h3>
                <h5>Please <a href="login">login</a> or <a href="register">register</a> 
                    for a new account.</h5>
            </NotAuthorized>
        </AuthorizeRouteView>
        <FocusOnNavigate RouteData="@routeData" Selector="h1" />
    </Found>
    <NotFound>
        <PageTitle>Not found</PageTitle>
        <LayoutView Layout="@typeof(ShopLayout)">
            <p role="alert">Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>
</CascadingAuthenticationState>

Run

User Button

User Button

Register

Register

Login

Login
  • You can check your JSON Web Token in jwt.io.

Profile

Profile

Refactoring Auth Service

  • public int GetUserId() => int.Parse(_httpContextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)); returns UserId which is logged in now.
  • I will use it only in AuthService and use AuthService in other services.

Refactoring Auth Service in Server

Interface

  • BlazorEcommerce.Server/Services/AuthService/IAuthService.cs
public interface IAuthService
{
    ...
    int GetUserId();
}

Implement

  • BlazorEcommerce.Server/Services/AuthService/AuthService.cs
public class AuthService : IAuthService
{
    private readonly DataContext _context;
    private readonly IConfiguration _configuration;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public AuthService(
        DataContext context, 
        IConfiguration configuration,
        IHttpContextAccessor httpContextAccessor)
    {
        _context = context;
        _configuration = configuration;
        _httpContextAccessor = httpContextAccessor;
    }

    public int GetUserId() 
        => int.Parse(_httpContextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier));
    ...
}

Set

  • BlazorEcommerce.Server/Program.cs
...
builder.Services.AddHttpContextAccessor();
...

Refactoring Auth Service in Client

Interface

  • BlazorEcommerce.Client/Services/AuthService/IAuthService.cs
public interface IAuthService
{
    ...
    Task<bool> IsUserAuthenticated();
}

Implement

  • BlazorEcommerce.Client/Services/AuthService/AuthService.cs
public class AuthService : IAuthService
{
    private readonly HttpClient _http;
    private readonly AuthenticationStateProvider _authStateProvider;

    public AuthService(HttpClient http, AuthenticationStateProvider authStateProvider)
    {
        _http = http;
        _authStateProvider = authStateProvider;
    }
    ...

    public async Task<bool> IsUserAuthenticated() 
        => (await _authStateProvider.GetAuthenticationStateAsync()).User.Identity.IsAuthenticated;
}

Cart with Authentication and Database

Models

  • To add authentication, add UserId in CartItem.

  • BlazorEcommerce.Shared/CartItem.cs

public class CartItem
{
    public int UserId { get; set; }
    public int ProductId { get; set; }
    public int ProductTypeId { get; set; }
    public int Quantity { get; set; } = 1;
}

Set

  • BlazorEcommerce.Server/Data/DataContext.cs
public class DataContext : DbContext
{
    ...
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<CartItem>()
            .HasKey(ci => new { ci.UserId, ci.ProductId, ci.ProductTypeId });
        ...
    }

    ...
    public DbSet<CartItem> CartItems { get; set; }
}

Database

  • Type dotnet ef migrations add CartItems and dotnet ef database update

Cart Service in Server

Interface

  • BlazorEcommerce.Server/Services/CartService/ICartService.cs
public interface ICartService
{
    ...
    Task<ServiceResponse<List<CartProductResponse>>> StoreCartItems(List<CartItem> cartItems);
    Task<ServiceResponse<int>> GetCartItemsCount();
    Task<ServiceResponse<List<CartProductResponse>>> GetDbCartProducts();
    Task<ServiceResponse<bool>> AddToCart(CartItem cartItem);
    Task<ServiceResponse<bool>> UpdateQuantity(CartItem cartItem);
    Task<ServiceResponse<bool>> RemoveItemFromCart(int productId, int productTypeId);
}

Implement

  • BlazorEcommerce.Server/Services/CartService/CartService.cs
public class CartService : ICartService
{
    private readonly DataContext _context;
    private readonly IAuthService _authService;

    public CartService(DataContext context, IAuthService authService)
    {
        _context = context;
        _authService = authService;
    }
    ...

    public async Task<ServiceResponse<List<CartProductResponse>>> StoreCartItems
        (List<CartItem> cartItems)
    {
        cartItems.ForEach(cartItem => cartItem.UserId = _authService.GetUserId());
        _context.CartItems.AddRange(cartItems);
        await _context.SaveChangesAsync();

        return await GetDbCartProducts();
    }

    public async Task<ServiceResponse<int>> GetCartItemsCount()
    {
        var count = await _context.CartItems
            .Where(ci => ci.UserId == _authService.GetUserId()).CountAsync();
        return new ServiceResponse<int> { Data = count };
    }

    public async Task<ServiceResponse<List<CartProductResponse>>> GetDbCartProducts() => 
        await GetCartProducts(await _context.CartItems
            .Where(ci => ci.UserId == _authService.GetUserId()).ToListAsync());

    public async Task<ServiceResponse<bool>> AddToCart(CartItem cartItem)
    {
        cartItem.UserId = _authService.GetUserId();

        var sameItem = await _context.CartItems
            .FirstOrDefaultAsync(ci => ci.ProductId == cartItem.ProductId &&
            ci.ProductTypeId == cartItem.ProductTypeId && ci.UserId == cartItem.UserId);

        if (sameItem == null)
            _context.CartItems.Add(cartItem);
        else
            sameItem.Quantity += cartItem.Quantity;

        await _context.SaveChangesAsync();

        return new ServiceResponse<bool> { Data = true };
    }

    public async Task<ServiceResponse<bool>> UpdateQuantity(CartItem cartItem)
    {
        var dbCartItems = await _context.CartItems
            .FirstOrDefaultAsync(ci => ci.ProductId == cartItem.ProductId &&
            ci.ProductTypeId == cartItem.ProductTypeId && ci.UserId == _authService.GetUserId());
        if(dbCartItems == null)
            return new ServiceResponse<bool>
            {
                Data = false,
                Success = false,
                Message = "Cart item does not exist."
            };

        dbCartItems.Quantity = cartItem.Quantity;
        await _context.SaveChangesAsync();

        return new ServiceResponse<bool> { Data = true };
    }

    public async Task<ServiceResponse<bool>> RemoveItemFromCart(int productId, int productTypeId)
    {
        var dbCartItems = await _context.CartItems
            .FirstOrDefaultAsync(ci => ci.ProductId == productId &&
            ci.ProductTypeId == productTypeId && ci.UserId == _authService.GetUserId());
        if (dbCartItems == null)
            return new ServiceResponse<bool>
            {
                Data = false,
                Success = false,
                Message = "Cart item does not exist."
            };

        _context.CartItems.Remove(dbCartItems);
        await _context.SaveChangesAsync();

        return new ServiceResponse<bool> { Data = true };
    }
}
  • CountAsync returns count of the list items.

Controller

  • BlazorEcommerce.Server/Controllers/CartController.cs
...
public class CartController : ControllerBase
{
    ...
    [HttpPost]
    public async Task<ActionResult<ServiceResponse<List<CartProductResponse>>>> 
        StoreCartItems(List<CartItem> cartItems)
    {
        var result = await _cartService.StoreCartItems(cartItems);
        return Ok(result);
    }

    [HttpGet("count")]
    public async Task<ActionResult<ServiceResponse<int>>> GetCartItemsCount() => 
        await _cartService.GetCartItemsCount();

    [HttpGet]
    public async Task<ActionResult<ServiceResponse<List<CartProductResponse>>>> GetDbCartProducts()
    {
        var result = await _cartService.GetDbCartProducts();
        return Ok(result);
    }

    [HttpPost("add")]
    public async Task<ActionResult<ServiceResponse<bool>>> AddToCart(CartItem cartItem)
    {
        var result = await _cartService.AddToCart(cartItem);
        return Ok(result);
    }

    [HttpPost("update-quantity")]
    public async Task<ActionResult<ServiceResponse<bool>>> UpdateQuantity(CartItem cartItem)
    {
        var result = await _cartService.UpdateQuantity(cartItem);
        return Ok(result);
    }

    [HttpDelete("{productId}/{productTypeId}")]
    public async Task<ActionResult> RemoveItemFromCart(int productId, int productTypeId)
    {
        var result = await _cartService.RemoveItemFromCart(productId, productTypeId);
        return Ok(result);
    }
}

Cart Service in Client

Interface

  • BlazorEcommerce.Client/Services/CartService/ICartService.cs
public interface ICartService
{
    ...
    Task StoreCartItems(bool emptyLocalCart);
    Task GetCartItemsCount();
}

Implement

  • BlazorEcommerce.Client/Services/CartService/CartService.cs
public class CartService : ICartService
{
    private readonly ILocalStorageService _localStorage;
    private readonly HttpClient _http;
    private readonly IAuthService _authService;

    public CartService(
        ILocalStorageService localStorage, 
        HttpClient http,
        IAuthService authService)
    {
        _localStorage = localStorage;
        _http = http;
        _authService = authService;
    }

    public event Action OnChange;

    public async Task AddToCart(CartItem cartItem)
    {
        if (await _authService.IsUserAuthenticated())
            await _http.PostAsJsonAsync("api/cart/add", cartItem);
        else
        {
            var cart = await _localStorage.GetItemAsync<List<CartItem>>("cart");
            if (cart == null)
                cart = new List<CartItem>();

            var sameItem = cart.Find(x => x.ProductId == cartItem.ProductId &&
                x.ProductTypeId == cartItem.ProductTypeId);
            if (sameItem == null)
                cart.Add(cartItem);
            else
                sameItem.Quantity += cartItem.Quantity;

            await _localStorage.SetItemAsync("cart", cart);
        }

        await GetCartItemsCount();
    }

    public async Task<List<CartProductResponse>> GetCartProducts()
    {
        if (await _authService.IsUserAuthenticated())
        {
            var response = await _http
                .GetFromJsonAsync<ServiceResponse<List<CartProductResponse>>>("api/cart");
            return response.Data;
        }
        else
        {
            var cartItems = await _localStorage.GetItemAsync<List<CartItem>>("cart");
            if (cartItems == null)
                return new List<CartProductResponse>();

            var response = await _http.PostAsJsonAsync("api/cart/products", cartItems);
            var cartProducts =
                await response.Content
                    .ReadFromJsonAsync<ServiceResponse<List<CartProductResponse>>>();
            return cartProducts.Data;
        }
    }

    public async Task RemoveProductFromCart(int productId, int productTypeId)
    {
        if (await _authService.IsUserAuthenticated())
            await _http.DeleteAsync($"api/cart/{productId}/{productTypeId}");
        else
        {
            var cart = await _localStorage.GetItemAsync<List<CartItem>>("cart");
            if (cart == null)
                return;

            var cartItem = cart.Find(x => x.ProductId == productId
            && x.ProductTypeId == productTypeId);
            if (cartItem != null)
            {
                cart.Remove(cartItem);
                await _localStorage.SetItemAsync("cart", cart);
            }
        }

        await GetCartItemsCount();
    }

    public async Task UpdateQuantity(CartProductResponse product)
    {
        if (await _authService.IsUserAuthenticated())
        {
            var request = new CartItem
            {
                ProductId = product.ProductId,
                Quantity = product.Quantity,
                ProductTypeId = product.ProductTypeId
            };
            await _http.PostAsJsonAsync("api/cart/update-quantity", request);
        }
        else
        {
            var cart = await _localStorage.GetItemAsync<List<CartItem>>("cart");
            if (cart == null)
                return;

            var cartItem = cart.Find(x => x.ProductId == product.ProductId
            && x.ProductTypeId == product.ProductTypeId);
            if (cartItem != null)
            {
                cartItem.Quantity = product.Quantity;
                await _localStorage.SetItemAsync("cart", cart);
            }
        }
    }

    public async Task StoreCartItems(bool emptyLocalCart = false)
    {
        var localCart = await _localStorage.GetItemAsync<List<CartItem>>("cart");
        if (localCart == null)
            return;

        await _http.PostAsJsonAsync("api/cart", localCart);

        if (emptyLocalCart)
            await _localStorage.RemoveItemAsync("cart");
    }

    public async Task GetCartItemsCount()
    {
        if (await _authService.IsUserAuthenticated())
        {
            var result = await _http.GetFromJsonAsync<ServiceResponse<int>>("api/cart/count");
            var count = result.Data;

            await _localStorage.SetItemAsync<int>("cartItemsCount", count);
        }
        else
        {
            var cart = await _localStorage.GetItemAsync<List<CartItem>>("cart");
            await _localStorage.SetItemAsync<int>("cartItemsCount", cart != null ? cart.Count : 0);
        }

        OnChange.Invoke();
    }
}

Order

Models

  • BlazorEcommerce.Shared/Order.cs
public class Order
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public DateTime OrderDate { get; set; } = DateTime.Now;
    [Column(TypeName = "decimal(18,2)")]
    public decimal TotalPrice { get; set; }
    public List<OrderItem> OrderItems { get; set; }
}
  • BlazorEcommerce.Shared/OrderDetailsProductResponse.cs
public class OrderDetailsProductResponse
{
    public int ProductId { get; set; }
    public string Title { get; set; }
    public string ProductType { get; set; }
    public string ImageUrl { get; set; }
    public int Quantity { get; set; }
    public decimal TotalPrice { get; set; }
}
  • BlazorEcommerce.Shared/OrderDetailsResponse.cs
public class OrderDetailsResponse
{
    public DateTime OrderDate { get; set; }
    public decimal TotalPrice { get; set; }
    public List<OrderDetailsProductResponse> Products { get; set; }
}
  • BlazorEcommerce.Shared/OrderItem.cs
public class OrderItem
{
    public Order Order { get; set; }
    public int OrderId { get; set; }
    public Product Product { get; set; }
    public int ProductId { get; set; }
    public ProductType ProductType { get; set; }
    public int ProductTypeId { get; set; }
    public int Quantity { get; set; }
    [Column(TypeName = "decimal(18,2)")]
    public decimal TotalPrice { get; set; }
}
  • BlazorEcommerce.Shared/OrderOverviewResponse.cs
public class OrderOverviewResponse
{
    public int Id { get; set; }
    public DateTime OrderDate { get; set; }
    public decimal TotalPrice { get; set; }
    public string Product { get; set; }
    public string ProductImageUrl { get; set; }
}

Set

  • BlazorEcommerce.Server/Data/DataContext.cs
public class DataContext : DbContext
{
    ...
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        ...
        modelBuilder.Entity<OrderItem>()
            .HasKey(oi => new { oi.OrderId, oi.ProductId, oi.ProductTypeId });
        ...
    }

    ...
    public DbSet<Order> Orders { get; set; }
    public DbSet<OrderItem> OrderItems { get; set; }
}

Database

  • Type dotnet ef migrations add Orders and dotnet ef database update

Order Service in Server

Interface

  • BlazorEcommerce.Server/Services/OrderService/IOrderService.cs
public interface IOrderService
{
    Task<ServiceResponse<bool>> PlaceOrder();
    Task<ServiceResponse<List<OrderOverviewResponse>>> GetOrders();
    Task<ServiceResponse<OrderDetailsResponse>> GetOrderDetails(int orderId);
}

Implement

  • BlazorEcommerce.Server/Services/OrderService/OrderService.cs
public class OrderService : IOrderService
{
    private readonly DataContext _context;
    private readonly IAuthService _authService;
    private readonly ICartService _cartService;

    public OrderService(
        DataContext context,
        IAuthService authService,
        ICartService cartService)
    {
        _context = context;
        _authService = authService;
        _cartService = cartService;
    }

    public async Task<ServiceResponse<bool>> PlaceOrder()
    {
        var products = (await _cartService.GetDbCartProducts()).Data;
        decimal totalPrice = 0;
        products.ForEach(product => totalPrice += product.Price * product.Quantity);

        var orderItems =  new List<OrderItem>();
        products.ForEach(product => orderItems.Add(new OrderItem
        {
            ProductId = product.ProductId,
            ProductTypeId = product.ProductTypeId,
            Quantity = product.Quantity,
            TotalPrice = product.Price * product.Quantity
        }));

        var order = new Order
        {
            UserId = _authService.GetUserId(),
            OrderDate = DateTime.Now,
            TotalPrice = totalPrice,
            OrderItems = orderItems
        };

        _context.Add(order);

        _context.CartItems.RemoveRange(_context.CartItems
            .Where(ci => ci.UserId == _authService.GetUserId()));

        await _context.SaveChangesAsync();

        return new ServiceResponse<bool> { Data = true };
    }

    public async Task<ServiceResponse<List<OrderOverviewResponse>>> GetOrders()
    {
        var response = new ServiceResponse<List<OrderOverviewResponse>>();
        var order = await _context.Orders
            .Include(o => o.OrderItems)
            .ThenInclude(oi => oi.Product)
            .Where(o => o.UserId == _authService.GetUserId())
            .OrderByDescending(o => o.OrderDate)
            .ToListAsync();

        var orderResponse = new List<OrderOverviewResponse>();
        order.ForEach(o => orderResponse.Add(new OrderOverviewResponse
        {
            Id = o.Id,
            OrderDate = o.OrderDate,
            TotalPrice = o.TotalPrice,
            Product = o.OrderItems.Count > 1 ?
                $"{o.OrderItems.First().Product.Title} and" +
                $"{o.OrderItems.Count - 1} more..." :
                o.OrderItems.First().Product.Title,
            ProductImageUrl = o.OrderItems.First().Product.ImageUrl
        }));

        response.Data = orderResponse;
        return response;
    }

    public async Task<ServiceResponse<OrderDetailsResponse>> GetOrderDetails(int orderId)
    {
        var response = new ServiceResponse<OrderDetailsResponse>();
        var order = await _context.Orders
            .Include(o => o.OrderItems)
            .ThenInclude(oi => oi.Product)
            .Include(o => o.OrderItems)
            .ThenInclude(oi => oi.ProductType)
            .Where(o => o.UserId == _authService.GetUserId() && o.Id == orderId)
            .OrderByDescending(o => o.OrderDate)
            .FirstOrDefaultAsync();

        if(order == null)
        {
            response.Success = false;
            response.Message = "Order not found.";
            return response;
        }

        var orderDetailsResponse = new OrderDetailsResponse
        {
            OrderDate = order.OrderDate,
            TotalPrice = order.TotalPrice,
            Products = new List<OrderDetailsProductResponse>()
        };

        order.OrderItems.ForEach(item =>
        orderDetailsResponse.Products.Add(new OrderDetailsProductResponse
        {
            ProductId = item.ProductId,
            ImageUrl = item.Product.ImageUrl,
            ProductType = item.ProductType.Name,
            Quantity = item.Quantity,
            Title = item.Product.Title,
            TotalPrice = item.TotalPrice
        }));

        response.Data = orderDetailsResponse;
        return response;
    }
}

Controller

  • BlazorEcommerce.Server/Controllers/OrderController.cs
[Route("api/[controller]")]
[ApiController]
public class OrderController : ControllerBase
{
    private readonly IOrderService _orderService;

    public OrderController(IOrderService orderService)
    {
        _orderService = orderService;
    }

    [HttpPost]
    public async Task<ActionResult<ServiceResponse<bool>>> PlaceOrder()
    {
        var result = await _orderService.PlaceOrder();
        return Ok(result);
    }

    [HttpGet]
    public async Task<ActionResult<ServiceResponse<List<OrderOverviewResponse>>>> GetOrders()
    {
        var result = await _orderService.GetOrders();
        return Ok(result);
    }

    [HttpGet("{orderId}")]
    public async Task<ActionResult<ServiceResponse<OrderDetailsResponse>>> GetOrderDetails
        (int orderId)
    {
        var result = await _orderService.GetOrderDetails(orderId);
        return Ok(result);
    }
}

Set

  • BlazorEcommerce.Server/Program.cs
...
builder.Services.AddScoped<IOrderService, OrderService>();
...

Order Service in Client

Interface

  • BlazorEcommerce.Client/Services/OrderService/IOrderService.cs
public interface IOrderService
{
    Task PlaceOrder();
    Task<List<OrderOverviewResponse>> GetOrders();
    Task<OrderDetailsResponse> GetOrderDetails(int orderId);
}

Implement

  • BlazorEcommerce.Client/Services/OrderService/OrderService.cs
public class OrderService : IOrderService
{
    private readonly HttpClient _http;
    private readonly AuthenticationStateProvider _authStateProvider;
    private readonly NavigationManager _navigationManager;

    public OrderService(
        HttpClient http, 
        AuthenticationStateProvider authStateProvider,
        NavigationManager navigationManager)
    {
        _http = http;
        _authStateProvider = authStateProvider;
        _navigationManager = navigationManager;
    }

    private async Task<bool> IsUserAuthenticated()
        => (await _authStateProvider.GetAuthenticationStateAsync())
        .User.Identity.IsAuthenticated;

    public async Task PlaceOrder()
    {
        if (await IsUserAuthenticated())
            await _http.PostAsync("api/order", null);
        else
            _navigationManager.NavigateTo("login");
    }

    public async Task<List<OrderOverviewResponse>> GetOrders()
    {
        var result = await _http
            .GetFromJsonAsync<ServiceResponse<List<OrderOverviewResponse>>>("api/order");
        return result.Data;
    }

    public async Task<OrderDetailsResponse> GetOrderDetails(int orderId)
    {
        var result = await _http
            .GetFromJsonAsync<ServiceResponse<OrderDetailsResponse>>($"api/order/{orderId}");
        return result.Data;
    }
}

Set

  • BlazorEcommerce.Client/Program.cs
...
builder.Services.AddScoped<IOrderService, OrderService>();
...

View

Cart

  • BlazorEcommerce.Client/Shared/UserButton.razor
...
<AuthorizeView>
    <Authorized>
        <a href="profile" class="dropdown-item">Profile</a>
        <a href="orders" class="dropdown-item">Orders</a>
        <hr/>
        <button class="dropdown-item" @onclick="Logout">Logout</button>
    </Authorized>
    <NotAuthorized>
        <a href="login?returnUrl=@NavigationManager.ToBaseRelativePath(NavigationManager.Uri)" 
            class="dropdown-item">Login</a>
        <a href="register" class="dropdown-item">Register</a>
    </NotAuthorized>
</AuthorizeView>
...
  • BlazorEcommerce.Client/Shared/UserButton.razor.cs
public partial class UserButton
{
    ...
    [Inject]
    public ICartService CartService { get; set; }
    ...

    private async Task Logout()
    {
        await LocalStorage.RemoveItemAsync("authToken");
        await CartService.GetCartItemsCount();
        await AuthenticationStateProvider.GetAuthenticationStateAsync();
        NavigationManager.NavigateTo("");
    }
}
  • BlazorEcommerce.Client/Shared/CartCounter.razor.cs
public partial class CartCounter : IDisposable
{
    ...
    private int GetCartItemCount()
    {
        var count = LocalStorage.GetItem<int>("cartItemsCount");
        return count;
    }
    ...
}
  • BlazorEcommerce.Client/Pages/Login.razor.cs
public partial class Login
{
    ...
    [Inject]
    public ICartService CartService { get; set; }
    ...

    private async Task HandleLogin()
    {
        var result = await AuthService.Login(user);
        if(result.Success)
        {
            errorMessage = string.Empty;

            await LocalStorage.SetItemAsync("authToken", result.Data);
            await AuthenticationStateProvider.GetAuthenticationStateAsync();
            await CartService.StoreCartItems(true);
            await CartService.GetCartItemsCount();
            NavigationManager.NavigateTo(returnUrl);
        }
        else
            errorMessage = result.Message;
    }
}
  • BlazorEcommerce.Client/Pages/Cart.razor.cs
public partial class Login
{
    ...
    private async Task LoadCart()
    {
        await CartService.GetCartItemsCount();
        cartProducts = await CartService.GetCartProducts();
        if (!cartProducts.Any())
            message = "Your cart is empty.";
    }
}

Order

  • BlazorEcommerce.Client/Pages/Cart.razor
...
else if(orderPlaced)
{
    <span>Thank you for your order! You can check your orders <a href="orders">here</a>.</span>
}
else
{
    ...
    <button @onclick="PlaceOrder" class="btn alert-success float-end mt-1">Place Order</button>
}
  • BlazorEcommerce.Client/Pages/Cart.razor.cs
public partial class Login
{
    ...
    [Inject]
    public IOrderService OrderService { get; set; }

    ...
    bool orderPlaced = false;

    protected override async Task OnInitializedAsync()
    {
        orderPlaced = false;
        await LoadCart();
    }

    private async Task PlaceOrder()
    {
        await OrderService.PlaceOrder();
        await CartService.GetCartItemsCount();
        orderPlaced = true;
    }
}
  • BlazorEcommerce.Client/Pages/Order.razor
@page "/orders"

<h3>Orders</h3>

@if(orders == null)
{
    <span>Loading your orders...</span>
}
else if(orders.Count <= 0)
{
    <span>You have no orders, yet.</span>
}
else
{
    foreach(var order in orders)
    {
        <div class="container">
            <div class="image-wrapper">
                <img src="@order.ProductImageUrl" class="image"/>
            </div>
            <div class="details">
                <h4>@order.Product</h4>
                <span>@order.OrderDate</span><br/>
                <a href="orders/@order.Id">Show more...</a>
            </div>
            <div class="order-price">$@order.TotalPrice</div>
        </div>
    }
}
  • BlazorEcommerce.Client/Pages/Order.razor.cs
public partial class Orders
{
    [Inject]
    public IOrderService OrderService { get; set; }

    List<OrderOverviewResponse> orders = null;

    protected override async Task OnInitializedAsync()
    {
        orders = await OrderService.GetOrders();
    }
}
  • BlazorEcommerce.Client/Pages/Order.razor.css
.container {
    display: flex;
    padding: 6px;
    border: 1px solid;
    border-color: lightgray;
    border-radius: 6px;
    margin-bottom: 10px;
}

.image-wrapper {
    width: 150px;
    text-align: center;
}

.image {
    max-height: 150px;
    max-width: 150px;
    padding: 6px;
}

.details {
    flex-grow: 1;
    padding: 6px;
}

.order-price {
    font-weight: 600;
    font-size: 1.2em;
    text-align: right;
}
  • BlazorEcommerce.Client/Pages/OrderDetails.razor
@page "/orders/{orderId:int}"

@if(order == null)
{
    <span>Loading order...</span>
}
else
{
    <h3>Order from @order.OrderDate</h3>

    <div>
        @foreach(var product in order.Products)
        {
            <div class="container">
                <div class="image-wrapper">
                    <img src="@product.ImageUrl" class="image"/>
                </div>
            </div>
            <div class="name">
                <h5><a href="/product/@product.ProductId">@product.Title</a></h5>
                <span>@product.ProductType</span><br/>
                <span>Quantity: @product.Quantity</span>
            </div>
            <div class="product-price">$@product.TotalPrice</div>
        }
        <div class="product-price">
            Total: $@order.TotalPrice
        </div>
    </div>
}
  • BlazorEcommerce.Client/Pages/OrderDetails.razor.cs
 public partial class OrderDetails
{
    [Parameter]
    public int OrderId { get; set; }

    [Inject]
    public IOrderService OrderService { get; set; }

    OrderDetailsResponse order = null;

    protected override async Task OnInitializedAsync()
    {
        order = await OrderService.GetOrderDetails(OrderId);
    }
}
  • BlazorEcommerce.Client/Pages/OrderDetails.razor.css
.container {
    display: flex;
    padding: 6px;
}

.image-wrapper {
    width: 150px;
    text-align: center;
}

.image {
    max-height: 150px;
    max-width: 150px;
    padding: 6px;
}

.name {
    flex-grow: 1;
    padding: 6px;
}

.product-price {
    font-weight: 600;
    text-align: right;
}

Run

Cart

Cart

Order

Order

Payment with Webhook

  • A webhook in web development is a method of augmenting or altering the behavior of a web page or web application with custom callbacks.

Stripe

  • It offers payment processing software and application programming interfaces (APIs) for e-commerce websites and mobile.
  • Please read Stripe Documents, if you want to get more information.

Account

  • Create account ot Stripe.
Payment with Stripe Checkout

API Key

  • Create your secret key from Stripe.
API Key

Stripe CLI

  • Download Stripe-Cli.
  • Login in Stripe with stripe login.
Package in Server

Package in Server

  • Download Stripe.net in Server.
Package in Server

Payment Service in Server

Get User Email From Auth Service

  • BlazorEcommerce.Server/Services/AuthService/IAuthService.cs
public interface IAuthService
{
    ...
    string GetUserEmail();
    Task<User> GetUserByEmail(string email);
}
  • BlazorEcommerce.Server/Services/AuthService/AuthService.cs
public class AuthService : IAuthService
{
    ...
    public string GetUserEmail()
            => _httpContextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.Name);

    public async Task<User> GetUserByEmail(string email)
        => await _context.Users.FirstOrDefaultAsync(u => u.Email.Equals(email));
    ...
}

When you checkout, Get Cart Products from Database

  • BlazorEcommerce.Server/Services/CartService/ICartService.cs
public interface ICartService
{
    ...
    Task<ServiceResponse<List<CartProductResponse>>> GetDbCartProducts(int? userId = null);
    ...
}
  • BlazorEcommerce.Server/Services/CartService/CartService.cs
public class CartService : ICartService
{
    ...
    public async Task<ServiceResponse<List<CartProductResponse>>> GetDbCartProducts
        (int? userId  = null)
    {
        if(userId == null)
            userId = _authService.GetUserId();

        return await GetCartProducts(await _context.CartItems
            .Where(ci => ci.UserId == userId).ToListAsync());
    }
    ...
}

get Orders when you checkout

  • BlazorEcommerce.Server/Services/OrderService/IOrderService.cs
public interface IOrderService
{
    Task<ServiceResponse<bool>> PlaceOrder(int userId);
    ...
}
  • BlazorEcommerce.Server/Services/OrderService/OrderService.cs
public class CartService : ICartService
{
    ...
    public async Task<ServiceResponse<bool>> PlaceOrder(int userId)
    {
        var products = (await _cartService.GetDbCartProducts(userId)).Data;
        decimal totalPrice = 0;
        products.ForEach(product => totalPrice += product.Price * product.Quantity);

        var orderItems =  new List<OrderItem>();
        products.ForEach(product => orderItems.Add(new OrderItem
        {
            ProductId = product.ProductId,
            ProductTypeId = product.ProductTypeId,
            Quantity = product.Quantity,
            TotalPrice = product.Price * product.Quantity
        }));

        var order = new Order
        {
            UserId = userId,
            OrderDate = DateTime.Now,
            TotalPrice = totalPrice,
            OrderItems = orderItems
        };

        _context.Add(order);

        _context.CartItems.RemoveRange(_context.CartItems
            .Where(ci => ci.UserId == userId));

        await _context.SaveChangesAsync();

        return new ServiceResponse<bool> { Data = true };
    }
    ...
}
  • BlazorEcommerce.Server/Controllers/OrderController.cs
[Route("api/[controller]")]
[ApiController]
public class OrderController : ControllerBase
{
    private readonly IOrderService _orderService;

    public OrderController(IOrderService orderService)
    {
        _orderService = orderService;
    }

    [HttpGet]
    public async Task<ActionResult<ServiceResponse<List<OrderOverviewResponse>>>> GetOrders()
    {
        var result = await _orderService.GetOrders();
        return Ok(result);
    }

    [HttpGet("{orderId}")]
    public async Task<ActionResult<ServiceResponse<OrderDetailsResponse>>> GetOrderDetails
        (int orderId)
    {
        var result = await _orderService.GetOrderDetails(orderId);
        return Ok(result);
    }
}

Payment Service

  • BlazorEcommerce.Server/Services/PaymentService/IPaymentService.cs
public interface IPaymentService
{
    Task<Session> CreateCheckoutSession();
    Task<ServiceResponse<bool>> FulfillOrder(HttpRequest request);
}
  • BlazorEcommerce.Server/Services/PaymentService/PaymentService.cs
public class PaymentService : IPaymentService
{
    private readonly IAuthService _authService;
    private readonly ICartService _cartService;
    private readonly IOrderService _orderService;

    const string secret = "your secret key";

    public PaymentService(
        IAuthService authService,
        ICartService cartService,
        IOrderService orderService)
    {
        StripeConfiguration.ApiKey = "your api key";

        _authService = authService;
        _cartService = cartService;
        _orderService = orderService;
    }

    public async Task<Session> CreateCheckoutSession()
    {
        var products = (await _cartService.GetDbCartProducts()).Data;
        var lineItems = new List<SessionLineItemOptions>();
        products.ForEach(product => lineItems.Add(new SessionLineItemOptions
        {
            PriceData = new SessionLineItemPriceDataOptions
            {
                UnitAmountDecimal = product.Price * 100,
                Currency = "usd",
                ProductData = new SessionLineItemPriceDataProductDataOptions
                {
                    Name = product.Title,
                    Images = new List<string> { product.ImageUrl }
                }
            },
            Quantity = product.Quantity
        }));

        var options = new SessionCreateOptions
        {
            CustomerEmail = _authService.GetUserEmail(),
            ShippingAddressCollection = 
                new SessionShippingAddressCollectionOptions
                {
                    AllowedCountries = new List<string> { "US" }
                },
            PaymentMethodTypes = new List<string> { "card" },
            LineItems = lineItems,
            Mode = "payment",
            SuccessUrl = "your local host url/order-success",
            CancelUrl = "your local host url/cart"
        };

        var service = new SessionService();
        Session session = service.Create(options);
        return session;
    }

    public async Task<ServiceResponse<bool>> FulfillOrder(HttpRequest request)
    {
        var json = await new StreamReader(request.Body).ReadToEndAsync();
        try
        {
            var stripeEvent = EventUtility.ConstructEvent(
                json,
                request.Headers["Stripe-Signature"],
                secret);

            if(stripeEvent.Type == Events.CheckoutSessionCompleted)
            {
                var session = stripeEvent.Data.Object as Session;
                var user = await _authService.GetUserByEmail(session.CustomerEmail);
                await _orderService.PlaceOrder(user.Id);
            }

            return new ServiceResponse<bool> { Data = true };
        }
        catch (StripeException e)
        {
            return new ServiceResponse<bool> { Data = false, Success = false, Message = e.Message };
        }
    }
}
  • BlazorEcommerce.Server/Controllers/PaymentController.cs
[Route("api/[controller]")]
[ApiController]
public class PaymentController : ControllerBase
{
    private readonly IPaymentService _paymentService;

    public PaymentController(IPaymentService paymentService)
    {
        _paymentService = paymentService;
    }

    [HttpPost("checkout"), Authorize]
    public async Task<ActionResult<string>> CreateCheckoutSession()
    {
        var session = await _paymentService.CreateCheckoutSession();
        return Ok(session.Url);
    }

    [HttpPost]
    public async Task<ActionResult<ServiceResponse<string>>> FulfillOrder()
    {
        var response = await _paymentService.FulfillOrder(Request);
        if(!response.Success)
            return BadRequest(response.Message);

        return Ok(response);
    }
}

Set

  • BlazorEcommerce.Server/Program.cs
...
builder.Services.AddScoped<IPaymentService, PaymentService>();
...

Refactoring Order Service in Client

  • BlazorEcommerce.Client/Services/OrderService/IOrderService.cs
public interface IOrderService
{
    Task<string> PlaceOrder();
    ...
}
  • BlazorEcommerce.Client/Services/OrderService/OrderService.cs
public class OrderService : IOrderService
{
    ...
    public async Task<string> PlaceOrder()
    {
        if (await IsUserAuthenticated())
        {
            var result = await _http.PostAsync("api/payment/checkout", null);
            var url = await result.Content.ReadAsStringAsync();
            return url;
        }
        else
            return "login";
    }
    ...
}

View

  • BlazorEcommerce.Client/Pages/Cart.razor
@page "/cart"

<h3>Shopping Cart</h3>

@if (!cartProducts.Any())
{
    <span>@message</span>
}
else
{
    <div>
        @foreach(var product in cartProducts)
        {
            <div class="container">
                <div class="image-wrapper">
                    <img src="@product.ImageUrl" class="image"/>
                </div>
                <div class="name">
                    <h5><a href="/product/@product.ProductId">@product.Title</a></h5>
                    <span>@product.ProductType</span><br/>
                    <input type="number" 
                           value="@product.Quantity" 
                           @onchange="@((ChangeEventArgs e) => UpdateQuantity(e, product))" 
                           class="form-control input-quantity" 
                           min="1"/>
                    <button class="btn-delete" 
                            @onclick="@(() 
                                => RemoveProductFromCart(product.ProductId, product.ProductTypeId))">
                            Delete</button>
                </div>
                <div class="cart-product-price">$@(product.Price * product.Quantity)</div>
            </div>
        }
        <div class="cart-product-price">
            Total (@cartProducts.Count): $@cartProducts.Sum(product => 
                @product.Price * product.Quantity)
        </div>
    </div>
    <button @onclick="PlaceOrder" class="btn alert-success float-end mt-1">Checkout</button>
}
  • BlazorEcommerce.Client/Pages/Cart.razor.cs
public partial class Cart
{
    ...
    [Inject]
    public NavigationManager NavigationManager { get; set; }

    ...
    protected override async Task OnInitializedAsync()
        => await LoadCart();

    ...
    private async Task PlaceOrder()
    {
        string url = await OrderService.PlaceOrder();
        NavigationManager.NavigateTo(url);
    }
}
  • BlazorEcommerce.Client/Pages/OrderSuccess.razor
@page "/order-success"

<h3>Thank you!</h3>

<span>Thank you for your order! You can check your orders <a href="orders">here</a>.</span>
  • BlazorEcommerce.Client/Pages/Cart.razor.cs
public partial class OrderSucess
{
    [Inject]
    public ICartService CartService { get; set; }

    protected override async Task OnInitializedAsync()
        => await CartService.GetCartItemsCount();
}

Run

Payment

Payment

Address

Models

  • BlazorEcommerce.Shared/Address.cs
public class Address
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string Street { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public string State { get; set; } = string.Empty;
    public string Zip { get; set; } = string.Empty;
    public string Country { get; set; } = string.Empty;
}
  • BlazorEcommerce.Shared/User.cs
public class User
{
    ...
    public Address Address { get; set; }
}

Set

  • BlazorEcommerce.Shared/Program.cs
public class DataContext : DbContext
{
    ...
    public DbSet<Address> Addresses { get; set; }
}

Database

  • Type dotnet ef migrations add UserAddress and dotnet ef database update.

Address Service in Server

Interface

  • BlazorEcommerce.Server/Services/AddressService/IAddressService.cs
public interface IAddressService
{
    Task<ServiceResponse<Address>> GetAddress();
    Task<ServiceResponse<Address>> AddOrUpdateAddress(Address address);
}

Implement

  • BlazorEcommerce.Server/Services/AddressService/AddressService.cs
public class AddressService : IAddressService
{
    private readonly DataContext _context;
    private readonly IAuthService _authService;

    public AddressService(DataContext context, IAuthService authService)
    {
        _context = context;
        _authService = authService;
    }

    public async Task<ServiceResponse<Address>> AddOrUpdateAddress(Address address)
    {
        var response = new ServiceResponse<Address>();
        var dbAddress = (await GetAddress()).Data;
        if(dbAddress == null)
        {
            address.UserId = _authService.GetUserId();
            _context.Addresses.Add(address);
            response.Data = address;
        }
        else
        { 
            dbAddress.FirstName = address.FirstName;
            dbAddress.LastName = address.LastName;
            dbAddress.Street = address.Street;
            dbAddress.City = address.City;
            dbAddress.State = address.State;
            dbAddress.Zip = address.Zip;
            dbAddress.Country = address.Country;
            response.Data = dbAddress;
        }

        await _context.SaveChangesAsync();

        return response;
    }

    public async Task<ServiceResponse<Address>> GetAddress()
    {
        int userId = _authService.GetUserId();
        var address = await _context.Addresses
            .FirstOrDefaultAsync(a => a.UserId == userId);
        return new ServiceResponse<Address> { Data = address };
    }
}

Controller

  • BlazorEcommerce.Server/Controllers/AddressController.cs
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class AddressController : ControllerBase
{
    private readonly IAddressService _addressService;

    public AddressController(IAddressService addressService)
    {
        _addressService = addressService;
    }

    [HttpGet]
    public async Task<ActionResult<ServiceResponse<Address>>> GetAddress()
        => await _addressService.GetAddress();

    [HttpPost]
    public async Task<ActionResult<ServiceResponse<Address>>> AddOrUpdateAddress(Address address)
        => await _addressService.AddOrUpdateAddress(address);
}

Set

  • BlazorEcommerce.Server/Program.cs
...
builder.Services.AddScoped<IAddressService, AddressService>();
...

Address Service in Client

Interface

  • BlazorEcommerce.Client/Services/AddressService/IAddressService.cs
public interface IAddressService
{
    Task<Address> GetAddress();
    Task<Address> AddOrUpdateAddress(Address address);
}

Implement

  • BlazorEcommerce.Client/Services/AddressService/AddressService.cs
public class AddressService : IAddressService
{
    private readonly HttpClient _http;

    public AddressService(HttpClient http)
    {
        _http = http;
    }

    public async Task<Address> AddOrUpdateAddress(Address address)
    {
        var response = await _http.PostAsJsonAsync("api/address", address);
        return response.Content.ReadFromJsonAsync<ServiceResponse<Address>>().Result.Data;
    }

    public async Task<Address> GetAddress()
    {
        var response = await _http.GetFromJsonAsync<ServiceResponse<Address>>("api/address");
        return response.Data;
    }
}

Set

  • BlazorEcommerce.Client/Program.cs
...
builder.Services.AddScoped<IAddressService, AddressService>();
...

View

  • BlazorEcommerce.Client/Shared/AddressForm.razor
@if (address == null)
{
    <span>
        You haven't specified a delivery address, yet.
        <button class="btn" @onclick="InitAddress">Add an address?</button>
    </span>
}
else if (!editAddress)
{
    <p>
        <span>@address.FirstName @address.LastName</span><br/>
        <span>@address.Street</span><br/>
        <span>@address.City @address.State @address.Zip</span><br/>
        <span>@address.Country</span><br/>
    </p>
    <button class="btn btn-primary" @onclick="EditAddress">Edit</button>
}
else
{
    <EditForm Model="address" OnSubmit="SubmitAddress">
        <div class="mb-3">
            <label for="firstname">First Name</label>
            <InputText id="firstname" @bind-Value="address.FirstName" class="form-control" />
        </div>
        <div class="mb-3">
            <label for="lastname">Last Name</label>
            <InputText id="lastname" @bind-Value="address.LastName" class="form-control" />
        </div>
        <div class="mb-3">
            <label for="street">Street</label>
            <InputText id="street" @bind-Value="address.Street" class="form-control" />
        </div>
        <div class="mb-3">
            <label for="city">City</label>
            <InputText id="city" @bind-Value="address.City" class="form-control" />
        </div>
        <div class="mb-3">
            <label for="state">State</label>
            <InputText id="state" @bind-Value="address.State" class="form-control" />
        </div>
        <div class="mb-3">
            <label for="zip">Zip/Postal Code</label>
            <InputText id="zip" @bind-Value="address.Zip" class="form-control" />
        </div>
        <div class="mb-3">
            <label for="country">Country</label>
            <InputText id="country" @bind-Value="address.Country" class="form-control" />
        </div>
        <button type="submit" class="btn btn-primary">Save</button>
    </EditForm>
}
  • BlazorEcommerce.Client/Shared/AddressForm.razor.cs
public partial class AddressForm
{
    [Inject]
    public IAddressService AddressService { get; set; }

    Address address = null;
    bool editAddress = false;

    protected override async Task OnInitializedAsync()
        => address = await AddressService.GetAddress();

    private async Task SubmitAddress()
    {
        editAddress = false;
        address = await AddressService.AddOrUpdateAddress(address);
    }

    private void InitAddress()
    {
        address = new Address();
        editAddress = true;
    }

    private void EditAddress()
        => editAddress = true;
}
  • BlazorEcommerce.Client/Pages/Cart.razor
...
@if(isAuthenticated)
{
    <div>
        <h5>Delivery Address</h5>
        <AddressForm />
    </div>
}
...
  • BlazorEcommerce.Client/Pages/Cart.razor.cs
public partial class Cart
{
    ...
    [Inject]
    public IAuthService AuthService { get; set; }

    ...
    bool isAuthenticated = false;

    protected override async Task OnInitializedAsync()
    {
        isAuthenticated = await AuthService.IsUserAuthenticated();
        await LoadCart();
    }
    ...
}
  • BlazorEcommerce.Client/Pages/Profile.razor
...
<h5>Delivery Address</h5>
<AddressForm />
<p></p>
...

Run

Profile

Profile

Cart

Cart

Code

Download



C#.Net6BlazorWSAM Share Tweet +1