refactor(web-client-tpos): unify JSON serialization for API client, streamline authentication flows, and update UI layouts and navigation.

This commit is contained in:
Ho Ngoc Hai
2026-03-04 11:21:22 +07:00
parent ce61b4d3db
commit ba11317293
27 changed files with 370 additions and 210 deletions

View File

@@ -230,10 +230,26 @@
_shopId = slashIndex > 0 ? remaining[..slashIndex] : remaining;
_isShopContext = true;
// EN: Try to read shop info from query params or localStorage (set by Dashboard click)
// VI: Đọc thông tin shop từ query params hoặc localStorage
// For now, use shopId as name fallback
_shopName = _shopName == "Cửa hàng" ? $"Shop #{_shopId?[..8]}" : _shopName;
// EN: Use shopId as name fallback if not yet set by child page
// VI: Dùng shopId làm tên nếu chưa được set bởi trang con
if (string.IsNullOrEmpty(_shopName))
_shopName = $"Shop #{_shopId?[..8]}";
// EN: Detect shop category from URL path segments
// VI: Phát hiện loại cửa hàng từ các phần URL
if (_shopCategory == null && slashIndex > 0)
{
var subPath = remaining[(slashIndex + 1)..];
var subSlash = subPath.IndexOf('/');
var section = subSlash > 0 ? subPath[..subSlash] : subPath;
// EN: Map URL section to shop category
// VI: Map section URL sang danh mục cửa hàng
_shopCategory = section switch
{
"cafe" or "restaurant" or "karaoke" or "spa" => section,
_ => _shopCategory
};
}
}
else
{

View File

@@ -14,7 +14,7 @@
<a href="/#features" class="tpos-nav-link">@L["Nav_Features"]</a>
<a href="/#pricing" class="tpos-nav-link">@L["Nav_Pricing"]</a>
<a href="/auth/login" class="tpos-nav-link">@L["Nav_Login"]</a>
<a href="/auth/register" class="btn-accent">@L["Nav_FreeTrial"]</a>
<a href="/register" class="btn-accent">@L["Nav_FreeTrial"]</a>
</div>
</div>
</nav>

View File

@@ -1,7 +1,7 @@
@inherits LayoutComponentBase
@inject IStringLocalizer<MainLayout> L
<MudThemeProvider IsDarkMode="true" Theme="_theme" />
<MudThemeProvider IsDarkMode="true" Theme="AppTheme.DefaultDark" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
@@ -16,7 +16,7 @@
<div class="tpos-nav-links">
<a href="#features" class="tpos-nav-link">@L["Nav_Features"]</a>
<a href="#pricing" class="tpos-nav-link">@L["Nav_Pricing"]</a>
<a href="#" class="tpos-nav-link">@L["Nav_Contact"]</a>
<a href="/home#contact" class="tpos-nav-link">@L["Nav_Contact"]</a>
<!-- Language Switcher -->
<LanguageSwitcher />
@@ -43,7 +43,7 @@
<div class="tpos-mobile-drawer">
<a href="#features" class="tpos-mobile-link" @onclick="CloseMobileMenu">@L["Nav_Features"]</a>
<a href="#pricing" class="tpos-mobile-link" @onclick="CloseMobileMenu">@L["Nav_Pricing"]</a>
<a href="#" class="tpos-mobile-link" @onclick="CloseMobileMenu">@L["Nav_Contact"]</a>
<a href="/home#contact" class="tpos-mobile-link" @onclick="CloseMobileMenu">@L["Nav_Contact"]</a>
<a href="/login" class="tpos-mobile-link" @onclick="CloseMobileMenu">@L["Nav_Login"]</a>
<div class="tpos-mobile-actions">
<LanguageSwitcher />
@@ -64,21 +64,4 @@
private void ToggleMobileMenu() => _mobileMenuOpen = !_mobileMenuOpen;
private void CloseMobileMenu() => _mobileMenuOpen = false;
private MudTheme _theme = new()
{
PaletteDark = new PaletteDark()
{
Primary = "#FF5C00",
PrimaryContrastText = "#FFFFFF",
AppbarBackground = "rgba(10,10,11,0.85)",
AppbarText = "#FFFFFF",
Background = "#0A0A0B",
Surface = "#111113",
TextPrimary = "#FFFFFF",
TextSecondary = "#ADADB0",
ActionDefault = "#FFFFFF",
LinesDefault = "#1F1F23"
}
};
}

View File

