A Small Blazor Demo: Updating a Counter
Blazor lets a page respond to user actions without requiring a full browser-page reload.
A Razor component usually combines HTML-like markup with C# logic.
The markup defines the content that appears on the screen.
The @code block contains the state and methods used by that markup.
In this example, a button updates a number displayed on the page.
The @page directive makes the component available at a browser route.
The @count expression displays the current value of the count field.
The @onclick directive connects the button to a C# method.
When the button is clicked, IncrementCount changes the value.
Blazor detects the event and renders the updated value.
A second button demonstrates how another event can reset the same state.
This small example introduces routing, component state, event handling, and rendering.
@page "/counter-demo"
<PageTitle>Counter Demo</PageTitle>
<div class="container py-4">
<h2>Counter Demo</h2>
<p>Current count: <strong>@count</strong></p>
<button class="btn btn-primary me-2"
@onclick="IncrementCount">
Increase
</button>
<button class="btn btn-outline-secondary"
@onclick="ResetCount">
Reset
</button>
</div>
@code {
private int count = 0;
private void IncrementCount()
{
count++;
}
private void ResetCount()
{
count = 0;
}
}
What the demo teaches
-
@page "/counter-demo"defines the route for the component. -
private int count = 0;stores the component's current state. -
@countrenders that state in the markup. -
@onclick="IncrementCount"connects a UI event to a C# method. -
IncrementCountchanges the state, and Blazor refreshes the rendered value. -
ResetCountdemonstrates that multiple events can update the same state.
The following implementation applies these Blazor fundamentals to a larger interface, with separate pages and reusable components.
Project Documentation: Pages and Reusable Components
Purpose
This implementation contains two routed pages and three reusable UI
components. The page components own their API calls and page-level
state. The child components focus on rendering reusable parts of the
interface and raising events to their parent.
Folder structure
GymManagement.Client/
├── Pages/
│ ├── MyMemberships.razor
│ └── MyAttendance.razor
└── Components/
└── Membership/
├── MembershipSummaryCards.razor
├── MembershipCard.razor
└── MembershipCardList.razor
Component relationship
-
MyMemberships.razoris the parent page. It loads data, calls the summary endpoint, handles searching and status filtering, sends sorting parameters, controls pagination, and performs cancellation/navigation actions. -
MembershipSummaryCards.razorreceives four counts through[Parameter]properties and displays them. -
MembershipCardList.razorloops through the current page of records and renders oneMembershipCard.razorfor each record. -
MembershipCard.razordisplays one record and usesEventCallback<Guid>to notify the list/page when the user chooses Renew, Cancel, or Attendance. -
MyAttendance.razorloads attendance history and manages its own pagination.
Important behavior
- Searching, filtering, sorting selections, and pagination state are
kept in
MyMemberships.razor; there are no separate search or pagination components. - Attendance pagination is kept in
MyAttendance.razor. - DTOs and helper classes are declared inside the relevant Razor
files'
@codeblocks; no separateModelsfolder is used. -
SortByandSortDirectionare sent to the API. The backend must validate these values and sort the full filtered query before applying pagination. - The summary cards use
api/Subscription/MyMembershipSummary, so the backend should return counts across all records, not only the current page. - The page expects the API response shapes shown in the code. Adjust the inline DTOs if the backend uses different property names or types.
- The attendance DTO is illustrative and should be aligned with the actual attendance endpoint response.
- The API endpoints must enforce authentication and ownership on the server. Do not trust a user ID supplied by the browser to decide which records to return.
Parent page: includes the summary component and list component, and
keeps searching, filtering, sorting, pagination, API requests,
navigation, and cancellation logic in its @code block.
@page "/my-memberships"
@using System.Net.Http.Json
@using System.Globalization
@using Microsoft.AspNetCore.Components.Forms
@using GymManagement.Client.Components.Membership
@using Blazor.SweetAlert2
@inject HttpClient Http
@inject NavigationManager Navigation
@inject SweetAlertService Swal
<PageTitle>My Memberships</PageTitle>
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h2 class="fw-bold mb-1">
<i class="bi bi-person-badge me-2"></i>
My Memberships
</h2>
<p class="text-secondary mb-0">
Manage your memberships and track their status.
</p>
</div>
<button class="btn btn-primary" @onclick="BrowseGyms">
<i class="bi bi-plus-circle me-1"></i>
Browse Gyms
</button>
</div>
<MembershipSummaryCards ActiveCount="@ActiveCount"
ExpiringCount="@ExpiringCount"
ExpiredCount="@ExpiredCount"
CancelledCount="@CancelledCount" />
@* Search, filter, and sorting are handled in this page. *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<EditForm Model="@SearchModel" OnValidSubmit="@SearchAsync">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-4">
<label class="form-label fw-semibold">Search</label>
<div class="input-group">
<span class="input-group-text bg-white">
<i class="bi bi-search"></i>
</span>
<InputText class="form-control"
@bind-Value="SearchModel.SearchTerm"
placeholder="Gym or plan name..." />
</div>
</div>
<div class="col-12 col-sm-6 col-lg-2">
<label class="form-label fw-semibold">Status</label>
<InputSelect class="form-select"
@bind-Value="SearchModel.Status">
<option value="">All statuses</option>
<option value="Active">Active</option>
<option value="Expiring">Expiring</option>
<option value="Expired">Expired</option>
<option value="Cancelled">Cancelled</option>
</InputSelect>
</div>
<div class="col-12 col-sm-6 col-lg-2">
<label class="form-label fw-semibold">Sort by</label>
<InputSelect class="form-select"
@bind-Value="SearchModel.SortBy">
<option value="EndDate">Expiry date</option>
<option value="StartDate">Start date</option>
<option value="PlanName">Plan name</option>
<option value="PricePaid">Price</option>
</InputSelect>
</div>
<div class="col-12 col-sm-6 col-lg-2">
<label class="form-label fw-semibold">Order</label>
<InputSelect class="form-select"
@bind-Value="SearchModel.SortDirection">
<option value="Ascending">Ascending</option>
<option value="Descending">Descending</option>
</InputSelect>
</div>
<div class="col-12 col-sm-6 col-lg-2 d-flex gap-2">
<button type="submit"
class="btn btn-primary flex-grow-1"
disabled="@IsLoading">
Search
</button>
<button type="button"
class="btn btn-outline-secondary"
@onclick="ClearSearchAsync"
disabled="@IsLoading">
Clear
</button>
</div>
</div>
</EditForm>
</div>
</div>
@if (IsLoading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="text-secondary mt-3">Loading memberships...</p>
</div>
}
else if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<div class="alert alert-danger d-flex justify-content-between align-items-center">
<span>@ErrorMessage</span>
<button class="btn btn-outline-danger btn-sm"
@onclick="LoadPageAsync">
Retry
</button>
</div>
}
else if (Memberships.Count == 0)
{
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5">
<i class="bi bi-card-list display-4 text-secondary"></i>
<h5 class="mt-3">No memberships found</h5>
<p class="text-secondary">
Try another search or browse available gyms.
</p>
<button class="btn btn-primary" @onclick="BrowseGyms">
Browse Gyms
</button>
</div>
</div>
}
else
{
<MembershipCardList Items="@Memberships"
ActionDisabled="@IsActionRunning"
OnRenew="@RenewMembership"
OnCancel="@CancelMembershipAsync"
OnAttendance="@ViewAttendance" />
@* Pagination is implemented here, not in a separate component. *@
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mt-4">
<span class="text-secondary small">
Showing @FirstItem–@LastItem of @TotalCount memberships
</span>
<div class="d-flex flex-wrap align-items-center gap-2">
<label for="membershipPageSize" class="small text-secondary">
Per page
</label>
<select id="membershipPageSize"
class="form-select form-select-sm"
style="width: 85px"
value="@PageSize"
@onchange="ChangePageSizeAsync"
disabled="@IsLoading">
<option value="6">6</option>
<option value="9">9</option>
<option value="12">12</option>
</select>
<button class="btn btn-outline-secondary btn-sm"
@onclick="PreviousPageAsync"
disabled="@(IsLoading || PageNumber <= 1)">
<i class="bi bi-chevron-left"></i>
Previous
</button>
<span class="small text-nowrap">
Page @PageNumber of @TotalPages
</span>
<button class="btn btn-outline-secondary btn-sm"
@onclick="NextPageAsync"
disabled="@(IsLoading || PageNumber >= TotalPages)">
Next
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
}
</div>
@code {
private List<MembershipCardList.MembershipItem> Memberships = new();
private SearchModelType SearchModel = new();
private bool IsLoading;
private bool IsActionRunning;
private string ErrorMessage = string.Empty;
private int PageNumber = 1;
private int PageSize = 6;
private int TotalCount;
private int TotalPages = 1;
private int ActiveCount;
private int ExpiringCount;
private int ExpiredCount;
private int CancelledCount;
private int FirstItem =>
TotalCount == 0 ? 0 : ((PageNumber - 1) * PageSize) + 1;
private int LastItem =>
Math.Min(PageNumber * PageSize, TotalCount);
protected override async Task OnInitializedAsync()
{
await LoadPageAsync();
}
private async Task LoadPageAsync()
{
IsLoading = true;
ErrorMessage = string.Empty;
try
{
var searchTerm = Uri.EscapeDataString(
SearchModel.SearchTerm.Trim());
var status = Uri.EscapeDataString(SearchModel.Status);
var sortBy = Uri.EscapeDataString(SearchModel.SortBy);
var sortDirection = Uri.EscapeDataString(
SearchModel.SortDirection);
var url =
"api/Subscription/MyMemberships" +
$"?SearchTerm={searchTerm}" +
$"&Status={status}" +
$"&SortBy={sortBy}" +
$"&SortDirection={sortDirection}" +
$"&PageNumber={PageNumber}" +
$"&PageSize={PageSize}";
var response =
await Http.GetFromJsonAsync<
PagedResponse<MembershipResponseDto>>(url);
if (response is null)
{
throw new InvalidOperationException(
"The server returned an empty memberships response.");
}
Memberships = response.Items.Select(m =>
{
var displayStatus = GetDisplayStatus(m);
return new MembershipCardList.MembershipItem
{
Id = m.Id,
GymId = m.GymId,
GymName = m.GymName,
MembershipPlanId = m.MembershipPlanId,
PlanName = m.PlanName,
StartDate = m.StartDate,
EndDate = m.EndDate,
PricePaid = m.PricePaid,
Status = m.Status,
DisplayStatus = displayStatus
};
}).ToList();
PageNumber = response.PageNumber;
PageSize = response.PageSize;
TotalCount = response.TotalCount;
TotalPages = Math.Max(1, response.TotalPages);
// Get counts across ALL memberships from the summary endpoint.
await LoadSummaryAsync();
}
catch (Exception ex)
{
ErrorMessage = "Unable to load memberships. " + ex.Message;
}
finally
{
IsLoading = false;
}
}
private async Task LoadSummaryAsync()
{
var summary = await Http.GetFromJsonAsync<MembershipSummaryDto>(
"api/Subscription/MyMembershipSummary");
if (summary is null)
{
return;
}
ActiveCount = summary.ActiveCount;
ExpiringCount = summary.ExpiringCount;
ExpiredCount = summary.ExpiredCount;
CancelledCount = summary.CancelledCount;
}
private async Task SearchAsync()
{
PageNumber = 1;
await LoadPageAsync();
}
private async Task ClearSearchAsync()
{
SearchModel = new SearchModelType();
PageNumber = 1;
await LoadPageAsync();
}
private async Task PreviousPageAsync()
{
if (PageNumber > 1)
{
PageNumber--;
await LoadPageAsync();
}
}
private async Task NextPageAsync()
{
if (PageNumber < TotalPages)
{
PageNumber++;
await LoadPageAsync();
}
}
private async Task ChangePageSizeAsync(ChangeEventArgs args)
{
if (int.TryParse(args.Value?.ToString(), out var size) &&
size is 6 or 9 or 12)
{
PageSize = size;
PageNumber = 1;
await LoadPageAsync();
}
}
private static string GetDisplayStatus(MembershipResponseDto membership)
{
if (string.Equals(
membership.Status, "Cancelled",
StringComparison.OrdinalIgnoreCase))
{
return "Cancelled";
}
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (membership.EndDate < today ||
string.Equals(
membership.Status, "Expired",
StringComparison.OrdinalIgnoreCase))
{
return "Expired";
}
var daysRemaining = membership.EndDate.DayNumber - today.DayNumber;
if (string.Equals(
membership.Status, "Active",
StringComparison.OrdinalIgnoreCase) &&
daysRemaining is >= 0 and <= 7)
{
return "Expiring";
}
return membership.Status;
}
private void BrowseGyms()
{
Navigation.NavigateTo("/browse-gyms");
}
private void RenewMembership(Guid membershipId)
{
var membership = Memberships.FirstOrDefault(m => m.Id == membershipId);
if (membership is not null)
{
Navigation.NavigateTo(
$"/subscribe?gymId={membership.GymId}" +
$"&planId={membership.MembershipPlanId}");
}
}
private void ViewAttendance(Guid membershipId)
{
Navigation.NavigateTo($"/my-attendance?membershipId={membershipId}");
}
private async Task CancelMembershipAsync(Guid membershipId)
{
var membership = Memberships.FirstOrDefault(m => m.Id == membershipId);
if (membership is null)
{
return;
}
var confirmation = await Swal.FireAsync(new SweetAlertOptions
{
Title = "Cancel membership?",
Text = $"Are you sure you want to cancel {membership.PlanName}?",
Icon = SweetAlertIcon.Warning,
ShowCancelButton = true,
ConfirmButtonText = "Yes, cancel",
CancelButtonText = "Keep membership",
ConfirmButtonColor = "#dc3545"
});
if (!confirmation.IsConfirmed)
{
return;
}
IsActionRunning = true;
try
{
var response = await Http.PostAsync(
$"api/Subscription/{membershipId}/Cancel",
content: null);
var result = await response.Content
.ReadFromJsonAsync<SubscriptionCommandResponse>();
if (response.IsSuccessStatusCode && result?.Success == true)
{
await Swal.FireAsync(
"Cancelled",
result.Message,
SweetAlertIcon.Success);
await LoadPageAsync();
}
else
{
await Swal.FireAsync(
"Unable to cancel",
result?.Message ?? "The membership could not be cancelled.",
SweetAlertIcon.Error);
}
}
catch
{
await Swal.FireAsync(
"Error",
"An error occurred while cancelling the membership.",
SweetAlertIcon.Error);
}
finally
{
IsActionRunning = false;
}
}
private sealed class SearchModelType
{
public string SearchTerm { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string SortBy { get; set; } = "EndDate";
public string SortDirection { get; set; } = "Ascending";
}
private sealed class MembershipResponseDto
{
public Guid Id { get; set; }
public Guid GymId { get; set; }
public string GymName { get; set; } = string.Empty;
public Guid MembershipPlanId { get; set; }
public string PlanName { get; set; } = string.Empty;
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public decimal PricePaid { get; set; }
public string Status { get; set; } = string.Empty;
}
private sealed class MembershipSummaryDto
{
public int ActiveCount { get; set; }
public int ExpiringCount { get; set; }
public int ExpiredCount { get; set; }
public int CancelledCount { get; set; }
}
private sealed class PagedResponse<T>
{
public List<T> Items { get; set; } = new();
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages { get; set; }
}
private sealed class SubscriptionCommandResponse
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
}
}
Pages/MyAttendance.razor
Attendance page: reads an optional membershipId from the query string,
requests attendance records, and manages page navigation and page-size
changes locally.
@page "/my-attendance"
@using System.Net.Http.Json
@using System.Globalization
@inject HttpClient Http
@inject NavigationManager Navigation
<PageTitle>My Attendance</PageTitle>
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h2 class="fw-bold mb-1">
<i class="bi bi-calendar-check me-2"></i>
My Attendance
</h2>
<p class="text-secondary mb-0">
Review your gym check-in and check-out history.
</p>
</div>
<button class="btn btn-outline-secondary" @onclick="BackToMemberships">
<i class="bi bi-arrow-left me-1"></i>
My Memberships
</button>
</div>
@if (MembershipId.HasValue)
{
<div class="alert alert-info d-flex justify-content-between align-items-center">
<span>Showing attendance for the selected membership.</span>
<button class="btn btn-sm btn-outline-primary"
@onclick="ShowAllAttendanceAsync">
Show all
</button>
</div>
}
@if (IsLoading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="text-secondary mt-3">Loading attendance...</p>
</div>
}
else if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<div class="alert alert-danger d-flex justify-content-between align-items-center">
<span>@ErrorMessage</span>
<button class="btn btn-outline-danger btn-sm" @onclick="LoadAttendanceAsync">
Retry
</button>
</div>
}
else if (AttendanceRecords.Count == 0)
{
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5">
<i class="bi bi-calendar-x display-4 text-secondary"></i>
<h5 class="mt-3">No attendance records found</h5>
<p class="text-secondary mb-0">
Your attendance records will appear here when available.
</p>
</div>
</div>
}
else
{
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th class="px-3">Date</th>
<th>Gym</th>
<th>Membership Plan</th>
<th>Check-in</th>
<th>Check-out</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var record in AttendanceRecords)
{
<tr @key="record.Id">
<td class="px-3 text-nowrap">
@record.CheckInTime.ToLocalTime().ToString("dd MMM yyyy")
</td>
<td>@record.GymName</td>
<td>@record.PlanName</td>
<td class="text-nowrap">
@record.CheckInTime.ToLocalTime().ToString("hh:mm tt")
</td>
<td class="text-nowrap">
@if (record.CheckOutTime.HasValue)
{
@record.CheckOutTime.Value.ToLocalTime().ToString("hh:mm tt")
}
else
{
<span class="text-secondary">Not checked out</span>
}
</td>
<td>
<span class="@GetAttendanceBadgeClass(record.Status)">
@record.Status
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@* Pagination stays inside this page. *@
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mt-4">
<span class="text-secondary small">
Showing @FirstItem–@LastItem of @TotalCount records
</span>
<div class="d-flex flex-wrap align-items-center gap-2">
<label for="attendancePageSize" class="small text-secondary">
Per page
</label>
<select id="attendancePageSize"
class="form-select form-select-sm"
style="width: 85px"
value="@PageSize"
@onchange="ChangePageSizeAsync"
disabled="@IsLoading">
<option value="6">6</option>
<option value="9">9</option>
<option value="12">12</option>
</select>
<button class="btn btn-outline-secondary btn-sm"
@onclick="PreviousPageAsync"
disabled="@(IsLoading || PageNumber <= 1)">
<i class="bi bi-chevron-left"></i>
Previous
</button>
<span class="small text-nowrap">
Page @PageNumber of @TotalPages
</span>
<button class="btn btn-outline-secondary btn-sm"
@onclick="NextPageAsync"
disabled="@(IsLoading || PageNumber >= TotalPages)">
Next
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
}
</div>
@code {
[SupplyParameterFromQuery(Name = "membershipId")]
public Guid? MembershipId { get; set; }
private List<AttendanceItem> AttendanceRecords = new();
private bool IsLoading;
private string ErrorMessage = string.Empty;
private int PageNumber = 1;
private int PageSize = 6;
private int TotalCount;
private int TotalPages = 1;
private int FirstItem =>
TotalCount == 0 ? 0 : ((PageNumber - 1) * PageSize) + 1;
private int LastItem =>
Math.Min(PageNumber * PageSize, TotalCount);
protected override async Task OnParametersSetAsync()
{
PageNumber = 1;
await LoadAttendanceAsync();
}
private async Task LoadAttendanceAsync()
{
IsLoading = true;
ErrorMessage = string.Empty;
try
{
var url =
$"api/Attendance/MyAttendance" +
$"?PageNumber={PageNumber}" +
$"&PageSize={PageSize}";
if (MembershipId.HasValue)
{
url += $"&MembershipId={MembershipId.Value}";
}
var response =
await Http.GetFromJsonAsync<PagedResponse<AttendanceItem>>(url);
if (response is null)
{
throw new InvalidOperationException(
"The server returned an empty attendance response.");
}
AttendanceRecords = response.Items ?? new List<AttendanceItem>();
PageNumber = response.PageNumber;
PageSize = response.PageSize;
TotalCount = response.TotalCount;
TotalPages = Math.Max(1, response.TotalPages);
}
catch (Exception ex)
{
ErrorMessage = "Unable to load attendance. " + ex.Message;
}
finally
{
IsLoading = false;
}
}
private async Task PreviousPageAsync()
{
if (PageNumber > 1)
{
PageNumber--;
await LoadAttendanceAsync();
}
}
private async Task NextPageAsync()
{
if (PageNumber < TotalPages)
{
PageNumber++;
await LoadAttendanceAsync();
}
}
private async Task ChangePageSizeAsync(ChangeEventArgs args)
{
if (int.TryParse(args.Value?.ToString(), out var size) &&
size is 6 or 9 or 12)
{
PageSize = size;
PageNumber = 1;
await LoadAttendanceAsync();
}
}
private async Task ShowAllAttendanceAsync()
{
Navigation.NavigateTo("/my-attendance");
MembershipId = null;
PageNumber = 1;
await LoadAttendanceAsync();
}
private void BackToMemberships()
{
Navigation.NavigateTo("/my-memberships");
}
private static string GetAttendanceBadgeClass(string status)
{
return status.ToLowerInvariant() switch
{
"present" or "completed" or "checked out" =>
"badge text-bg-success",
"checked in" =>
"badge text-bg-primary",
"missed" or "absent" =>
"badge text-bg-danger",
"cancelled" =>
"badge text-bg-secondary",
_ =>
"badge text-bg-light"
};
}
// Adjust these properties to match your actual Attendance entity/API DTO.
private sealed class AttendanceItem
{
public Guid Id { get; set; }
public string GymName { get; set; } = string.Empty;
public string PlanName { get; set; } = string.Empty;
public DateTime CheckInTime { get; set; }
public DateTime? CheckOutTime { get; set; }
public string Status { get; set; } = string.Empty;
}
private sealed class PagedResponse<T>
{
public List<T> Items { get; set; } = new();
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages { get; set; }
}
}
Components/Membership/MembershipSummaryCards.razor
Reusable summary component: accepts four count parameters and displays
them as responsive cards.
<div class="row g-3 mb-4">
<div class="col-12 col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<p class="text-secondary mb-1">Active</p>
<h3 class="fw-bold mb-0">@ActiveCount</h3>
</div>
<div class="summary-icon bg-success-subtle text-success">
<i class="bi bi-check-circle fs-3"></i>
</div>
</div>
</div>
</div>
<div class="col-12 col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<p class="text-secondary mb-1">Expiring Soon</p>
<h3 class="fw-bold mb-0">@ExpiringCount</h3>
</div>
<div class="summary-icon bg-warning-subtle text-warning">
<i class="bi bi-hourglass-split fs-3"></i>
</div>
</div>
</div>
</div>
<div class="col-12 col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<p class="text-secondary mb-1">Expired</p>
<h3 class="fw-bold mb-0">@ExpiredCount</h3>
</div>
<div class="summary-icon bg-danger-subtle text-danger">
<i class="bi bi-calendar-x fs-3"></i>
</div>
</div>
</div>
</div>
<div class="col-12 col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<p class="text-secondary mb-1">Cancelled</p>
<h3 class="fw-bold mb-0">@CancelledCount</h3>
</div>
<div class="summary-icon bg-secondary-subtle text-secondary">
<i class="bi bi-x-circle fs-3"></i>
</div>
</div>
</div>
</div>
</div>
<style>
.summary-icon {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@code {
[Parameter] public int ActiveCount { get; set; }
[Parameter] public int ExpiringCount { get; set; }
[Parameter] public int ExpiredCount { get; set; }
[Parameter] public int CancelledCount { get; set; }
}
Components/Membership/MembershipCard.razor
Reusable single-record component: displays dates, price, status, and
action buttons. It reports button actions using EventCallback<Guid>
rather than making API calls itself.
<div class="col-12 col-md-6 col-xl-4">
<div class="card membership-card border-0 shadow-sm h-100">
<div class="card-body p-4 d-flex flex-column">
<div class="d-flex justify-content-between align-items-start gap-2 mb-3">
<div>
<h5 class="fw-bold mb-1">@PlanName</h5>
<p class="text-secondary mb-0">
<i class="bi bi-building me-1"></i>
@GymName
</p>
</div>
<span class="@GetBadgeClass(Status)">@Status</span>
</div>
<hr />
<div class="row g-3 mb-3">
<div class="col-6">
<small class="text-secondary d-block">Start Date</small>
<span class="fw-semibold">@StartDate.ToString("dd MMM yyyy")</span>
</div>
<div class="col-6">
<small class="text-secondary d-block">Expiry Date</small>
<span class="fw-semibold">@EndDate.ToString("dd MMM yyyy")</span>
</div>
<div class="col-6">
<small class="text-secondary d-block">Amount Paid</small>
<span class="fw-bold text-primary">
@PricePaid.ToString("C", CultureInfo.GetCultureInfo("en-IN"))
</span>
</div>
<div class="col-6">
<small class="text-secondary d-block">Membership ID</small>
<span class="small text-break">@MembershipId.ToString("N")[..8]</span>
</div>
</div>
@if (Status == "Expiring")
{
var daysLeft = EndDate.DayNumber -
DateOnly.FromDateTime(DateTime.UtcNow).DayNumber;
<div class="alert alert-warning py-2 small">
<i class="bi bi-clock me-1"></i>
@if (daysLeft <= 0)
{
<span>Membership expires today.</span>
}
else
{
<span>Membership expires in @daysLeft day(s).</span>
}
</div>
}
<div class="d-flex flex-wrap gap-2 mt-auto pt-2">
@if (Status != "Cancelled")
{
<button class="btn btn-primary flex-grow-1"
@onclick="OnRenewClicked"
disabled="@ActionDisabled">
<i class="bi bi-arrow-repeat me-1"></i>
Renew
</button>
}
@if (Status == "Active" || Status == "Expiring")
{
<button class="btn btn-outline-danger flex-grow-1"
@onclick="OnCancelClicked"
disabled="@ActionDisabled">
<i class="bi bi-x-circle me-1"></i>
Cancel
</button>
}
<button class="btn btn-outline-secondary"
@onclick="OnAttendanceClicked">
<i class="bi bi-calendar-check me-1"></i>
Attendance
</button>
</div>
</div>
</div>
</div>
<style>
.membership-card {
border-radius: 16px;
transition: transform .2s ease, box-shadow .2s ease;
}
.membership-card:hover {
transform: translateY(-3px);
box-shadow: 0 .75rem 1.5rem rgba(0, 0, 0, .10) !important;
}
</style>
@code {
[Parameter] public Guid MembershipId { get; set; }
[Parameter] public Guid GymId { get; set; }
[Parameter] public Guid MembershipPlanId { get; set; }
[Parameter] public string GymName { get; set; } = string.Empty;
[Parameter] public string PlanName { get; set; } = string.Empty;
[Parameter] public string Status { get; set; } = string.Empty;
[Parameter] public DateOnly StartDate { get; set; }
[Parameter] public DateOnly EndDate { get; set; }
[Parameter] public decimal PricePaid { get; set; }
[Parameter] public bool ActionDisabled { get; set; }
[Parameter] public EventCallback<Guid> OnRenew { get; set; }
[Parameter] public EventCallback<Guid> OnCancel { get; set; }
[Parameter] public EventCallback<Guid> OnAttendance { get; set; }
private string GetBadgeClass(string status)
{
return status switch
{
"Active" => "badge rounded-pill text-bg-success",
"Expiring" => "badge rounded-pill text-bg-warning",
"Expired" => "badge rounded-pill text-bg-danger",
"Cancelled" => "badge rounded-pill text-bg-secondary",
_ => "badge rounded-pill text-bg-light"
};
}
private Task OnRenewClicked() =>
OnRenew.InvokeAsync(MembershipId);
private Task OnCancelClicked() =>
OnCancel.InvokeAsync(MembershipId);
private Task OnAttendanceClicked() =>
OnAttendance.InvokeAsync(MembershipId);
}
Components/Membership/MembershipCardList.razor
List component: loops over the items and renders MembershipCard. It
forwards the action callbacks to the parent page and declares the list
item class in its own @code block.
<div class="row g-4">
@foreach (var membership in Items)
{
<MembershipCard @key="membership.Id"
MembershipId="@membership.Id"
GymId="@membership.GymId"
MembershipPlanId="@membership.MembershipPlanId"
GymName="@membership.GymName"
PlanName="@membership.PlanName"
StartDate="@membership.StartDate"
EndDate="@membership.EndDate"
PricePaid="@membership.PricePaid"
Status="@membership.DisplayStatus"
ActionDisabled="@ActionDisabled"
OnRenew="@OnRenew"
OnCancel="@OnCancel"
OnAttendance="@OnAttendance" />
}
</div>
@code {
[Parameter]
public List<MembershipItem> Items { get; set; } = new();
[Parameter]
public bool ActionDisabled { get; set; }
[Parameter]
public EventCallback<Guid> OnRenew { get; set; }
[Parameter]
public EventCallback<Guid> OnCancel { get; set; }
[Parameter]
public EventCallback<Guid> OnAttendance { get; set; }
public sealed class MembershipItem
{
public Guid Id { get; set; }
public Guid GymId { get; set; }
public string GymName { get; set; } = string.Empty;
public Guid MembershipPlanId { get; set; }
public string PlanName { get; set; } = string.Empty;
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public decimal PricePaid { get; set; }
public string Status { get; set; } = string.Empty;
// Used for displaying Expiring when an active membership
// has seven or fewer days remaining.
public string DisplayStatus { get; set; } = string.Empty;
}
}
Setup and verification checklist
- Confirm the namespace in
@using GymManagement.Client.Components.Membershipmatches the actual client project namespace. - Ensure Bootstrap CSS and Bootstrap Icons are loaded by the application.
- Ensure the SweetAlert2 package and its service registration are
configured, because the main page injects
SweetAlertService. - Ensure
HttpClientis configured with the correct backend base address and authentication handler, if the API requires JWT authentication. - Confirm that
DateOnlyvalues serialize and deserialize in the format expected by the backend. - Confirm the backend supports the query parameters
SearchTerm,Status,SortBy,SortDirection,PageNumber, andPageSize. - Confirm the summary endpoint returns
ActiveCount,ExpiringCount,ExpiredCount, andCancelledCount. - Confirm cancellation returns a JSON object with
SuccessandMessage. - Confirm attendance returns
Items,PageNumber,PageSize,TotalCount, andTotalPages, and each item contains the properties declared inAttendanceItem. - Test empty results, failed API requests, page-size changes, next/previous navigation, status filtering, sorting in both directions, cancellation confirmation, and navigation to the attendance page.
A note about sorting and pagination
The frontend passes sorting and pagination choices to the backend. For
the results to be correct across the complete dataset, the backend
should apply search and status filtering first, then apply an
allowlisted sort field and direction, then calculate the total count,
and finally apply Skip/Take (or equivalent pagination). Sorting only
the records already loaded on the current page will not sort the entire
dataset.
Top comments (0)