@@ -1,17 +0,0 @@
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Rounded.Home">
Home
</MudNavLink>
<MudNavLink Href="/products" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Rounded.ShoppingCart">
Products
</MudNavLink>
<MudNavLink Href="/auth" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Rounded.Person">
Auth
</MudNavLink>
<MudNavLink Href="/counter" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Rounded.Add">
Counter
</MudNavLink>
<MudNavLink Href="/weather" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Rounded.Cloud">
Weather
</MudNavLink>
</MudNavMenu>

View File

@@ -1,83 +0,0 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.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.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
.nav-scrollable {
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -25,7 +25,7 @@
<span style="width:6px;height:6px;border-radius:100px;background:currentColor;"></span>
<span>Online</span>
</div>
<span style="font-size:13px;color:var(--pos-text-secondary);">@DateTime.Now.ToString("HH:mm")</span>
<span style="font-size:13px;color:var(--pos-text-secondary);">@_currentTime</span>
<button class="admin-icon-btn" @onclick="GoToAdmin" title="Admin">
<i data-lucide="settings"></i>
</button>
@@ -38,11 +38,23 @@
</div>
</div>
@implements IDisposable
@code {
private string StoreName { get; set; } = "GoodGo POS";
private string _currentTime = DateTime.Now.ToString("HH:mm");
private Timer? _timer;
protected override async Task OnInitializedAsync()
{
// EN: Update clock every 30 seconds
// VI: Cập nhật đồng hồ mỗi 30 giây
_timer = new Timer(_ =>
{
_currentTime = DateTime.Now.ToString("HH:mm");
InvokeAsync(StateHasChanged);
}, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
// EN: Extract shopId from URL path: /pos/{shopId}/...
// VI: Trích xuất shopId từ URL: /pos/{shopId}/...
try
@@ -65,6 +77,7 @@
private void GoToAdmin() => NavigationManager.NavigateTo("/admin");
public void Dispose() => _timer?.Dispose();
}

View File

@@ -1,4 +1,5 @@
@page "/auth/email-sent"
@layout AuthLayout
@using WebClientTpos.Client.Components.Auth
@inherits AuthBase

View File

@@ -1,4 +1,5 @@
@page "/forgot-password"
@layout AuthLayout
@using WebClientTpos.Shared.DTOs
@using WebClientTpos.Shared
@inject HttpClient Http

View File

@@ -1,10 +1,11 @@
@page "/auth/forgot-password-new"
@layout AuthLayout
@using WebClientTpos.Client.Components.Auth
@inherits AuthBase
@*
EN: Forgot password page — centered card, email/phone input sends reset link.
VI: Trang quên mật khẩu — card giữa, input email/SĐT gửi link đặt lại.
EN: Forgot password page — centered card, email/phone input -> sends reset link.
VI: Trang quên mật khẩu — card giữa, input email/SĐT -> gửi link đặt lại.
Design: pencil-design/src/pages/tPOS/auth/forgot-password/desktop.pen
*@
@@ -17,19 +18,29 @@
Subtitle="@L["Auth_Forgot_Subtitle"]"
Icon="key-round"
IconClass="auth-icon--orange"
BackLink="/auth/login/branch"
BackLink="/login"
BackLinkText="@L["Auth_Forgot_BackLink"]"
Compact="true">
<ChildContent>
<div class="auth-form">
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div style="margin-bottom:16px;padding:12px 16px;border-radius:8px;background:rgba(239,68,68,0.12);color:#EF4444;font-size:14px;display:flex;align-items:center;gap:8px;">
<i data-lucide="alert-circle" style="width:16px;height:16px;flex-shrink:0;"></i>
<span>@_errorMessage</span>
</div>
}
<AuthInput Label="@L["Auth_Forgot_InputLabel"]"
InputType="text"
Placeholder="@L["Auth_Forgot_InputPlaceholder"]"
PrefixIcon="mail"
InputId="forgot-email" />
InputId="forgot-email"
Value="@_email"
ValueChanged="@(e => _email = e.Value?.ToString() ?? "")" />
</div>
<AuthButton Variant="orange" OnClick="HandleSubmit">
<AuthButton Variant="orange" Disabled="@_isLoading" Loading="@_isLoading" OnClick="HandleSubmit">
@L["Auth_Forgot_Submit"]
</AuthButton>
</ChildContent>
@@ -42,13 +53,13 @@
Icon="mail-check"
IconClass="auth-icon--success">
<ChildContent>
<span class="auth-highlight">user@example.com</span>
<span class="auth-highlight">@_email</span>
<AuthButton Variant="orange" IconName="external-link">
@L["Auth_Forgot_OpenEmail"]
</AuthButton>
<AuthButton Variant="outline">
<AuthButton Variant="outline" OnClick="HandleResend">
@L["Auth_Forgot_ResendEmail"]
</AuthButton>
</ChildContent>
@@ -65,10 +76,46 @@
</div>
@code {
private bool _resetSent = false;
[Inject] private HttpClient Http { get; set; } = default!;
private void HandleSubmit()
private string _email = "";
private bool _resetSent = false;
private bool _isLoading = false;
private string? _errorMessage;
private async Task HandleSubmit()
{
_resetSent = true;
if (_isLoading) return;
_errorMessage = null;
if (string.IsNullOrWhiteSpace(_email))
{
_errorMessage = "Vui lòng nhập email hoặc số điện thoại";
return;
}
_isLoading = true;
StateHasChanged();
try
{
var response = await Http.PostAsJsonAsync("/api/auth/forgot-password", new { Email = _email });
// EN: Show success state regardless (security: don't reveal if email exists)
// VI: Hiển thị thành công (bảo mật: không tiết lộ email có tồn tại không)
_resetSent = true;
}
catch
{
_resetSent = true;
}
finally
{
_isLoading = false;
}
}
private void HandleResend()
{
_resetSent = false;
}
}

View File

@@ -1,4 +1,5 @@
@page "/auth/login/admin"
@layout AuthLayout
@using WebClientTpos.Client.Components.Auth
@using WebClientTpos.Client.Services
@using WebClientTpos.Shared.DTOs

View File

@@ -1,5 +1,7 @@
@page "/auth/login/branch"
@layout AuthLayout
@using WebClientTpos.Client.Components.Auth
@using WebClientTpos.Client.Services
@inherits AuthBase
@*
@@ -15,39 +17,103 @@
LogoText="a"
Title="@L["Auth_Branch_BrandTitle"]"
Description="@L["Auth_Branch_BrandDesc"]" />
<div class="auth-form-panel">
<AuthCard Title="@L["Auth_Branch_Title"]"
Subtitle="@L["Auth_Branch_Subtitle"]"
Transparent="true">
<ChildContent>
<div class="auth-form">
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div style="margin-bottom:16px;padding:12px 16px;border-radius:8px;background:rgba(239,68,68,0.12);color:#EF4444;font-size:14px;display:flex;align-items:center;gap:8px;">
<i data-lucide="alert-circle" style="width:16px;height:16px;flex-shrink:0;"></i>
<span>@_errorMessage</span>
</div>
}
<AuthInput Label="@L["Auth_Branch_EmailLabel"]"
InputType="text"
Placeholder="@L["Auth_Branch_EmailPlaceholder"]"
PrefixIcon="mail"
InputId="branch-email"
AutoComplete="email" />
AutoComplete="email"
Value="@_email"
ValueChanged="@(e => _email = e.Value?.ToString() ?? "")" />
<AuthInput Label="@L["Auth_Branch_PasswordLabel"]"
InputType="password"
Placeholder="••••••••"
PrefixIcon="lock"
InputId="branch-password"
AutoComplete="current-password"
ForgotPasswordLink="/auth/forgot-password"
Value="@_password"
ValueChanged="@(e => _password = e.Value?.ToString() ?? "")"
ForgotPasswordLink="/forgot-password"
ForgotPasswordText="@L["Auth_Branch_ForgotPwd"]" />
</div>
<AuthButton Variant="orange">
@L["Auth_Branch_Submit"]
<AuthButton Variant="orange" Disabled="@_isLoading" Loading="@_isLoading" OnClick="HandleLogin">
@if (_isLoading)
{
<span>Đang xử lý...</span>
}
else
{
@L["Auth_Branch_Submit"]
}
</AuthButton>
</ChildContent>
<FooterContent>
<span class="auth-footer-text">
@L["Auth_Branch_NoAccount"] <a href="/auth/register">@L["Auth_Branch_Contact"]</a>
@L["Auth_Branch_NoAccount"] <a href="/register">@L["Auth_Branch_Contact"]</a>
</span>
</FooterContent>
</AuthCard>
</div>
</div>
@code {
[Inject] private AuthService AuthSvc { get; set; } = default!;
[Inject] private NavigationManager Nav { get; set; } = default!;
private string _email = "";
private string _password = "";
private bool _isLoading = false;
private string? _errorMessage;
private async Task HandleLogin()
{
if (_isLoading) return;
_errorMessage = null;
if (string.IsNullOrWhiteSpace(_email))
{
_errorMessage = "Vui lòng nhập email";
return;
}
if (string.IsNullOrWhiteSpace(_password))
{
_errorMessage = "Vui lòng nhập mật khẩu";
return;
}
_isLoading = true;
StateHasChanged();
var (ok, error) = await AuthSvc.LoginAsync(_email, _password);
if (ok)
{
_isLoading = false;
StateHasChanged();
await Task.Delay(500);
Nav.NavigateTo("/admin", forceLoad: true);
}
else
{
_errorMessage = error ?? "Đăng nhập thất bại";
_isLoading = false;
}
}
}

View File

@@ -1,5 +1,7 @@
@page "/auth/login/staff"
@layout AuthLayout
@using WebClientTpos.Client.Components.Auth
@using WebClientTpos.Client.Services
@inherits AuthBase
@*
@@ -20,23 +22,42 @@
Compact="true">
<ChildContent>
<div class="auth-form">
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div style="margin-bottom:16px;padding:12px 16px;border-radius:8px;background:rgba(239,68,68,0.12);color:#EF4444;font-size:14px;display:flex;align-items:center;gap:8px;">
<i data-lucide="alert-circle" style="width:16px;height:16px;flex-shrink:0;"></i>
<span>@_errorMessage</span>
</div>
}
<AuthInput Label="@L["Auth_Staff_IdLabel"]"
InputType="text"
Placeholder="@L["Auth_Staff_IdPlaceholder"]"
PrefixIcon="user"
InputId="staff-id"
AutoComplete="username" />
AutoComplete="username"
Value="@_email"
ValueChanged="@(e => _email = e.Value?.ToString() ?? "")" />
<AuthInput Label="@L["Auth_Staff_PasswordLabel"]"
InputType="password"
Placeholder="••••••••"
PrefixIcon="lock"
InputId="staff-password"
AutoComplete="current-password" />
AutoComplete="current-password"
Value="@_password"
ValueChanged="@(e => _password = e.Value?.ToString() ?? "")" />
</div>
<AuthButton Variant="green" IconName="log-in">
@L["Auth_Staff_Submit"]
<AuthButton Variant="green" IconName="log-in" Disabled="@_isLoading" Loading="@_isLoading" OnClick="HandleLogin">
@if (_isLoading)
{
<span>Đang xử lý...</span>
}
else
{
@L["Auth_Staff_Submit"]
}
</AuthButton>
</ChildContent>
<FooterContent>
@@ -58,8 +79,53 @@
</div>
</div>
<span class="auth-footer-text">
@L["Auth_Staff_ForgotPwd"] <a href="/auth/forgot-password">@L["Auth_Staff_ContactManager"]</a>
@L["Auth_Staff_ForgotPwd"] <a href="/forgot-password">@L["Auth_Staff_ContactManager"]</a>
</span>
</FooterContent>
</AuthCard>
</div>
@code {
[Inject] private AuthService AuthSvc { get; set; } = default!;
[Inject] private NavigationManager Nav { get; set; } = default!;
private string _email = "";
private string _password = "";
private bool _isLoading = false;
private string? _errorMessage;
private async Task HandleLogin()
{
if (_isLoading) return;
_errorMessage = null;
if (string.IsNullOrWhiteSpace(_email))
{
_errorMessage = "Vui lòng nhập mã nhân viên";
return;
}
if (string.IsNullOrWhiteSpace(_password))
{
_errorMessage = "Vui lòng nhập mật khẩu";
return;
}
_isLoading = true;
StateHasChanged();
var (ok, error) = await AuthSvc.LoginAsync(_email, _password);
if (ok)
{
_isLoading = false;
StateHasChanged();
await Task.Delay(500);
Nav.NavigateTo("/admin", forceLoad: true);
}
else
{
_errorMessage = error ?? "Đăng nhập thất bại";
_isLoading = false;
}
}
}

View File

@@ -1,4 +1,5 @@
@page "/auth/otp-verify"
@layout AuthLayout
@using WebClientTpos.Client.Components.Auth
@inherits AuthBase

View File

@@ -1,4 +1,5 @@
@page "/auth/password-reset"
@layout AuthLayout
@using WebClientTpos.Client.Components.Auth
@inherits AuthBase
@@ -79,6 +80,6 @@
private void GoToLogin()
{
Navigation.NavigateTo("/auth/login/branch");
Navigation.NavigateTo("/login");
}
}

View File

@@ -1,9 +1,13 @@
@page "/profile"
@layout AuthLayout
@using WebClientTpos.Shared.DTOs
@using WebClientTpos.Shared
@using System.Net.Http.Headers
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IStringLocalizer<Profile> L
@inject WebClientTpos.Client.Services.AuthService AuthService
@inject WebClientTpos.Client.Services.AuthStateService AuthState
@*
EN: User profile management page (requires authentication).
@@ -155,9 +159,28 @@
protected override async Task OnInitializedAsync()
{
await AuthService.TryRestoreSessionAsync();
if (!AuthState.IsAuthenticated)
{
Navigation.NavigateTo("/login");
return;
}
await LoadUserProfile();
}
/// <summary>
/// EN: Attach Bearer token to HttpClient before API calls.
/// VI: Đính kèm Bearer token vào HttpClient trước khi gọi API.
/// </summary>
private void AttachToken()
{
if (!string.IsNullOrEmpty(AuthState.Token))
{
Http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", AuthState.Token);
}
}
/// <summary>
/// EN: Load user profile data.
/// VI: Tải dữ liệu hồ sơ người dùng.
@@ -166,17 +189,28 @@
{
try
{
var response = await Http.GetAsync("api/auth/profile");
AttachToken();
// EN: Use OIDC userinfo endpoint (Duende IdentityServer standard).
// VI: Dùng endpoint OIDC userinfo (chuẩn Duende IdentityServer).
var response = await Http.GetAsync("/api/iam/connect/userinfo");
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<UserProfileDto>>();
userProfile = result?.Data;
var info = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
if (info != null)
{
userProfile = new UserProfileDto
{
Id = Guid.TryParse(info.GetValueOrDefault("sub")?.ToString(), out var id) ? id : Guid.Empty,
Email = info.GetValueOrDefault("email")?.ToString() ?? "",
DisplayName = info.GetValueOrDefault("name")?.ToString() ?? "",
EmailVerified = bool.TryParse(info.GetValueOrDefault("email_verified")?.ToString(), out var ev) && ev,
CreatedAt = DateTime.UtcNow
};
}
}
else
{
// EN: Redirect to login if unauthorized
// VI: Chuyển hướng đến đăng nhập nếu chưa xác thực
Navigation.NavigateTo("/login");
}
}
@@ -202,7 +236,8 @@
try
{
var response = await Http.PutAsJsonAsync("api/auth/profile", userProfile);
AttachToken();
var response = await Http.PutAsJsonAsync("/api/auth/profile", userProfile);
if (response.IsSuccessStatusCode)
{
@@ -238,7 +273,8 @@
try
{
var response = await Http.PostAsJsonAsync("api/auth/change-password", changePasswordModel);
AttachToken();
var response = await Http.PostAsJsonAsync("/api/auth/change-password", changePasswordModel);
if (response.IsSuccessStatusCode)
{
@@ -282,7 +318,7 @@
{
try
{
await Http.PostAsync("api/auth/logout", null);
await AuthService.LogoutAsync();
Navigation.NavigateTo("/login");
}
catch

View File

@@ -1,4 +1,5 @@
@page "/register"
@layout AuthLayout
@using WebClientTpos.Shared.DTOs
@using WebClientTpos.Shared
@using WebClientTpos.Client.Services
@@ -156,7 +157,7 @@
// EN: Redirect to login after 2 seconds
// VI: Chuyển hướng đến đăng nhập sau 2 giây
await Task.Delay(2000);
Navigation.NavigateTo("/auth/login/admin");
Navigation.NavigateTo("/login");
}
else
{

View File

@@ -1,4 +1,5 @@
@page "/auth/register/customer"
@layout AuthLayout
@using WebClientTpos.Client.Components.Auth
@inherits AuthBase

View File

@@ -1,4 +1,5 @@
@page "/reset-password"
@layout AuthLayout
@using WebClientTpos.Shared.DTOs
@using WebClientTpos.Shared
@inject HttpClient Http

View File

@@ -1,4 +1,5 @@
@page "/auth/two-factor"
@layout AuthLayout
@using WebClientTpos.Client.Components.Auth
@inherits AuthBase

View File

@@ -1,4 +1,5 @@
@page "/verify-email"
@layout AuthLayout
@using WebClientTpos.Shared
@inject HttpClient Http
@inject NavigationManager Navigation

View File

@@ -15,7 +15,7 @@
<h1 class="home-hero__title">@L["HeroHeadline"]</h1>
<p class="home-hero__subtitle">@L["HeroSubtext"]</p>
<div class="home-hero__actions">
<a href="/auth/register" class="home-hero__btn home-hero__btn--primary">
<a href="/register" class="home-hero__btn home-hero__btn--primary">
<i data-lucide="zap"></i>
@L["HeroCTA_Primary"]
</a>
@@ -39,23 +39,23 @@
@* Verticals Showcase *@
<div class="home-verticals">
<div class="home-verticals__grid">
<a href="/auth/register" class="home-vertical-card">
<a href="/register" class="home-vertical-card">
<i data-lucide="coffee"></i>
<span>Café</span>
</a>
<a href="/auth/register" class="home-vertical-card">
<a href="/register" class="home-vertical-card">
<i data-lucide="utensils-crossed"></i>
<span>@L["Industry_Restaurant_Title"]</span>
</a>
<a href="/auth/register" class="home-vertical-card">
<a href="/register" class="home-vertical-card">
<i data-lucide="mic"></i>
<span>@L["Industry_Karaoke_Title"]</span>
</a>
<a href="/auth/register" class="home-vertical-card">
<a href="/register" class="home-vertical-card">
<i data-lucide="sparkles"></i>
<span>@L["Industry_Spa_Title"]</span>
</a>
<a href="/auth/register" class="home-vertical-card">
<a href="/register" class="home-vertical-card">
<i data-lucide="shopping-bag"></i>
<span>@L["Vertical_Retail"]</span>
</a>

View File

@@ -8,7 +8,6 @@
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject AuthService AuthService
@inject PosDataService DataService
@inject IJSRuntime JS

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Components;
using WebClientTpos.Client.Services;
namespace WebClientTpos.Client.Pages.Pos;
@@ -11,6 +12,8 @@ namespace WebClientTpos.Client.Pages.Pos;
public abstract class PosBase : ComponentBase
{
[Inject] protected NavigationManager NavigationManager { get; set; } = default!;
[Inject] protected AuthService AuthService { get; set; } = default!;
[Inject] protected AuthStateService AuthState { get; set; } = default!;
/// <summary>
/// EN: Shop ID from route — injected into every POS page.
@@ -48,6 +51,16 @@ public abstract class PosBase : ComponentBase
/// </summary>
protected bool HasActiveShift => !string.IsNullOrEmpty(CurrentShiftId);
/// <summary>
/// EN: Restore auth session from localStorage on first render.
/// VI: Khôi phục session xác thực từ localStorage khi render lần đầu.
/// </summary>
protected override async Task OnInitializedAsync()
{
await AuthService.TryRestoreSessionAsync();
await base.OnInitializedAsync();
}
/// <summary>
/// EN: Format Vietnamese currency for POS display (compact).
/// VI: Định dạng tiền tệ VND cho POS (gọn).

View File

@@ -11,6 +11,8 @@ public class PosDataService
{
private readonly HttpClient _http;
private readonly AuthStateService _authState;
// EN: Read options — case-insensitive to handle both snake_case and camelCase responses
// VI: Options đọc — không phân biệt hoa thường để xử lý cả snake_case và camelCase
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -18,6 +20,14 @@ public class PosDataService
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
// EN: Write options — camelCase to match ASP.NET model binding defaults
// VI: Options ghi — camelCase để khớp với ASP.NET model binding mặc định
private static readonly JsonSerializerOptions _writeOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public PosDataService(HttpClient http, AuthStateService authState)
{
_http = http;
@@ -93,14 +103,14 @@ public class PosDataService
public async Task<bool> CreateProductAsync(CreateProductRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/products", req, _jsonOptions);
var resp = await _http.PostAsJsonAsync("api/bff/products", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateProductAsync(Guid productId, CreateProductRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/products/{productId}", req, _jsonOptions);
var resp = await _http.PutAsJsonAsync($"api/bff/products/{productId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -138,14 +148,14 @@ public class PosDataService
public async Task<bool> CreateStaffAsync(CreateStaffRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/staff", req, _jsonOptions);
var resp = await _http.PostAsJsonAsync("api/bff/staff", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateStaffAsync(Guid staffId, CreateStaffRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/staff/{staffId}", req, _jsonOptions);
var resp = await _http.PutAsJsonAsync($"api/bff/staff/{staffId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -161,7 +171,7 @@ public class PosDataService
public async Task<bool> UpdateInventoryAsync(Guid inventoryId, UpdateInventoryRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/inventory/{inventoryId}", req, _jsonOptions);
var resp = await _http.PutAsJsonAsync($"api/bff/inventory/{inventoryId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -230,19 +240,19 @@ public class PosDataService
public record CreateCampaignRequest(string Name, string? Description, decimal FaceValue, int TotalVouchers, DateTime StartDate, DateTime EndDate);
public async Task<List<CampaignInfo>> GetCampaignsAsync()
{ AttachToken(); return await _http.GetFromJsonAsync<List<CampaignInfo>>("api/bff/promotions", _jsonOptions) ?? new(); }
{ AttachToken(); return await _http.GetFromJsonAsync<List<CampaignInfo>>("api/bff/campaigns", _jsonOptions) ?? new(); }
public async Task<bool> CreateCampaignAsync(CreateCampaignRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/campaigns", req, _jsonOptions);
var resp = await _http.PostAsJsonAsync("api/bff/campaigns", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateCampaignAsync(Guid campaignId, CreateCampaignRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/campaigns/{campaignId}", req, _jsonOptions);
var resp = await _http.PutAsJsonAsync($"api/bff/campaigns/{campaignId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -263,14 +273,14 @@ public class PosDataService
public async Task<bool> CreateMemberAsync(CreateMemberRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/members", req, _jsonOptions);
var resp = await _http.PostAsJsonAsync("api/bff/members", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateMemberAsync(Guid memberId, UpdateMemberRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/members/{memberId}", req, _jsonOptions);
var resp = await _http.PutAsJsonAsync($"api/bff/members/{memberId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -347,14 +357,7 @@ public class PosDataService
public async Task<CreatePosOrderResponse?> CreatePosOrderAsync(CreatePosOrderRequest req)
{
AttachToken();
// EN: Use camelCase for POST body (ASP.NET model binding default)
// VI: Dùng camelCase cho POST body (ASP.NET model binding mặc định)
var postOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
var resp = await _http.PostAsJsonAsync("api/bff/pos/orders", req, postOptions);
var resp = await _http.PostAsJsonAsync("api/bff/pos/orders", req, _writeOptions);
if (resp.IsSuccessStatusCode)
return await resp.Content.ReadFromJsonAsync<CreatePosOrderResponse>(_jsonOptions);
return null;
@@ -369,14 +372,14 @@ public class PosDataService
public async Task<bool> CreateCategoryAsync(AdminCreateCategoryRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/categories", req, _jsonOptions);
var resp = await _http.PostAsJsonAsync("api/bff/categories", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateCategoryAsync(Guid categoryId, AdminCreateCategoryRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/categories/{categoryId}", req, _jsonOptions);
var resp = await _http.PutAsJsonAsync($"api/bff/categories/{categoryId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -419,7 +422,7 @@ public class PosDataService
public async Task<bool> UpdateShopAsync(Guid shopId, UpdateShopRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}", req, _jsonOptions);
var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -452,7 +455,7 @@ public class PosDataService
public async Task<bool> UpdateShopSettingsAsync(Guid shopId, UpdateShopSettingsRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}/settings", req, _jsonOptions);
var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}/settings", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -474,10 +477,10 @@ public class PosDataService
public record CreateTableRequest(Guid ShopId, string TableNumber, int Capacity, string? Zone);
public async Task<bool> CreateTableAsync(CreateTableRequest req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/tables", req, _jsonOptions); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/tables", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateTableAsync(Guid tableId, CreateTableRequest req)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/tables/{tableId}", req, _jsonOptions); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/tables/{tableId}", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> DeleteTableAsync(Guid tableId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/tables/{tableId}"); return r.IsSuccessStatusCode; }
@@ -487,10 +490,10 @@ public class PosDataService
public record CreateAppointmentRequest(Guid ShopId, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid? ServiceId, DateTime StartTime, DateTime EndTime, string? Status = null);
public async Task<bool> CreateAppointmentAsync(CreateAppointmentRequest req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/appointments", req, _jsonOptions); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/appointments", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateAppointmentAsync(Guid apptId, CreateAppointmentRequest req)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/appointments/{apptId}", req, _jsonOptions); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/appointments/{apptId}", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> CancelAppointmentAsync(Guid apptId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/appointments/{apptId}/cancel"); return r.IsSuccessStatusCode; }
@@ -500,10 +503,10 @@ public class PosDataService
public record CreateResourceRequest(Guid ShopId, string Name, string ResourceType, int Capacity);
public async Task<bool> CreateResourceAsync(CreateResourceRequest req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/resources", req, _jsonOptions); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/resources", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateResourceAsync(Guid resourceId, CreateResourceRequest req)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/resources/{resourceId}", req, _jsonOptions); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/resources/{resourceId}", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> DeleteResourceAsync(Guid resourceId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/resources/{resourceId}"); return r.IsSuccessStatusCode; }
@@ -513,13 +516,13 @@ public class PosDataService
public record CreateScheduleRequest(Guid ShopId, Guid StaffId, int DayOfWeek, string StartTime, string EndTime);
public async Task<bool> CreateScheduleAsync(CreateScheduleRequest req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/schedules", req, _jsonOptions); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/staff/schedules", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateScheduleAsync(Guid scheduleId, CreateScheduleRequest req)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/schedules/{scheduleId}", req, _jsonOptions); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/staff/schedules/{scheduleId}", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> DeleteScheduleAsync(Guid scheduleId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/schedules/{scheduleId}"); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/staff/schedules/{scheduleId}"); return r.IsSuccessStatusCode; }
// ═══ KITCHEN TICKETS ═══
@@ -535,7 +538,7 @@ public class PosDataService
}
public async Task<bool> UpdateTicketStatusAsync(Guid ticketId, UpdateTicketStatusRequest req)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/kitchen/tickets/{ticketId}/status", req, _jsonOptions); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/kitchen/tickets/{ticketId}/status", req, _writeOptions); return r.IsSuccessStatusCode; }
// ═══ RECIPES CRUD ═══
@@ -553,10 +556,10 @@ public class PosDataService
}
public async Task<bool> CreateRecipeAsync(CreateRecipeRequest req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/recipes", req, _jsonOptions); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/recipes", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> UpdateRecipeAsync(Guid recipeId, CreateRecipeRequest req)
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/recipes/{recipeId}", req, _jsonOptions); return r.IsSuccessStatusCode; }
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/recipes/{recipeId}", req, _writeOptions); return r.IsSuccessStatusCode; }
public async Task<bool> DeleteRecipeAsync(Guid recipeId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/recipes/{recipeId}"); return r.IsSuccessStatusCode; }

View File

@@ -45,6 +45,14 @@ public class FinancialController : ControllerBase
public Task<IActionResult> GetPromotions() =>
_promotion.GetAsync("/api/v1/promotions").ProxyAsync();
/// <summary>
/// EN: Get campaigns for current merchant.
/// VI: Lấy danh sách chiến dịch của merchant hiện tại.
/// </summary>
[HttpGet("campaigns")]
public Task<IActionResult> GetCampaigns() =>
_promotion.GetAsync("/api/v1/campaigns").ProxyAsync();
/// <summary>
/// EN: Create a campaign.
/// VI: Tạo chiến dịch.

View File

@@ -74,7 +74,7 @@ public class StaffController : ControllerBase
/// EN: Create a staff schedule.
/// VI: Tạo lịch làm việc nhân viên.
/// </summary>
[HttpPost("schedules")]
[HttpPost("staff/schedules")]
public Task<IActionResult> CreateSchedule([FromBody] JsonElement body) =>
_merchant.PostAsJsonAsync("/api/v1/merchants/me/staff/schedules", body).ProxyAsync();
@@ -82,7 +82,7 @@ public class StaffController : ControllerBase
/// EN: Update a staff schedule.
/// VI: Cập nhật lịch làm việc nhân viên.
/// </summary>
[HttpPut("schedules/{scheduleId:guid}")]
[HttpPut("staff/schedules/{scheduleId:guid}")]
public Task<IActionResult> UpdateSchedule(Guid scheduleId, [FromBody] JsonElement body) =>
_merchant.PutAsJsonAsync($"/api/v1/merchants/me/staff/schedules/{scheduleId}", body).ProxyAsync();
@@ -90,7 +90,7 @@ public class StaffController : ControllerBase
/// EN: Delete a staff schedule.
/// VI: Xóa lịch làm việc nhân viên.
/// </summary>
[HttpDelete("schedules/{scheduleId:guid}")]
[HttpDelete("staff/schedules/{scheduleId:guid}")]
public Task<IActionResult> DeleteSchedule(Guid scheduleId) =>
_merchant.DeleteAsync($"/api/v1/merchants/me/staff/schedules/{scheduleId}").ProxyAsync();
}

View File

@@ -107,7 +107,7 @@ void AddServiceClient(string name, string envVar, string defaultUrl)
}).AddHttpMessageHandler<WebClientTpos.Server.Infrastructure.AuthForwardingHandler>();
}
AddServiceClient("MerchantService", "MerchantService__BaseUrl", "http://localhost:5002");
AddServiceClient("MerchantService", "MerchantService__BaseUrl", "http://localhost:5005");
AddServiceClient("CatalogService", "CatalogService__BaseUrl", "http://localhost:5016");
AddServiceClient("OrderService", "OrderService__BaseUrl", "http://localhost:5017");
AddServiceClient("InventoryService", "InventoryService__BaseUrl", "http://localhost:5018");