feat: Set up initial WebClientTpos .NET project structure, including client, server, assets, and documentation.

This commit is contained in:
Ho Ngoc Hai
2026-02-12 00:41:43 +07:00
parent b661bb7d8b
commit 689f4fa96f
51 changed files with 4860 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
# ═══════════════════════════════════════════════════════════════════════════════
# WebClientTpos Dockerfile
# EN: Multi-stage build for Blazor WebAssembly Hosted
# VI: Multi-stage build cho Blazor WebAssembly Hosted
# ═══════════════════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════════════════
# Stage 1: Build
# ═══════════════════════════════════════════════════════════════════════════════
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
# EN: Copy solution and project files for layer caching
# VI: Copy solution và project files để cache layers
COPY WebClientTpos.slnx ./
COPY src/WebClientTpos.Shared/WebClientTpos.Shared.csproj ./src/WebClientTpos.Shared/
COPY src/WebClientTpos.Client/WebClientTpos.Client.csproj ./src/WebClientTpos.Client/
COPY src/WebClientTpos.Server/WebClientTpos.Server.csproj ./src/WebClientTpos.Server/
# EN: Restore dependencies
# VI: Restore dependencies
RUN dotnet restore
# EN: Copy source code
# VI: Copy source code
COPY . .
# EN: Build and publish
# VI: Build và publish
RUN dotnet publish src/WebClientTpos.Server/WebClientTpos.Server.csproj \
-c Release \
-o /app/publish \
--no-restore
# ═══════════════════════════════════════════════════════════════════════════════
# Stage 2: Runtime
# ═══════════════════════════════════════════════════════════════════════════════
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS runtime
WORKDIR /app
# EN: Create non-root user for security
# VI: Tạo user không phải root để bảo mật
RUN adduser -D -u 1000 appuser && chown -R appuser /app
USER appuser
# EN: Copy published output
# VI: Copy output đã publish
COPY --from=build /app/publish .
# EN: Expose port
# VI: Expose port
EXPOSE 8080
# EN: Health check
# VI: Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# EN: Set environment variables
# VI: Thiết lập biến môi trường
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
# EN: Run the application
# VI: Chạy ứng dụng
ENTRYPOINT ["dotnet", "WebClientTpos.Server.dll"]

View File

@@ -0,0 +1,79 @@
# WebClientTpos - Blazor Web App .NET 10
> **EN**: [English Documentation](docs/en/README.md)
> **VI**: [Tài liệu Tiếng Việt](docs/vi/README.md)
Base frontend web application cho GoodGo Platform được xây dựng với Blazor WebAssembly + BFF Pattern.
## Quick Links
- 📖 [Architecture](docs/en/ARCHITECTURE.md) / [Kiến trúc](docs/vi/ARCHITECTURE.md)
- 🚀 [Quick Start](docs/en/README.md#quick-start)
- 🔧 [Configuration](docs/en/README.md#configuration)
## Architecture / Kiến trúc
```
┌─────────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Blazor WebAssembly Client │ │
│ └─────────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬────────────────────────────────────┘
│ /api/*
┌─────────────────────────────────────────────────────────────────────┐
│ BFF (Backend for Frontend) │
│ WebClientTpos.Server + YARP │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Routes: /api/iam/** → iam-service │ │
│ │ /api/merchants/** → merchant-service │ │
│ │ /api/catalog/** → catalog-service │ │
│ │ /api/orders/** → order-service │ │
│ └─────────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬────────────────────────────────────┘
│ Internal Network
┌─────────────────────────────────────────────────────────────────────┐
│ Microservices │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ IAM │ │ Merchant │ │ Catalog │ │ Order │ │
│ │ :5101 │ │ :5102 │ │ :5103 │ │ :5104 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## Tech Stack
| Layer | Technology |
|-------|------------|
| Client | Blazor WebAssembly (.NET 10) |
| BFF | ASP.NET Core + YARP Reverse Proxy |
| Shared | Class Library với Data Annotations |
| Styling | CSS Variables, Dark Mode |
## Getting Started / Bắt đầu
```bash
cd apps/web-client-base-net
dotnet restore
dotnet run --project src/WebClientTpos.Server
# Open http://localhost:5091
```
## Project Structure / Cấu trúc
```
web-client-base-net/
├── src/
│ ├── WebClientTpos.Client/ # Blazor WebAssembly
│ ├── WebClientTpos.Server/ # BFF with YARP Proxy
│ └── WebClientTpos.Shared/ # Shared DTOs
├── docs/
│ ├── en/ # English docs
│ └── vi/ # Vietnamese docs
└── Dockerfile
```
See detailed documentation in [docs/en/](docs/en/) or [docs/vi/](docs/vi/).

View File

@@ -0,0 +1,7 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/WebClientTpos.Client/WebClientTpos.Client.csproj" />
<Project Path="src/WebClientTpos.Server/WebClientTpos.Server.csproj" />
<Project Path="src/WebClientTpos.Shared/WebClientTpos.Shared.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,99 @@
# WebClientTpos Architecture
Base frontend architecture for GoodGo Platform using Blazor WebAssembly with BFF Pattern.
## Overview
This application implements the **Backend for Frontend (BFF)** pattern with YARP reverse proxy:
- **Client**: Blazor WebAssembly running in browser
- **BFF**: ASP.NET Core server proxying requests to microservices
- **Shared**: Common DTOs with validation (shared between Client/BFF)
## Architecture Diagram
```mermaid
flowchart TD
subgraph Browser["🌐 Browser"]
C[Blazor WASM Client]
end
subgraph BFF["⚙️ BFF Layer"]
Y[YARP Reverse Proxy]
SA[Static Assets]
end
subgraph MS["🔧 Microservices"]
IAM[IAM Service]
MER[Merchant Service]
CAT[Catalog Service]
ORD[Order Service]
end
C -->|Static Files| SA
C -->|/api/*| Y
Y -->|/api/iam/**| IAM
Y -->|/api/merchants/**| MER
Y -->|/api/catalog/**| CAT
Y -->|/api/orders/**| ORD
style C fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:3px
style Y fill:#8E44AD,color:#ECF0F1,stroke:#7D3C98,stroke-width:2px
style SA fill:#34495E,color:#ECF0F1,stroke:#2C3E50,stroke-width:2px
style IAM fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
style MER fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
style CAT fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
style ORD fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
```
## Project Structure
```
web-client-base-net/
├── src/
│ ├── WebClientTpos.Client/ # Blazor WebAssembly
│ │ ├── Layout/ # MainLayout, NavMenu
│ │ ├── Pages/ # Razor pages
│ │ └── wwwroot/ # Static assets, CSS
│ ├── WebClientTpos.Server/ # BFF + YARP Proxy
│ │ ├── Program.cs # App configuration
│ │ └── yarp.json # Proxy routes
│ └── WebClientTpos.Shared/ # Shared DTOs
│ └── DTOs/ # ProductDto, UserDto
└── docs/
```
## Shared DTOs
| DTO | Purpose | Validation |
|-----|---------|------------|
| `ProductDto` | Product data | Name (3-100 chars), Price (0.01-1B) |
| `RegisterDto` | User registration | Email, Password (8+ chars, complexity) |
| `LoginDto` | User login | Email, Password |
| `ApiResponse<T>` | Standard response wrapper | Success, Data, Error |
## YARP Routes
| Route | Target Service | Port |
|-------|---------------|------|
| `/api/iam/**` | IAM Service | 5101 |
| `/api/merchants/**` | Merchant Service | 5102 |
| `/api/catalog/**` | Catalog Service | 5103 |
| `/api/orders/**` | Order Service | 5104 |
## Blazor Pages
| Page | Route | Description |
|------|-------|-------------|
| Home | `/` | Landing page |
| Products | `/products` | Product management |
| Auth | `/auth` | Login/Register |
| Counter | `/counter` | Demo component |
| Weather | `/weather` | Demo data fetch |
## Key Technologies
- **.NET 10** - Latest framework
- **Blazor WebAssembly** - Client-side SPA
- **YARP** - Reverse proxy for microservices
- **Data Annotations** - Shared validation

View File

@@ -0,0 +1,65 @@
# WebClientTpos
Base frontend application cho GoodGo Platform.
## Quick Start
```bash
# Navigate to project
cd apps/web-client-base-net
# Restore packages
dotnet restore
# Run BFF server
dotnet run --project src/WebClientTpos.Server
# Open browser at http://localhost:5091
```
## Tech Stack
| Component | Technology |
|-----------|------------|
| Client | Blazor WebAssembly (.NET 10) |
| BFF | ASP.NET Core + YARP |
| Shared | Class Library with Data Annotations |
| Styling | CSS Variables, Dark Mode |
## Project Structure
```
src/
├── WebClientTpos.Client/ # Blazor WASM frontend
├── WebClientTpos.Server/ # BFF with YARP proxy
└── WebClientTpos.Shared/ # Shared DTOs
```
## Features
- ✅ BFF Pattern with YARP reverse proxy
- ✅ Shared validation (Client + Server)
- ✅ Dark/Light mode support
- ✅ Glassmorphism UI design
- ✅ Health check endpoint `/health`
## Configuration
YARP proxy routes in `yarp.json`:
```json
{
"ReverseProxy": {
"Routes": {
"iam-route": { "Match": { "Path": "/api/iam/{**catch-all}" } }
},
"Clusters": {
"iam-cluster": { "Destinations": { "d1": { "Address": "http://localhost:5101" } } }
}
}
}
```
## Related Documentation
- [Architecture](ARCHITECTURE.md) - System architecture details

View File

@@ -0,0 +1,99 @@
# Kiến trúc WebClientTpos
Kiến trúc frontend cơ sở cho GoodGo Platform sử dụng Blazor WebAssembly với mô hình BFF.
## Tổng quan
Ứng dụng này triển khai mô hình **Backend for Frontend (BFF)** với YARP reverse proxy:
- **Client**: Blazor WebAssembly chạy trên trình duyệt
- **BFF**: ASP.NET Core server proxy yêu cầu đến microservices
- **Shared**: DTOs chung với validation (chia sẻ giữa Client/BFF)
## Sơ đồ Kiến trúc
```mermaid
flowchart TD
subgraph Browser["🌐 Trình duyệt"]
C[Blazor WASM Client]
end
subgraph BFF["⚙️ Lớp BFF"]
Y[YARP Reverse Proxy]
SA[Tài nguyên tĩnh]
end
subgraph MS["🔧 Microservices"]
IAM[Dịch vụ IAM]
MER[Dịch vụ Merchant]
CAT[Dịch vụ Catalog]
ORD[Dịch vụ Order]
end
C -->|Tệp tĩnh| SA
C -->|/api/*| Y
Y -->|/api/iam/**| IAM
Y -->|/api/merchants/**| MER
Y -->|/api/catalog/**| CAT
Y -->|/api/orders/**| ORD
style C fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:3px
style Y fill:#8E44AD,color:#ECF0F1,stroke:#7D3C98,stroke-width:2px
style SA fill:#34495E,color:#ECF0F1,stroke:#2C3E50,stroke-width:2px
style IAM fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
style MER fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
style CAT fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
style ORD fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
```
## Cấu trúc Dự án
```
web-client-base-net/
├── src/
│ ├── WebClientTpos.Client/ # Blazor WebAssembly
│ │ ├── Layout/ # MainLayout, NavMenu
│ │ ├── Pages/ # Trang Razor
│ │ └── wwwroot/ # Tài nguyên tĩnh, CSS
│ ├── WebClientTpos.Server/ # BFF + YARP Proxy
│ │ ├── Program.cs # Cấu hình ứng dụng
│ │ └── yarp.json # Routes proxy
│ └── WebClientTpos.Shared/ # DTOs chung
│ └── DTOs/ # ProductDto, UserDto
└── docs/
```
## Shared DTOs
| DTO | Mục đích | Validation |
|-----|----------|------------|
| `ProductDto` | Dữ liệu sản phẩm | Tên (3-100 ký tự), Giá (0.01-1 tỷ) |
| `RegisterDto` | Đăng ký người dùng | Email, Mật khẩu (8+ ký tự, độ phức tạp) |
| `LoginDto` | Đăng nhập | Email, Mật khẩu |
| `ApiResponse<T>` | Wrapper response chuẩn | Success, Data, Error |
## YARP Routes
| Route | Dịch vụ đích | Cổng |
|-------|-------------|------|
| `/api/iam/**` | Dịch vụ IAM | 5101 |
| `/api/merchants/**` | Dịch vụ Merchant | 5102 |
| `/api/catalog/**` | Dịch vụ Catalog | 5103 |
| `/api/orders/**` | Dịch vụ Order | 5104 |
## Trang Blazor
| Trang | Route | Mô tả |
|-------|-------|-------|
| Home | `/` | Trang chủ |
| Products | `/products` | Quản lý sản phẩm |
| Auth | `/auth` | Đăng nhập/Đăng ký |
| Counter | `/counter` | Component demo |
| Weather | `/weather` | Demo fetch dữ liệu |
## Công nghệ chính
- **.NET 10** - Framework mới nhất
- **Blazor WebAssembly** - SPA phía client
- **YARP** - Reverse proxy cho microservices
- **Data Annotations** - Validation chia sẻ

View File

@@ -0,0 +1,65 @@
# WebClientTpos
Ứng dụng frontend cơ sở cho GoodGo Platform.
## Bắt đầu nhanh
```bash
# Di chuyển đến dự án
cd apps/web-client-base-net
# Khôi phục packages
dotnet restore
# Chạy BFF server
dotnet run --project src/WebClientTpos.Server
# Mở trình duyệt tại http://localhost:5091
```
## Công nghệ
| Thành phần | Công nghệ |
|------------|-----------|
| Client | Blazor WebAssembly (.NET 10) |
| BFF | ASP.NET Core + YARP |
| Shared | Class Library với Data Annotations |
| Styling | CSS Variables, Dark Mode |
## Cấu trúc Dự án
```
src/
├── WebClientTpos.Client/ # Blazor WASM frontend
├── WebClientTpos.Server/ # BFF với YARP proxy
└── WebClientTpos.Shared/ # DTOs chia sẻ
```
## Tính năng
- ✅ Mô hình BFF với YARP reverse proxy
- ✅ Validation chia sẻ (Client + Server)
- ✅ Hỗ trợ chế độ Sáng/Tối
- ✅ Thiết kế UI Glassmorphism
- ✅ Endpoint kiểm tra sức khỏe `/health`
## Cấu hình
Routes YARP proxy trong `yarp.json`:
```json
{
"ReverseProxy": {
"Routes": {
"iam-route": { "Match": { "Path": "/api/iam/{**catch-all}" } }
},
"Clusters": {
"iam-cluster": { "Destinations": { "d1": { "Address": "http://localhost:5101" } } }
}
}
}
```
## Tài liệu liên quan
- [Kiến trúc](ARCHITECTURE.md) - Chi tiết kiến trúc hệ thống

View File

@@ -0,0 +1,6 @@
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
</Router>

View File

@@ -0,0 +1,67 @@
@using System.Globalization
@inject NavigationManager Navigation
<MudMenu Dense="true" AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopRight" LockScroll="true">
<ActivatorContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="mr-2 cursor-pointer">
<MudText Typo="Typo.button" Style="font-family: var(--font-heading);">
@GetCurrentLabel()
</MudText>
<MudIcon Icon="@Icons.Material.Rounded.Language" Size="Size.Small" />
</MudStack>
</ActivatorContent>
<ChildContent>
<MudMenuItem OnClick="@(() => SwitchLanguage("vi-VN"))">
<MudStack Row="true" Spacing="2">
<MudText>🇻🇳</MudText>
<MudText>Tiếng Việt</MudText>
</MudStack>
</MudMenuItem>
<MudMenuItem OnClick="@(() => SwitchLanguage("en-US"))">
<MudStack Row="true" Spacing="2">
<MudText>🇺🇸</MudText>
<MudText>English</MudText>
</MudStack>
</MudMenuItem>
</ChildContent>
</MudMenu>
@code {
private string GetCurrentLabel()
{
var uri = new Uri(Navigation.Uri);
var path = uri.PathAndQuery;
// Simple heuristic: if path starts with /vi-VN or /vi, show VI. Default EN.
if (path.StartsWith("/vi", StringComparison.OrdinalIgnoreCase))
{
return "VI";
}
return "EN";
}
private void SwitchLanguage(string targetCulture)
{
var uri = new Uri(Navigation.Uri);
var path = uri.PathAndQuery;
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
string newPath;
if (segments.Length > 0 && (segments[0].Equals("vi-VN", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("en-US", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("vi", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("en", StringComparison.OrdinalIgnoreCase)))
{
segments[0] = targetCulture;
newPath = "/" + string.Join('/', segments);
}
else
{
if (path == "/") path = "";
newPath = $"/{targetCulture}{path}";
}
Navigation.NavigateTo(newPath, forceLoad: true);
}
}

View File

@@ -0,0 +1,86 @@
@inherits LayoutComponentBase
@inject IStringLocalizer<MainLayout> L
<MudThemeProvider IsDarkMode="true" Theme="_theme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<!-- aPOS Navigation Bar -->
<nav class="tpos-navbar">
<div class="tpos-navbar-inner">
<!-- Logo -->
<a href="/" class="tpos-logo">@L["AppName"]</a>
<!-- Desktop nav links -->
<div class="tpos-nav-links">
<a href="#features" class="tpos-nav-link">@L["Nav_Features"]</a>
<a href="#industries" class="tpos-nav-link">@L["Nav_Industries"]</a>
<a href="#pricing" class="tpos-nav-link">@L["Nav_Pricing"]</a>
<a href="#" class="tpos-nav-link">@L["Nav_Contact"]</a>
<!-- Language Switcher -->
<LanguageSwitcher />
<a href="/login" class="tpos-nav-link">@L["Nav_Login"]</a>
<a href="#" class="btn-accent">@L["Nav_FreeTrial"]</a>
</div>
<!-- Mobile hamburger button -->
<button class="tpos-hamburger @(_mobileMenuOpen ? "active" : "")"
@onclick="ToggleMobileMenu"
aria-label="Toggle menu">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
<!-- Mobile nav overlay -->
@if (_mobileMenuOpen)
{
<div class="tpos-mobile-overlay" @onclick="CloseMobileMenu"></div>
<div class="tpos-mobile-drawer">
<a href="#features" class="tpos-mobile-link" @onclick="CloseMobileMenu">@L["Nav_Features"]</a>
<a href="#industries" class="tpos-mobile-link" @onclick="CloseMobileMenu">@L["Nav_Industries"]</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="/login" class="tpos-mobile-link" @onclick="CloseMobileMenu">@L["Nav_Login"]</a>
<div class="tpos-mobile-actions">
<LanguageSwitcher />
<a href="#" class="btn-accent btn-accent-lg" style="width:100%; text-align:center;">@L["Nav_FreeTrial"]</a>
</div>
</div>
}
<!-- Main Content -->
<MudLayout>
<MudMainContent Style="padding-top: 0;">
@Body
</MudMainContent>
</MudLayout>
@code {
private bool _mobileMenuOpen = false;
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

@@ -0,0 +1,3 @@
/* MainLayout scoped styles - aPOS */
/* The main layout uses global CSS classes from app.css */
/* This file is kept minimal as aPOS styling is handled globally */

View File

@@ -0,0 +1,17 @@
<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

@@ -0,0 +1,83 @@
.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

@@ -0,0 +1,54 @@
using System.Globalization;
using System.Net.Http.Json;
using Microsoft.Extensions.Localization;
namespace WebClientTpos.Client.Localization;
public class JsonStringLocalizer : IStringLocalizer
{
private readonly LocalizationCache _cache;
private readonly string _resourceName;
public JsonStringLocalizer(LocalizationCache cache, string resourceName)
{
_cache = cache;
_resourceName = resourceName;
}
// This constructor style is used by the Factory (if we update factory)
public JsonStringLocalizer(LocalizationCache cache)
{
_cache = cache;
_resourceName = "Shared";
}
public LocalizedString this[string name]
{
get
{
var value = GetString(name);
return new LocalizedString(name, value ?? name, resourceNotFound: value == null);
}
}
public LocalizedString this[string name, params object[] arguments]
{
get
{
var format = GetString(name);
var value = string.Format(format ?? name, arguments);
return new LocalizedString(name, value, resourceNotFound: format == null);
}
}
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
{
// Not fully supported by simple cache
return Enumerable.Empty<LocalizedString>();
}
private string? GetString(string name)
{
return _cache.GetString(name);
}
}

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.Localization;
namespace WebClientTpos.Client.Localization;
public class JsonStringLocalizerFactory : IStringLocalizerFactory
{
private readonly LocalizationCache _cache;
private readonly IServiceProvider _serviceProvider;
public JsonStringLocalizerFactory(LocalizationCache cache, IServiceProvider serviceProvider)
{
_cache = cache;
_serviceProvider = serviceProvider;
}
public IStringLocalizer Create(Type resourceSource)
{
return new JsonStringLocalizer(_cache, resourceSource.Name);
}
public IStringLocalizer Create(string baseName, string location)
{
return new JsonStringLocalizer(_cache, baseName);
}
}

View File

@@ -0,0 +1,50 @@
using System.Globalization;
using System.Net.Http.Json;
namespace WebClientTpos.Client.Localization;
public class LocalizationCache
{
private readonly HttpClient _httpClient;
private Dictionary<string, string> _strings = new();
private bool _isLoaded;
public LocalizationCache(HttpClient httpClient)
{
_httpClient = httpClient;
}
public string? GetString(string key)
{
if (_strings.TryGetValue(key, out var value))
{
return value;
}
return null;
}
public async Task LoadAsync(CultureInfo culture)
{
if (_isLoaded) return; // Or check if culture changed
try
{
var cultureName = culture.Name;
// Map generic "vi" to "vi-VN" if needed, but for now we trust the culture name matches file
// Fallback for simple "vi" -> "vi-VN"
if (cultureName == "vi") cultureName = "vi-VN";
if (cultureName == "en") cultureName = "en-US";
var loaded = await _httpClient.GetFromJsonAsync<Dictionary<string, string>>($"/locales/{cultureName}.json?v={DateTime.Now.Ticks}");
if (loaded != null)
{
_strings = loaded;
_isLoaded = true;
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading localization for {culture.Name}: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,108 @@
@page "/forgot-password"
@using WebClientTpos.Shared.DTOs
@using WebClientTpos.Shared
@inject HttpClient Http
@inject IStringLocalizer<ForgotPassword> L
@*
EN: Forgot password page.
VI: Trang quên mật khẩu.
*@
<PageTitle>@L["Auth_ForgotPassword_Title"]</PageTitle>
<div class="auth-container">
<section class="auth-card">
<h1 class="auth-title">@L["Auth_ForgotPassword_Title"]</h1>
<p class="auth-subtitle">@L["Auth_ForgotPassword_Subtitle"]</p>
<EditForm Model="@forgotPasswordModel" OnValidSubmit="HandleForgotPassword" FormName="ForgotPasswordForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="forgot-email">@L["Auth_ForgotPassword_Email"] *</label>
<InputText id="forgot-email"
@bind-Value="forgotPasswordModel.Email"
class="form-input"
placeholder="email@example.com"
autocomplete="email" />
<ValidationMessage For="() => forgotPasswordModel.Email" class="validation-message" />
</div>
<button type="submit" class="btn-primary btn-full" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner-small"></span>
<span>@L["Common_Loading"]</span>
}
else
{
@L["Auth_ForgotPassword_Submit"]
}
</button>
</EditForm>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert @(success ? "alert-success" : "alert-error")">
@message
</div>
}
<div class="auth-footer">
<a href="/login" class="link-primary">@L["Auth_ForgotPassword_BackToLogin"]</a>
</div>
</section>
</div>
@code {
private ForgotPasswordDto forgotPasswordModel = new();
private bool isSubmitting = false;
private string message = "";
private bool success = false;
/// <summary>
/// EN: Handle forgot password form submission.
/// VI: Xử lý submit form quên mật khẩu.
/// </summary>
private async Task HandleForgotPassword()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/auth/forgot-password", forgotPasswordModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
if (result?.Success == true)
{
success = true;
message = L["Auth_ForgotPassword_Success"];
forgotPasswordModel = new(); // Clear form
}
else
{
success = false;
message = result?.Error ?? L["Auth_ForgotPassword_Error"];
}
}
else
{
success = false;
message = L["Auth_ForgotPassword_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
}

View File

@@ -0,0 +1,136 @@
@page "/login"
@using WebClientTpos.Shared.DTOs
@using WebClientTpos.Shared
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IStringLocalizer<Login> L
@*
EN: Login page with email/password authentication.
VI: Trang đăng nhập với xác thực email/mật khẩu.
*@
<PageTitle>@L["Auth_Login_Title"]</PageTitle>
<div class="auth-container">
<section class="auth-card">
<h1 class="auth-title">@L["Auth_Login_Title"]</h1>
<p class="auth-subtitle">@L["Auth_Login_Subtitle"]</p>
<EditForm Model="@loginModel" OnValidSubmit="HandleLogin" FormName="LoginForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="login-email">@L["Auth_Login_Email"] *</label>
<InputText id="login-email"
@bind-Value="loginModel.Email"
class="form-input"
placeholder="email@example.com"
autocomplete="email" />
<ValidationMessage For="() => loginModel.Email" class="validation-message" />
</div>
<div class="form-group">
<label for="login-password">@L["Auth_Login_Password"] *</label>
<InputText id="login-password"
@bind-Value="loginModel.Password"
type="password"
class="form-input"
placeholder="••••••••"
autocomplete="current-password" />
<ValidationMessage For="() => loginModel.Password" class="validation-message" />
</div>
<div class="form-actions-row">
<div class="checkbox-group">
<InputCheckbox id="remember-me"
@bind-Value="loginModel.RememberMe"
class="form-checkbox" />
<label for="remember-me" class="checkbox-label">@L["Auth_Login_RememberMe"]</label>
</div>
<a href="/forgot-password" class="link-secondary">@L["Auth_Login_ForgotPassword"]</a>
</div>
<button type="submit" class="btn-primary btn-full" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner-small"></span>
<span>@L["Common_Loading"]</span>
}
else
{
@L["Auth_Login_Submit"]
}
</button>
</EditForm>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert @(success ? "alert-success" : "alert-error")">
@message
</div>
}
<div class="auth-footer">
<span>@L["Auth_Login_NoAccount"]</span>
<a href="/register" class="link-primary">@L["Auth_Login_RegisterLink"]</a>
</div>
</section>
</div>
@code {
private LoginDto loginModel = new();
private bool isSubmitting = false;
private string message = "";
private bool success = false;
/// <summary>
/// EN: Handle login form submission.
/// VI: Xử lý submit form đăng nhập.
/// </summary>
private async Task HandleLogin()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/auth/login", loginModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<UserProfileDto>>();
if (result?.Success == true && result.Data != null)
{
success = true;
message = string.Format(L["Auth_Login_Success"], result.Data.DisplayName);
// EN: Redirect to home after 1 second
// VI: Chuyển hướng về trang chủ sau 1 giây
await Task.Delay(1000);
Navigation.NavigateTo("/");
}
else
{
success = false;
message = L["Auth_Login_Error"];
}
}
else
{
success = false;
message = L["Auth_Login_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
}

View File

@@ -0,0 +1,295 @@
@page "/profile"
@using WebClientTpos.Shared.DTOs
@using WebClientTpos.Shared
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IStringLocalizer<Profile> L
@*
EN: User profile management page (requires authentication).
VI: Trang quản lý hồ sơ người dùng (yêu cầu xác thực).
*@
<PageTitle>@L["Auth_Profile_Title"]</PageTitle>
<div class="profile-container">
<div class="profile-header">
<h1 class="profile-title">@L["Auth_Profile_Title"]</h1>
<p class="profile-subtitle">@L["Auth_Profile_Subtitle"]</p>
</div>
@if (isLoading)
{
<div class="loading-state">
<span class="spinner"></span>
<p>@L["Common_Loading"]</p>
</div>
}
else if (userProfile != null)
{
<div class="profile-content">
<!-- Personal Information Section -->
<section class="profile-section">
<h2 class="section-title">@L["Auth_Profile_PersonalInfo"]</h2>
<EditForm Model="@userProfile" OnValidSubmit="HandleUpdateProfile" FormName="ProfileForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="profile-name">@L["Auth_Register_DisplayName"]</label>
<InputText id="profile-name"
@bind-Value="userProfile.DisplayName"
class="form-input"
disabled="@(!isEditingProfile)" />
</div>
<div class="form-group">
<label for="profile-email">@L["Common_Email"]</label>
<InputText id="profile-email"
@bind-Value="userProfile.Email"
class="form-input"
disabled />
</div>
<div class="form-group">
<label>@L["Auth_Profile_MemberSince"]</label>
<p class="text-secondary">@userProfile.CreatedAt.ToString("MMMM dd, yyyy")</p>
</div>
@if (!isEditingProfile)
{
<button type="button" class="btn-secondary" @onclick="@(() => isEditingProfile = true)">
@L["Auth_Profile_EditProfile"]
</button>
}
else
{
<div class="btn-group">
<button type="submit" class="btn-primary" disabled="@isSubmitting">
@L["Auth_Profile_SaveChanges"]
</button>
<button type="button" class="btn-secondary" @onclick="CancelEditProfile">
@L["Common_Cancel"]
</button>
</div>
}
</EditForm>
</section>
<!-- Security Section -->
<section class="profile-section">
<h2 class="section-title">@L["Auth_Profile_Security"]</h2>
<EditForm Model="@changePasswordModel" OnValidSubmit="HandleChangePassword" FormName="ChangePasswordForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="current-password">@L["Auth_Profile_CurrentPassword"] *</label>
<InputText id="current-password"
@bind-Value="changePasswordModel.CurrentPassword"
type="password"
class="form-input"
placeholder="••••••••" />
<ValidationMessage For="() => changePasswordModel.CurrentPassword" class="validation-message" />
</div>
<div class="form-group">
<label for="new-password">@L["Auth_Profile_NewPassword"] *</label>
<InputText id="new-password"
@bind-Value="changePasswordModel.NewPassword"
type="password"
class="form-input"
placeholder="••••••••" />
<small class="form-hint">@L["Auth_Register_PasswordHint"]</small>
<ValidationMessage For="() => changePasswordModel.NewPassword" class="validation-message" />
</div>
<div class="form-group">
<label for="confirm-new-password">@L["Auth_Profile_ConfirmPassword"] *</label>
<InputText id="confirm-new-password"
@bind-Value="changePasswordModel.ConfirmPassword"
type="password"
class="form-input"
placeholder="••••••••" />
<ValidationMessage For="() => changePasswordModel.ConfirmPassword" class="validation-message" />
</div>
<button type="submit" class="btn-primary" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner-small"></span>
<span>@L["Common_Loading"]</span>
}
else
{
@L["Auth_Profile_UpdatePassword"]
}
</button>
</EditForm>
</section>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert @(success ? "alert-success" : "alert-error")">
@message
</div>
}
<div class="profile-actions">
<button class="btn-secondary" @onclick="HandleLogout">
@L["Auth_Profile_Logout"]
</button>
</div>
</div>
}
</div>
@code {
private UserProfileDto? userProfile;
private ChangePasswordDto changePasswordModel = new();
private bool isLoading = true;
private bool isEditingProfile = false;
private bool isSubmitting = false;
private string message = "";
private bool success = false;
protected override async Task OnInitializedAsync()
{
await LoadUserProfile();
}
/// <summary>
/// EN: Load user profile data.
/// VI: Tải dữ liệu hồ sơ người dùng.
/// </summary>
private async Task LoadUserProfile()
{
try
{
var response = await Http.GetAsync("api/auth/profile");
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<UserProfileDto>>();
userProfile = result?.Data;
}
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");
}
}
catch (Exception ex)
{
message = $"{L["Common_Error"]}: {ex.Message}";
success = false;
}
finally
{
isLoading = false;
}
}
/// <summary>
/// EN: Handle profile update.
/// VI: Xử lý cập nhật hồ sơ.
/// </summary>
private async Task HandleUpdateProfile()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PutAsJsonAsync("api/auth/profile", userProfile);
if (response.IsSuccessStatusCode)
{
success = true;
message = L["Auth_Profile_Success"];
isEditingProfile = false;
}
else
{
success = false;
message = L["Auth_Profile_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
/// <summary>
/// EN: Handle password change.
/// VI: Xử lý đổi mật khẩu.
/// </summary>
private async Task HandleChangePassword()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/auth/change-password", changePasswordModel);
if (response.IsSuccessStatusCode)
{
success = true;
message = L["Auth_Profile_PasswordSuccess"];
changePasswordModel = new(); // Clear form
}
else
{
success = false;
message = L["Auth_Profile_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
/// <summary>
/// EN: Cancel profile editing.
/// VI: Hủy chỉnh sửa hồ sơ.
/// </summary>
private async Task CancelEditProfile()
{
isEditingProfile = false;
// Reload to reset changes
await LoadUserProfile();
}
/// <summary>
/// EN: Handle user logout.
/// VI: Xử lý đăng xuất.
/// </summary>
private async Task HandleLogout()
{
try
{
await Http.PostAsync("api/auth/logout", null);
Navigation.NavigateTo("/login");
}
catch
{
// EN: Even if logout fails, navigate to login
// VI: Ngay cả khi đăng xuất thất bại, chuyển về đăng nhập
Navigation.NavigateTo("/login");
}
}
}

View File

@@ -0,0 +1,155 @@
@page "/register"
@using WebClientTpos.Shared.DTOs
@using WebClientTpos.Shared
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IStringLocalizer<Register> L
@*
EN: User registration page.
VI: Trang đăng ký người dùng.
*@
<PageTitle>@L["Auth_Register_Title"]</PageTitle>
<div class="auth-container">
<section class="auth-card">
<h1 class="auth-title">@L["Auth_Register_Title"]</h1>
<p class="auth-subtitle">@L["Auth_Register_Subtitle"]</p>
<EditForm Model="@registerModel" OnValidSubmit="HandleRegister" FormName="RegisterForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="reg-name">@L["Auth_Register_DisplayName"] *</label>
<InputText id="reg-name"
@bind-Value="registerModel.DisplayName"
class="form-input"
placeholder="John Doe"
autocomplete="name" />
<ValidationMessage For="() => registerModel.DisplayName" class="validation-message" />
</div>
<div class="form-group">
<label for="reg-email">@L["Auth_Register_Email"] *</label>
<InputText id="reg-email"
@bind-Value="registerModel.Email"
class="form-input"
placeholder="email@example.com"
autocomplete="email" />
<ValidationMessage For="() => registerModel.Email" class="validation-message" />
</div>
<div class="form-group">
<label for="reg-password">@L["Auth_Register_Password"] *</label>
<InputText id="reg-password"
@bind-Value="registerModel.Password"
type="password"
class="form-input"
placeholder="••••••••"
autocomplete="new-password" />
<small class="form-hint">@L["Auth_Register_PasswordHint"]</small>
<ValidationMessage For="() => registerModel.Password" class="validation-message" />
</div>
<div class="form-group">
<label for="reg-confirm">@L["Auth_Register_ConfirmPassword"] *</label>
<InputText id="reg-confirm"
@bind-Value="registerModel.ConfirmPassword"
type="password"
class="form-input"
placeholder="••••••••"
autocomplete="new-password" />
<ValidationMessage For="() => registerModel.ConfirmPassword" class="validation-message" />
</div>
<div class="checkbox-group mb-6">
<InputCheckbox id="accept-terms"
@bind-Value="registerModel.AcceptTerms"
class="form-checkbox" />
<label for="accept-terms" class="checkbox-label">@L["Auth_Register_Terms"]</label>
</div>
<button type="submit" class="btn-primary btn-full" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner-small"></span>
<span>@L["Common_Loading"]</span>
}
else
{
@L["Auth_Register_Submit"]
}
</button>
</EditForm>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert @(success ? "alert-success" : "alert-error")">
@message
</div>
}
<div class="auth-footer">
<span>@L["Auth_Register_HaveAccount"]</span>
<a href="/login" class="link-primary">@L["Auth_Register_LoginLink"]</a>
</div>
</section>
</div>
@code {
private RegisterDto registerModel = new();
private bool isSubmitting = false;
private string message = "";
private bool success = false;
/// <summary>
/// EN: Handle registration form submission.
/// VI: Xử lý submit form đăng ký.
/// </summary>
private async Task HandleRegister()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/auth/register", registerModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<UserProfileDto>>();
if (result?.Success == true)
{
success = true;
message = L["Auth_Register_Success"];
// 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("/login");
}
else
{
success = false;
message = result?.Error ?? L["Auth_Register_Error"];
}
}
else
{
success = false;
var content = await response.Content.ReadAsStringAsync();
message = $"{L["Auth_Register_Error"]}: {content}";
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
}

View File

@@ -0,0 +1,164 @@
@page "/reset-password"
@using WebClientTpos.Shared.DTOs
@using WebClientTpos.Shared
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IStringLocalizer<ResetPassword> L
@*
EN: Reset password page with token validation.
VI: Trang đặt lại mật khẩu với xác thực token.
*@
<PageTitle>@L["Auth_ResetPassword_Title"]</PageTitle>
<div class="auth-container">
<section class="auth-card">
<h1 class="auth-title">@L["Auth_ResetPassword_Title"]</h1>
<p class="auth-subtitle">@L["Auth_ResetPassword_Subtitle"]</p>
@if (invalidToken)
{
<div class="alert alert-error">
@L["Auth_ResetPassword_InvalidToken"]
</div>
<div class="auth-footer">
<a href="/forgot-password" class="link-primary">@L["Auth_ForgotPassword_Title"]</a>
</div>
}
else
{
<EditForm Model="@resetPasswordModel" OnValidSubmit="HandleResetPassword" FormName="ResetPasswordForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="new-password">@L["Auth_ResetPassword_NewPassword"] *</label>
<InputText id="new-password"
@bind-Value="resetPasswordModel.NewPassword"
type="password"
class="form-input"
placeholder="••••••••"
autocomplete="new-password" />
<small class="form-hint">@L["Auth_ResetPassword_PasswordHint"]</small>
<ValidationMessage For="() => resetPasswordModel.NewPassword" class="validation-message" />
</div>
<div class="form-group">
<label for="confirm-password">@L["Auth_ResetPassword_ConfirmPassword"] *</label>
<InputText id="confirm-password"
@bind-Value="resetPasswordModel.ConfirmPassword"
type="password"
class="form-input"
placeholder="••••••••"
autocomplete="new-password" />
<ValidationMessage For="() => resetPasswordModel.ConfirmPassword" class="validation-message" />
</div>
<button type="submit" class="btn-primary btn-full" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner-small"></span>
<span>@L["Common_Loading"]</span>
}
else
{
@L["Auth_ResetPassword_Submit"]
}
</button>
</EditForm>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert @(success ? "alert-success" : "alert-error")">
@message
</div>
}
}
</section>
</div>
@code {
private ResetPasswordDto resetPasswordModel = new();
private bool isSubmitting = false;
private string message = "";
private bool success = false;
private bool invalidToken = false;
protected override void OnInitialized()
{
// EN: Parse query parameters for token and email
// VI: Phân tích query parameters cho token và email
var uri = new Uri(Navigation.Uri);
var query = uri.Query;
if (!string.IsNullOrEmpty(query))
{
var queryParams = System.Web.HttpUtility.ParseQueryString(query);
var token = queryParams["token"];
var email = queryParams["email"];
if (!string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(email))
{
resetPasswordModel.Token = token;
resetPasswordModel.Email = email;
}
else
{
invalidToken = true;
}
}
else
{
invalidToken = true;
}
}
/// <summary>
/// EN: Handle reset password form submission.
/// VI: Xử lý submit form đặt lại mật khẩu.
/// </summary>
private async Task HandleResetPassword()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/auth/reset-password", resetPasswordModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
if (result?.Success == true)
{
success = true;
message = L["Auth_ResetPassword_Success"];
// 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("/login");
}
else
{
success = false;
message = result?.Error ?? L["Auth_ResetPassword_Error"];
}
}
else
{
success = false;
message = L["Auth_ResetPassword_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
}

View File

@@ -0,0 +1,124 @@
@page "/verify-email"
@using WebClientTpos.Shared
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IStringLocalizer<VerifyEmail> L
@*
EN: Email verification page.
VI: Trang xác minh email.
*@
<PageTitle>@L["Auth_VerifyEmail_Title"]</PageTitle>
<div class="auth-container">
<section class="auth-card text-center">
<h1 class="auth-title">@L["Auth_VerifyEmail_Title"]</h1>
@if (isVerifying)
{
<div class="loading-state">
<span class="spinner"></span>
<p class="mt-4">@L["Auth_VerifyEmail_Verifying"]</p>
</div>
}
else if (success)
{
<div class="success-state">
<svg class="checkmark" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
<p class="mt-4 text-lg">@L["Auth_VerifyEmail_Success"]</p>
<button class="btn-primary mt-6" @onclick="@(() => Navigation.NavigateTo("/login"))">
@L["Auth_VerifyEmail_GoToLogin"]
</button>
</div>
}
else
{
<div class="alert alert-error">
@message
</div>
<div class="mt-6">
<a href="/login" class="link-primary">@L["Auth_VerifyEmail_GoToLogin"]</a>
</div>
}
</section>
</div>
@code {
private bool isVerifying = true;
private bool success = false;
private string message = "";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await VerifyEmailAsync();
}
}
/// <summary>
/// EN: Verify email using token from query parameters.
/// VI: Xác minh email sử dụng token từ query parameters.
/// </summary>
private async Task VerifyEmailAsync()
{
try
{
// EN: Parse query parameters
// VI: Phân tích query parameters
var uri = new Uri(Navigation.Uri);
var query = uri.Query;
string? token = null;
string? userId = null;
if (!string.IsNullOrEmpty(query))
{
var queryParams = System.Web.HttpUtility.ParseQueryString(query);
token = queryParams["token"];
userId = queryParams["userId"];
}
if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(userId))
{
success = false;
message = L["Auth_VerifyEmail_InvalidToken"];
isVerifying = false;
StateHasChanged();
return;
}
// EN: Call verification endpoint
// VI: Gọi endpoint xác minh
var response = await Http.PostAsJsonAsync("api/auth/verify-email", new {
Token = token.ToString(),
UserId = userId.ToString()
});
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
success = result?.Success == true;
message = success ? L["Auth_VerifyEmail_Success"] : (result?.Error ?? L["Auth_VerifyEmail_Error"]);
}
else
{
success = false;
message = L["Auth_VerifyEmail_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isVerifying = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,302 @@
@page "/"
@inject IStringLocalizer<Home> L
<PageTitle>aPOS - @L["HeroHeadline"]</PageTitle>
<!-- ═══════════════════════════════════════════════════════════════════════
1. HERO SECTION
═══════════════════════════════════════════════════════════════════════ -->
<section class="hero-section">
<div class="hero-badge">@((MarkupString)L["HeroBadge"].Value)</div>
<h1 class="hero-headline">
@((MarkupString)L["HeroHeadline"].Value)
</h1>
<p class="hero-subtext">
@L["HeroSubtext"]
</p>
<div class="hero-actions">
<a href="#pricing" class="btn-accent btn-accent-lg">@L["HeroCTA_Primary"]</a>
<a href="#features" class="btn-outline btn-outline-lg">@L["HeroCTA_Secondary"]</a>
</div>
<div class="hero-mockup">
<span>@L["HeroMockup_Alt"]</span>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════════════
2. TRUST SECTION
═══════════════════════════════════════════════════════════════════════ -->
<section class="trust-section">
<div class="container">
<p class="trust-label">@L["Trust_Label"]</p>
<div class="trust-stats">
<span class="trust-stat">@((MarkupString)L["Trust_Stat1"].Value)</span>
<span class="trust-stat">@((MarkupString)L["Trust_Stat2"].Value)</span>
<span class="trust-stat">@((MarkupString)L["Trust_Stat3"].Value)</span>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════════════
3. FEATURES SECTION
═══════════════════════════════════════════════════════════════════════ -->
<section id="features" class="tpos-section">
<div class="container">
<div class="tpos-section-header">
<div class="tpos-badge">@L["Features_Badge"]</div>
<h2 class="tpos-section-title">@L["Features_Title"]</h2>
<p class="tpos-section-desc">@L["Features_Desc"]</p>
</div>
<div class="tpos-feature-grid">
@foreach (var f in _features)
{
<div class="tpos-feature-card">
<div class="tpos-feature-icon">@((MarkupString)L[f.Icon].Value)</div>
<h3 class="tpos-feature-title">@L[f.Title]</h3>
<p class="tpos-feature-desc">@L[f.Desc]</p>
</div>
}
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════════════
4. INDUSTRIES SECTION
═══════════════════════════════════════════════════════════════════════ -->
<section id="industries" class="tpos-section" style="background: var(--bg-surface);">
<div class="container">
<div class="tpos-section-header">
<div class="tpos-badge">@L["Industries_Badge"]</div>
<h2 class="tpos-section-title">@L["Industries_Title"]</h2>
<p class="tpos-section-desc">@L["Industries_Desc"]</p>
</div>
<div class="tpos-industry-grid">
@foreach (var ind in _industries)
{
<div class="tpos-industry-card">
<h3 class="tpos-industry-title">@L[ind.Title]</h3>
<p class="tpos-industry-desc">@L[ind.Desc]</p>
<div class="tpos-chips">
@foreach (var chip in L[ind.Chips].Value.Split(','))
{
<span class="tpos-chip">✨ @chip.Trim()</span>
}
</div>
</div>
}
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════════════
5. ONBOARDING STEPS
═══════════════════════════════════════════════════════════════════════ -->
<section class="tpos-section">
<div class="container">
<div class="tpos-section-header">
<div class="tpos-badge">@L["Steps_Badge"]</div>
<h2 class="tpos-section-title">@L["Steps_Title"]</h2>
<p class="tpos-section-desc">@L["Steps_Desc"]</p>
</div>
<div class="tpos-steps">
<div class="tpos-step">
<div class="tpos-step-num">1</div>
<h3 class="tpos-step-title">@L["Step1_Title"]</h3>
<p class="tpos-step-desc">@L["Step1_Desc"]</p>
</div>
<div class="tpos-step">
<div class="tpos-step-num">2</div>
<h3 class="tpos-step-title">@L["Step2_Title"]</h3>
<p class="tpos-step-desc">@L["Step2_Desc"]</p>
</div>
<div class="tpos-step">
<div class="tpos-step-num">3</div>
<h3 class="tpos-step-title">@L["Step3_Title"]</h3>
<p class="tpos-step-desc">@L["Step3_Desc"]</p>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════════════
6. PRICING SECTION
═══════════════════════════════════════════════════════════════════════ -->
<section id="pricing" class="tpos-section" style="background: var(--bg-surface);">
<div class="container">
<div class="tpos-section-header">
<div class="tpos-badge">@L["Pricing_Badge"]</div>
<h2 class="tpos-section-title">@L["Pricing_Title"]</h2>
<p class="tpos-section-desc">@L["Pricing_Desc"]</p>
</div>
<div class="tpos-pricing-grid">
<!-- Starter Plan -->
<div class="tpos-pricing-card">
<div class="tpos-pricing-badge">@L["Plan_Starter_Badge"]</div>
<div class="tpos-pricing-name">@L["Plan_Starter_Name"]</div>
<div class="tpos-pricing-price">
<span class="tpos-pricing-amount">@L["Plan_Starter_Price"]</span>
<span class="tpos-pricing-period">@L["Plan_Starter_Period"]</span>
</div>
<p class="tpos-pricing-desc">@L["Plan_Starter_Desc"]</p>
<ul class="tpos-pricing-features">
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Starter_Feature1"]</li>
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Starter_Feature2"]</li>
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Starter_Feature3"]</li>
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Starter_Feature4"]</li>
</ul>
<a href="#" class="btn-outline btn-full">@L["Plan_Starter_CTA"]</a>
</div>
<!-- Professional Plan (Featured) -->
<div class="tpos-pricing-card featured">
<div class="tpos-pricing-badge">@L["Plan_Pro_Badge"]</div>
<div class="tpos-pricing-name">@L["Plan_Pro_Name"]</div>
<div class="tpos-pricing-price">
<span class="tpos-pricing-amount">@L["Plan_Pro_Price"]</span>
<span class="tpos-pricing-period">@L["Plan_Pro_Period"]</span>
</div>
<p class="tpos-pricing-desc">@L["Plan_Pro_Desc"]</p>
<ul class="tpos-pricing-features">
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Pro_Feature1"]</li>
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Pro_Feature2"]</li>
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Pro_Feature3"]</li>
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Pro_Feature4"]</li>
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Pro_Feature5"]</li>
</ul>
<a href="#" class="btn-accent btn-full">@L["Plan_Pro_CTA"]</a>
</div>
<!-- Enterprise Plan -->
<div class="tpos-pricing-card">
<div class="tpos-pricing-badge">@L["Plan_Enterprise_Badge"]</div>
<div class="tpos-pricing-name">@L["Plan_Enterprise_Name"]</div>
<div class="tpos-pricing-price">
<span class="tpos-pricing-amount">@L["Plan_Enterprise_Price"]</span>
<span class="tpos-pricing-period">@L["Plan_Enterprise_Period"]</span>
</div>
<p class="tpos-pricing-desc">@L["Plan_Enterprise_Desc"]</p>
<ul class="tpos-pricing-features">
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Enterprise_Feature1"]</li>
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Enterprise_Feature2"]</li>
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Enterprise_Feature3"]</li>
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Enterprise_Feature4"]</li>
<li class="tpos-pricing-feature"><span class="check-icon">✓</span> @L["Plan_Enterprise_Feature5"]</li>
</ul>
<a href="#" class="btn-outline btn-full">@L["Plan_Enterprise_CTA"]</a>
</div>
</div>
<!-- Add-ons -->
<div class="tpos-addons">
<h3 class="tpos-addons-title">@L["Addons_Title"]</h3>
<div class="tpos-addons-grid">
@foreach (var addon in _addons)
{
<div class="tpos-addon-item">
<div class="tpos-addon-name">@L[addon.Name]</div>
<div class="tpos-addon-price">@L[addon.Price]</div>
</div>
}
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════════════
7. CTA SECTION
═══════════════════════════════════════════════════════════════════════ -->
<section class="tpos-cta">
<h2 class="tpos-cta-title">@L["CTA_Title"]</h2>
<p class="tpos-cta-sub">@L["CTA_Subtitle"]</p>
<div class="tpos-cta-actions">
<a href="#" class="btn-accent btn-accent-lg">@L["CTA_Primary"]</a>
<a href="#" class="btn-outline btn-outline-lg">@L["CTA_Secondary"]</a>
</div>
<p class="tpos-cta-trust">@L["CTA_Trust"]</p>
</section>
<!-- ═══════════════════════════════════════════════════════════════════════
8. FOOTER
═══════════════════════════════════════════════════════════════════════ -->
<footer class="tpos-footer">
<div class="tpos-footer-grid">
<div class="tpos-footer-brand">
<span class="tpos-logo">@L["AppName"]</span>
<p class="tpos-footer-tagline">@L["Footer_Tagline"]</p>
</div>
<div>
<div class="tpos-footer-col-title">@L["Footer_Col1_Title"]</div>
<ul class="tpos-footer-links">
<li><a href="#features" class="tpos-footer-link">@L["Footer_Col1_Link1"]</a></li>
<li><a href="#pricing" class="tpos-footer-link">@L["Footer_Col1_Link2"]</a></li>
<li><a href="#" class="tpos-footer-link">@L["Footer_Col1_Link3"]</a></li>
<li><a href="#" class="tpos-footer-link">@L["Footer_Col1_Link4"]</a></li>
</ul>
</div>
<div>
<div class="tpos-footer-col-title">@L["Footer_Col2_Title"]</div>
<ul class="tpos-footer-links">
<li><a href="#" class="tpos-footer-link">@L["Footer_Col2_Link1"]</a></li>
<li><a href="#" class="tpos-footer-link">@L["Footer_Col2_Link2"]</a></li>
<li><a href="#" class="tpos-footer-link">@L["Footer_Col2_Link3"]</a></li>
<li><a href="#" class="tpos-footer-link">@L["Footer_Col2_Link4"]</a></li>
</ul>
</div>
<div>
<div class="tpos-footer-col-title">@L["Footer_Col3_Title"]</div>
<ul class="tpos-footer-links">
<li><a href="#" class="tpos-footer-link">@L["Footer_Col3_Link1"]</a></li>
<li><a href="#" class="tpos-footer-link">@L["Footer_Col3_Link2"]</a></li>
<li><a href="#" class="tpos-footer-link">@L["Footer_Col3_Link3"]</a></li>
<li><a href="#" class="tpos-footer-link">@L["Footer_Col3_Link4"]</a></li>
</ul>
</div>
</div>
<div class="tpos-footer-copy">@L["Footer_Copyright"]</div>
</footer>
@code {
// Feature cards data
private record FeatureItem(string Icon, string Title, string Desc);
private readonly FeatureItem[] _features =
[
new("Feature_POS_Icon", "Feature_POS_Title", "Feature_POS_Desc"),
new("Feature_Loyalty_Icon", "Feature_Loyalty_Title", "Feature_Loyalty_Desc"),
new("Feature_Reports_Icon", "Feature_Reports_Title", "Feature_Reports_Desc"),
new("Feature_Staff_Icon", "Feature_Staff_Title", "Feature_Staff_Desc"),
new("Feature_Inventory_Icon", "Feature_Inventory_Title", "Feature_Inventory_Desc"),
new("Feature_Payments_Icon", "Feature_Payments_Title", "Feature_Payments_Desc"),
];
// Industry cards data
private record IndustryItem(string Title, string Desc, string Chips);
private readonly IndustryItem[] _industries =
[
new("Industry_Restaurant_Title", "Industry_Restaurant_Desc", "Industry_Restaurant_Chips"),
new("Industry_Bar_Title", "Industry_Bar_Desc", "Industry_Bar_Chips"),
new("Industry_Karaoke_Title", "Industry_Karaoke_Desc", "Industry_Karaoke_Chips"),
new("Industry_Coffee_Title", "Industry_Coffee_Desc", "Industry_Coffee_Chips"),
new("Industry_Spa_Title", "Industry_Spa_Desc", "Industry_Spa_Chips"),
new("Industry_Retail_Title", "Industry_Retail_Desc", "Industry_Retail_Chips"),
];
// Add-on modules data
private record AddonItem(string Name, string Price);
private readonly AddonItem[] _addons =
[
new("Addon_KDS_Name", "Addon_KDS_Price"),
new("Addon_Delivery_Name", "Addon_Delivery_Price"),
new("Addon_Accounting_Name", "Addon_Accounting_Price"),
new("Addon_EInvoice_Name", "Addon_EInvoice_Price"),
new("Addon_Marketing_Name", "Addon_Marketing_Price"),
new("Addon_Reservation_Name", "Addon_Reservation_Price"),
];
}

View File

@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View File

@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
using WebClientTpos.Client;
using WebClientTpos.Client.Localization;
using Microsoft.Extensions.Localization;
using System.Globalization;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
// EN: Add HttpClient for API calls
// VI: Thêm HttpClient cho các cuộc gọi API
builder.Services.AddSingleton(sp => new HttpClient { BaseAddress = new Uri(new Uri(builder.HostEnvironment.BaseAddress).GetLeftPart(UriPartial.Authority)) });
// EN: Add MudBlazor services
// VI: Thêm các services của MudBlazor
builder.Services.AddMudServices();
// Localization
builder.Services.AddLocalization();
builder.Services.AddSingleton<LocalizationCache>();
builder.Services.AddSingleton<IStringLocalizerFactory, JsonStringLocalizerFactory>();
// Build the host
var host = builder.Build();
// Initialize Localization Cache
// Initialize Localization Cache
var cache = host.Services.GetRequiredService<LocalizationCache>();
// Detect culture from BaseAddress (which is set by <base href> from Server)
var baseAddress = builder.HostEnvironment.BaseAddress;
var culture = new CultureInfo("en-US"); // Default
if (baseAddress.Contains("/vi-VN/", StringComparison.OrdinalIgnoreCase))
{
culture = new CultureInfo("vi-VN");
}
else if (baseAddress.Contains("/vi/", StringComparison.OrdinalIgnoreCase))
{
culture = new CultureInfo("vi-VN");
}
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
await cache.LoadAsync(culture);
await host.RunAsync();

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5043",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7002;http://localhost:5043",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Localization" Version="10.0.2" />
<PackageReference Include="MudBlazor" Version="8.15.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WebClientTpos.Shared\WebClientTpos.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using MudBlazor
@using WebClientTpos.Client
@using WebClientTpos.Client.Layout
@using WebClientTpos.Shared
@using WebClientTpos.Shared.DTOs
@using WebClientTpos.Client.Components
@using Microsoft.Extensions.Localization

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>aPOS - Smart POS System for Modern Business</title>
<meta name="description"
content="All-in-one point-of-sale solution with AI-powered insights, real-time analytics, and seamless multi-store management." />
<base href="/" />
<!-- EN: Google Fonts - Inter -->
<!-- VI: Google Fonts - Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<!-- EN: Material Design Icons -->
<!-- VI: Icons Material Design -->
<link
href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Two+Tone|Material+Icons+Round|Material+Icons+Sharp"
rel="stylesheet">
<!-- EN: MudBlazor CSS -->
<!-- VI: CSS MudBlazor -->
<link href="/_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<!-- EN: Custom CSS -->
<!-- VI: CSS tùy chỉnh -->
<link rel="stylesheet" href="/css/app.css" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link href="/WebClientTpos.Client.styles.css" rel="stylesheet" />
</head>
<body>
<div id="app">
<!-- EN: Loading indicator (aPOS dark theme) -->
<!-- VI: Chỉ báo đang tải (giao diện tối aPOS) -->
<div
style="display: flex; justify-content: center; align-items: center; height: 100vh; flex-direction: column; gap: 1rem; background: #0A0A0B; color: #FFFFFF;">
<svg class="loading-progress" width="48" height="48" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="32" fill="none" stroke="#1F1F23" stroke-width="6" />
<circle cx="40" cy="40" r="32" fill="none" stroke="#FF5C00" stroke-width="6" stroke-dasharray="200"
stroke-dashoffset="60" style="animation: spin 1s linear infinite; transform-origin: center;">
</circle>
</svg>
<p style="color: #ADADB0; font-family: 'Inter', sans-serif; font-size: 0.875rem; font-weight: 600;">Loading
aPOS...</p>
</div>
<style>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</div>
<div id="blazor-error-ui"
style="display: none; position: fixed; bottom: 0; left: 0; right: 0; padding: 1rem; background: #ef4444; color: #fff; text-align: center; z-index: 9999;">
An unhandled error has occurred. / Đã xảy ra lỗi.
<a href="." class="reload" style="color: #fff; text-decoration: underline; margin-left: 1rem;">Reload / Tải
lại</a>
<span class="dismiss" style="cursor: pointer; margin-left: 1rem;"></span>
</div>
<!-- EN: Blazor WebAssembly -->
<script src="/_framework/blazor.webassembly.js"></script>
<!-- EN: MudBlazor JavaScript -->
<!-- VI: JavaScript MudBlazor -->
<script src="/_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,170 @@
{
"AppName": "aPOS",
"Nav_Features": "Features",
"Nav_Industries": "Industries",
"Nav_Pricing": "Pricing",
"Nav_Contact": "Contact",
"Nav_Login": "Login",
"Nav_FreeTrial": "Start Free Trial",
"HeroBadge": "🚀 Powered by AI",
"HeroHeadline": "The Smart POS System <br /> for Modern Business",
"HeroSubtext": "All-in-one point-of-sale solution with AI-powered insights, real-time analytics, and seamless multi-store management. Built for speed, designed for growth.",
"HeroCTA_Primary": "Start Free Trial",
"HeroCTA_Secondary": "Watch Demo",
"HeroMockup_Alt": "aPOS Dashboard Preview",
"Trust_Label": "Trusted by businesses across Southeast Asia",
"Trust_Stat1": "<strong>5,000+</strong> Active Stores",
"Trust_Stat2": "<strong>10M+</strong> Transactions/month",
"Trust_Stat3": "<strong>99.9%</strong> Uptime",
"Features_Badge": "Features",
"Features_Title": "Everything You Need to Run Your Business",
"Features_Desc": "From point-of-sale to back-office management, aPOS gives you the tools to streamline operations and grow revenue.",
"Feature_POS_Title": "Smart POS",
"Feature_POS_Desc": "Lightning-fast checkout with barcode scanning, split payments, and offline mode. Process orders in seconds.",
"Feature_POS_Icon": "💳",
"Feature_Loyalty_Title": "Loyalty & CRM",
"Feature_Loyalty_Desc": "Build customer loyalty with points, rewards, and personalized campaigns. Know your customers better.",
"Feature_Loyalty_Icon": "❤️",
"Feature_Reports_Title": "Real-time Reports",
"Feature_Reports_Desc": "AI-powered analytics and dashboards. Track sales, staff performance, and inventory in real-time.",
"Feature_Reports_Icon": "📊",
"Feature_Staff_Title": "Staff Management",
"Feature_Staff_Desc": "Schedule shifts, track attendance, manage permissions, and monitor performance across all locations.",
"Feature_Staff_Icon": "👥",
"Feature_Inventory_Title": "Inventory Control",
"Feature_Inventory_Desc": "Automatic stock alerts, supplier management, and multi-warehouse tracking. Never run out of stock.",
"Feature_Inventory_Icon": "📦",
"Feature_Payments_Title": "Multi-Payment",
"Feature_Payments_Desc": "Accept cash, cards, e-wallets, QR codes, and bank transfers. All payment methods in one system.",
"Feature_Payments_Icon": "💰",
"Industries_Badge": "Industries",
"Industries_Title": "Built for Every Business Type",
"Industries_Desc": "Tailored solutions for different industries with specialized features and workflows.",
"Industry_Restaurant_Title": "Restaurant & Café",
"Industry_Restaurant_Desc": "Table management, kitchen display, split bills, and tip handling for dine-in and takeaway.",
"Industry_Restaurant_Chips": "AI Menu Suggestions, Smart Table Management",
"Industry_Bar_Title": "Bar & Nightclub",
"Industry_Bar_Desc": "Tab management, happy hour pricing, age verification, and high-speed drink ordering.",
"Industry_Bar_Chips": "AI Inventory Forecast, Tab Analytics",
"Industry_Karaoke_Title": "Karaoke & Entertainment",
"Industry_Karaoke_Desc": "Room booking, hourly billing, food & drink combo packages, and membership integration.",
"Industry_Karaoke_Chips": "AI Dynamic Pricing, Room Optimization",
"Industry_Coffee_Title": "Coffee & Bakery",
"Industry_Coffee_Desc": "Quick-serve mode, recipe management, customization modifiers, and loyalty stamp cards.",
"Industry_Coffee_Chips": "AI Order Prediction, Peak Scheduling",
"Industry_Spa_Title": "Spa & Beauty",
"Industry_Spa_Desc": "Appointment booking, therapist scheduling, service packages, and commission tracking.",
"Industry_Spa_Chips": "AI Scheduling, Customer Insights",
"Industry_Retail_Title": "Retail & Fashion",
"Industry_Retail_Desc": "Barcode management, size/color variants, multi-store inventory sync, and customer wishlists.",
"Industry_Retail_Chips": "AI Demand Forecast, Smart Reorder",
"Steps_Badge": "Get Started",
"Steps_Title": "Up and Running in 3 Simple Steps",
"Steps_Desc": "No complex setup, no lengthy contracts. Start accepting orders today.",
"Step1_Title": "Sign Up & Configure",
"Step1_Desc": "Create your account, add your menu or products, and configure your business settings in minutes.",
"Step2_Title": "Install & Connect",
"Step2_Desc": "Download aPOS on any device tablet, phone, or desktop. Connect your printers and payment terminals.",
"Step3_Title": "Start Selling",
"Step3_Desc": "Go live! Process your first transaction and watch real-time analytics flow into your dashboard.",
"Pricing_Badge": "Pricing",
"Pricing_Title": "Simple, Transparent Pricing",
"Pricing_Desc": "No hidden fees. Choose the plan that fits your business and scale as you grow.",
"Plan_Starter_Badge": "STARTER",
"Plan_Starter_Name": "STARTER",
"Plan_Starter_Price": "Free",
"Plan_Starter_Period": "",
"Plan_Starter_Desc": "Perfect for small businesses just getting started",
"Plan_Starter_Feature1": "1 Store, 1 Terminal",
"Plan_Starter_Feature2": "Basic POS & Checkout",
"Plan_Starter_Feature3": "Daily Sales Reports",
"Plan_Starter_Feature4": "Email Support",
"Plan_Starter_CTA": "Get Started Free",
"Plan_Pro_Badge": "MOST POPULAR",
"Plan_Pro_Name": "PROFESSIONAL",
"Plan_Pro_Price": "499K",
"Plan_Pro_Period": "/month",
"Plan_Pro_Desc": "For growing businesses that need more power",
"Plan_Pro_Feature1": "Up to 5 Stores, Unlimited Terminals",
"Plan_Pro_Feature2": "Full POS + Loyalty + CRM",
"Plan_Pro_Feature3": "Advanced Analytics & Reports",
"Plan_Pro_Feature4": "Staff Management",
"Plan_Pro_Feature5": "Priority Support 24/7",
"Plan_Pro_CTA": "Start Free Trial",
"Plan_Enterprise_Badge": "ENTERPRISE",
"Plan_Enterprise_Name": "ENTERPRISE",
"Plan_Enterprise_Price": "Custom",
"Plan_Enterprise_Period": "",
"Plan_Enterprise_Desc": "For chains and franchises requiring enterprise-grade features",
"Plan_Enterprise_Feature1": "Unlimited Stores & Terminals",
"Plan_Enterprise_Feature2": "White-label Solution",
"Plan_Enterprise_Feature3": "Custom Integrations & API",
"Plan_Enterprise_Feature4": "Dedicated Account Manager",
"Plan_Enterprise_Feature5": "SLA & On-site Training",
"Plan_Enterprise_CTA": "Contact Sales",
"Addons_Title": "Add-on Modules",
"Addon_KDS_Name": "Kitchen Display",
"Addon_KDS_Price": "99K/mo",
"Addon_Delivery_Name": "Delivery Integration",
"Addon_Delivery_Price": "149K/mo",
"Addon_Accounting_Name": "Accounting Sync",
"Addon_Accounting_Price": "99K/mo",
"Addon_EInvoice_Name": "E-Invoice",
"Addon_EInvoice_Price": "79K/mo",
"Addon_Marketing_Name": "Marketing Hub",
"Addon_Marketing_Price": "129K/mo",
"Addon_Reservation_Name": "Reservations",
"Addon_Reservation_Price": "99K/mo",
"CTA_Title": "Ready to Transform Your Business?",
"CTA_Subtitle": "Join 5,000+ businesses already using aPOS to grow revenue, reduce costs, and delight their customers.",
"CTA_Primary": "Start Free Trial",
"CTA_Secondary": "Talk to Sales",
"CTA_Trust": "No credit card required · Free for 14 days · Cancel anytime",
"Footer_Tagline": "The smart POS system for modern businesses across Southeast Asia.",
"Footer_Col1_Title": "Product",
"Footer_Col1_Link1": "Features",
"Footer_Col1_Link2": "Pricing",
"Footer_Col1_Link3": "Integrations",
"Footer_Col1_Link4": "Changelog",
"Footer_Col2_Title": "Resources",
"Footer_Col2_Link1": "Documentation",
"Footer_Col2_Link2": "API Reference",
"Footer_Col2_Link3": "Blog",
"Footer_Col2_Link4": "Help Center",
"Footer_Col3_Title": "Company",
"Footer_Col3_Link1": "About Us",
"Footer_Col3_Link2": "Careers",
"Footer_Col3_Link3": "Contact",
"Footer_Col3_Link4": "Partners",
"Footer_Copyright": "© 2025 aPOS by GoodGo. All rights reserved.",
"Auth_Login_Title": "Sign In",
"Auth_Login_Subtitle": "Welcome back! Please sign in to your account.",
"Auth_Login_Email": "Email",
"Auth_Login_Password": "Password",
"Auth_Login_RememberMe": "Remember me",
"Auth_Login_ForgotPassword": "Forgot password?",
"Auth_Login_Submit": "Sign In",
"Auth_Login_NoAccount": "Don't have an account?",
"Auth_Login_Register": "Create one",
"Auth_Register_Title": "Create Account",
"Auth_Register_Subtitle": "Start your free trial today. No credit card required.",
"Auth_Register_FullName": "Full Name",
"Auth_Register_Email": "Email",
"Auth_Register_Password": "Password",
"Auth_Register_ConfirmPassword": "Confirm Password",
"Auth_Register_BusinessName": "Business Name",
"Auth_Register_AgreeTerms": "I agree to the Terms of Service and Privacy Policy",
"Auth_Register_Submit": "Create Account",
"Auth_Register_HasAccount": "Already have an account?",
"Auth_Register_Login": "Sign in",
"Auth_ForgotPwd_Title": "Reset Password",
"Auth_ForgotPwd_Subtitle": "Enter your email and we'll send you a reset link.",
"Auth_ForgotPwd_Email": "Email",
"Auth_ForgotPwd_Submit": "Send Reset Link",
"Auth_ForgotPwd_BackToLogin": "Back to Sign In",
"Validation_Required": "This field is required",
"Validation_Email": "Please enter a valid email address",
"Validation_PasswordMin": "Password must be at least 8 characters",
"Validation_PasswordMatch": "Passwords do not match",
"Validation_AgreeTerms": "You must agree to the terms"
}

View File

@@ -0,0 +1,170 @@
{
"AppName": "aPOS",
"Nav_Features": "Tính năng",
"Nav_Industries": "Ngành hàng",
"Nav_Pricing": "Bảng giá",
"Nav_Contact": "Liên hệ",
"Nav_Login": "Đăng nhập",
"Nav_FreeTrial": "Dùng thử miễn phí",
"HeroBadge": "🚀 Trang bị AI",
"HeroHeadline": "Hệ thống POS Thông minh <br /> cho Doanh nghiệp Hiện đại",
"HeroSubtext": "Giải pháp bán hàng tất-cả-trong-một với phân tích AI, báo cáo thời gian thực và quản lý đa cửa hàng. Xây dựng cho tốc độ, thiết kế cho tăng trưởng.",
"HeroCTA_Primary": "Dùng thử miễn phí",
"HeroCTA_Secondary": "Xem Demo",
"HeroMockup_Alt": "Giao diện bảng điều khiển aPOS",
"Trust_Label": "Được tin dùng bởi các doanh nghiệp tại Đông Nam Á",
"Trust_Stat1": "<strong>5,000+</strong> Cửa hàng hoạt động",
"Trust_Stat2": "<strong>10M+</strong> Giao dịch/tháng",
"Trust_Stat3": "<strong>99.9%</strong> Thời gian hoạt động",
"Features_Badge": "Tính năng",
"Features_Title": "Mọi thứ bạn cần để vận hành doanh nghiệp",
"Features_Desc": "Từ quầy bán hàng đến quản lý back-office, aPOS cung cấp công cụ giúp tối ưu vận hành và tăng doanh thu.",
"Feature_POS_Title": "POS Thông minh",
"Feature_POS_Desc": "Thanh toán siêu nhanh với quét mã vạch, chia hóa đơn, và chế độ offline. Xử lý đơn hàng trong tích tắc.",
"Feature_POS_Icon": "💳",
"Feature_Loyalty_Title": "Khách hàng & CRM",
"Feature_Loyalty_Desc": "Xây dựng lòng trung thành với tích điểm, ưu đãi và chiến dịch cá nhân hóa. Hiểu khách hàng hơn.",
"Feature_Loyalty_Icon": "❤️",
"Feature_Reports_Title": "Báo cáo Thời gian thực",
"Feature_Reports_Desc": "Phân tích AI và bảng điều khiển trực quan. Theo dõi doanh số, hiệu suất nhân viên và tồn kho theo thời gian thực.",
"Feature_Reports_Icon": "📊",
"Feature_Staff_Title": "Quản lý Nhân viên",
"Feature_Staff_Desc": "Lên lịch ca, chấm công, phân quyền và giám sát hiệu suất trên tất cả chi nhánh.",
"Feature_Staff_Icon": "👥",
"Feature_Inventory_Title": "Quản lý Kho hàng",
"Feature_Inventory_Desc": "Cảnh báo tồn kho tự động, quản lý nhà cung cấp và theo dõi đa kho. Không bao giờ hết hàng.",
"Feature_Inventory_Icon": "📦",
"Feature_Payments_Title": "Đa Phương thức Thanh toán",
"Feature_Payments_Desc": "Chấp nhận tiền mặt, thẻ, ví điện tử, mã QR và chuyển khoản. Tất cả trong một hệ thống.",
"Feature_Payments_Icon": "💰",
"Industries_Badge": "Ngành hàng",
"Industries_Title": "Thiết kế cho Mọi Ngành Kinh doanh",
"Industries_Desc": "Giải pháp tùy chỉnh cho từng ngành hàng với tính năng và quy trình chuyên biệt.",
"Industry_Restaurant_Title": "Nhà hàng & Quán ăn",
"Industry_Restaurant_Desc": "Quản lý bàn, màn hình bếp, chia hóa đơn và tip cho ăn tại chỗ và mang về.",
"Industry_Restaurant_Chips": "AI Gợi ý Menu, Quản lý Bàn Thông minh",
"Industry_Bar_Title": "Bar & Nightclub",
"Industry_Bar_Desc": "Quản lý tab, giá happy hour, xác minh tuổi và gọi đồ uống tốc độ cao.",
"Industry_Bar_Chips": "AI Dự đoán Tồn kho, Phân tích Tab",
"Industry_Karaoke_Title": "Karaoke & Giải trí",
"Industry_Karaoke_Desc": "Đặt phòng, tính tiền theo giờ, combo đồ ăn uống và tích hợp thành viên.",
"Industry_Karaoke_Chips": "AI Giá Động, Tối ưu Phòng",
"Industry_Coffee_Title": "Cà phê & Bánh",
"Industry_Coffee_Desc": "Chế độ bán nhanh, quản lý công thức, tùy chỉnh topping và thẻ tích điểm.",
"Industry_Coffee_Chips": "AI Dự đoán Đơn, Lịch Giờ Cao điểm",
"Industry_Spa_Title": "Spa & Làm đẹp",
"Industry_Spa_Desc": "Đặt lịch hẹn, xếp lịch kỹ thuật viên, gói dịch vụ và theo dõi hoa hồng.",
"Industry_Spa_Chips": "AI Xếp lịch, Phân tích Khách hàng",
"Industry_Retail_Title": "Bán lẻ & Thời trang",
"Industry_Retail_Desc": "Quản lý mã vạch, biến thể size/màu, đồng bộ tồn kho đa cửa hàng và wishlist.",
"Industry_Retail_Chips": "AI Dự báo Nhu cầu, Đặt hàng Thông minh",
"Steps_Badge": "Bắt đầu",
"Steps_Title": "Hoạt động chỉ trong 3 Bước Đơn giản",
"Steps_Desc": "Không cần cài đặt phức tạp, không hợp đồng dài hạn. Bắt đầu nhận đơn ngay hôm nay.",
"Step1_Title": "Đăng ký & Cấu hình",
"Step1_Desc": "Tạo tài khoản, thêm menu hoặc sản phẩm, và cấu hình cài đặt doanh nghiệp trong vài phút.",
"Step2_Title": "Cài đặt & Kết nối",
"Step2_Desc": "Tải aPOS trên mọi thiết bị — tablet, điện thoại hoặc máy tính. Kết nối máy in và terminal thanh toán.",
"Step3_Title": "Bắt đầu Bán hàng",
"Step3_Desc": "Sẵn sàng! Xử lý giao dịch đầu tiên và xem phân tích thời gian thực trên bảng điều khiển.",
"Pricing_Badge": "Bảng giá",
"Pricing_Title": "Bảng giá Minh bạch, Đơn giản",
"Pricing_Desc": "Không phí ẩn. Chọn gói phù hợp với doanh nghiệp và mở rộng khi phát triển.",
"Plan_Starter_Badge": "KHỞI ĐẦU",
"Plan_Starter_Name": "KHỞI ĐẦU",
"Plan_Starter_Price": "Miễn phí",
"Plan_Starter_Period": "",
"Plan_Starter_Desc": "Hoàn hảo cho doanh nghiệp nhỏ mới bắt đầu",
"Plan_Starter_Feature1": "1 Cửa hàng, 1 Máy POS",
"Plan_Starter_Feature2": "POS & Thanh toán cơ bản",
"Plan_Starter_Feature3": "Báo cáo Doanh số hàng ngày",
"Plan_Starter_Feature4": "Hỗ trợ qua Email",
"Plan_Starter_CTA": "Bắt đầu Miễn phí",
"Plan_Pro_Badge": "PHỔ BIẾN NHẤT",
"Plan_Pro_Name": "CHUYÊN NGHIỆP",
"Plan_Pro_Price": "499K",
"Plan_Pro_Period": "/tháng",
"Plan_Pro_Desc": "Cho doanh nghiệp đang phát triển cần thêm sức mạnh",
"Plan_Pro_Feature1": "Tối đa 5 Cửa hàng, Không giới hạn Máy POS",
"Plan_Pro_Feature2": "Đầy đủ POS + Loyalty + CRM",
"Plan_Pro_Feature3": "Phân tích & Báo cáo Nâng cao",
"Plan_Pro_Feature4": "Quản lý Nhân viên",
"Plan_Pro_Feature5": "Hỗ trợ Ưu tiên 24/7",
"Plan_Pro_CTA": "Dùng thử Miễn phí",
"Plan_Enterprise_Badge": "DOANH NGHIỆP",
"Plan_Enterprise_Name": "DOANH NGHIỆP",
"Plan_Enterprise_Price": "Liên hệ",
"Plan_Enterprise_Period": "",
"Plan_Enterprise_Desc": "Cho chuỗi và nhượng quyền cần tính năng cấp doanh nghiệp",
"Plan_Enterprise_Feature1": "Không giới hạn Cửa hàng & Máy POS",
"Plan_Enterprise_Feature2": "Giải pháp White-label",
"Plan_Enterprise_Feature3": "Tích hợp Tùy chỉnh & API",
"Plan_Enterprise_Feature4": "Quản lý Tài khoản Chuyên trách",
"Plan_Enterprise_Feature5": "SLA & Đào tạo Tại chỗ",
"Plan_Enterprise_CTA": "Liên hệ Tư vấn",
"Addons_Title": "Module Bổ sung",
"Addon_KDS_Name": "Màn hình Bếp",
"Addon_KDS_Price": "99K/tháng",
"Addon_Delivery_Name": "Tích hợp Giao hàng",
"Addon_Delivery_Price": "149K/tháng",
"Addon_Accounting_Name": "Đồng bộ Kế toán",
"Addon_Accounting_Price": "99K/tháng",
"Addon_EInvoice_Name": "Hóa đơn Điện tử",
"Addon_EInvoice_Price": "79K/tháng",
"Addon_Marketing_Name": "Marketing Hub",
"Addon_Marketing_Price": "129K/tháng",
"Addon_Reservation_Name": "Đặt bàn",
"Addon_Reservation_Price": "99K/tháng",
"CTA_Title": "Sẵn sàng Chuyển đổi Doanh nghiệp?",
"CTA_Subtitle": "Tham gia cùng 5,000+ doanh nghiệp đang sử dụng aPOS để tăng doanh thu, giảm chi phí và làm hài lòng khách hàng.",
"CTA_Primary": "Dùng thử Miễn phí",
"CTA_Secondary": "Nói chuyện với Tư vấn",
"CTA_Trust": "Không cần thẻ tín dụng · Miễn phí 14 ngày · Hủy bất cứ lúc nào",
"Footer_Tagline": "Hệ thống POS thông minh cho doanh nghiệp hiện đại tại Đông Nam Á.",
"Footer_Col1_Title": "Sản phẩm",
"Footer_Col1_Link1": "Tính năng",
"Footer_Col1_Link2": "Bảng giá",
"Footer_Col1_Link3": "Tích hợp",
"Footer_Col1_Link4": "Nhật ký Thay đổi",
"Footer_Col2_Title": "Tài nguyên",
"Footer_Col2_Link1": "Tài liệu Hướng dẫn",
"Footer_Col2_Link2": "Tài liệu API",
"Footer_Col2_Link3": "Blog",
"Footer_Col2_Link4": "Trung tâm Trợ giúp",
"Footer_Col3_Title": "Công ty",
"Footer_Col3_Link1": "Về Chúng tôi",
"Footer_Col3_Link2": "Tuyển dụng",
"Footer_Col3_Link3": "Liên hệ",
"Footer_Col3_Link4": "Đối tác",
"Footer_Copyright": "© 2025 aPOS bởi GoodGo. Bảo lưu mọi quyền.",
"Auth_Login_Title": "Đăng nhập",
"Auth_Login_Subtitle": "Chào mừng trở lại! Vui lòng đăng nhập vào tài khoản.",
"Auth_Login_Email": "Email",
"Auth_Login_Password": "Mật khẩu",
"Auth_Login_RememberMe": "Ghi nhớ đăng nhập",
"Auth_Login_ForgotPassword": "Quên mật khẩu?",
"Auth_Login_Submit": "Đăng nhập",
"Auth_Login_NoAccount": "Chưa có tài khoản?",
"Auth_Login_Register": "Tạo tài khoản",
"Auth_Register_Title": "Tạo Tài khoản",
"Auth_Register_Subtitle": "Bắt đầu dùng thử miễn phí. Không cần thẻ tín dụng.",
"Auth_Register_FullName": "Họ và Tên",
"Auth_Register_Email": "Email",
"Auth_Register_Password": "Mật khẩu",
"Auth_Register_ConfirmPassword": "Xác nhận Mật khẩu",
"Auth_Register_BusinessName": "Tên Doanh nghiệp",
"Auth_Register_AgreeTerms": "Tôi đồng ý với Điều khoản Dịch vụ và Chính sách Bảo mật",
"Auth_Register_Submit": "Tạo Tài khoản",
"Auth_Register_HasAccount": "Đã có tài khoản?",
"Auth_Register_Login": "Đăng nhập",
"Auth_ForgotPwd_Title": "Đặt lại Mật khẩu",
"Auth_ForgotPwd_Subtitle": "Nhập email của bạn để nhận liên kết đặt lại mật khẩu.",
"Auth_ForgotPwd_Email": "Email",
"Auth_ForgotPwd_Submit": "Gửi liên kết Đặt lại",
"Auth_ForgotPwd_BackToLogin": "Quay lại Đăng nhập",
"Validation_Required": "Trường này là bắt buộc",
"Validation_Email": "Vui lòng nhập địa chỉ email hợp lệ",
"Validation_PasswordMin": "Mật khẩu phải có ít nhất 8 ký tự",
"Validation_PasswordMatch": "Mật khẩu không khớp",
"Validation_AgreeTerms": "Bạn phải đồng ý với các điều khoản"
}

View File

@@ -0,0 +1,27 @@
[
{
"date": "2022-01-06",
"temperatureC": 1,
"summary": "Freezing"
},
{
"date": "2022-01-07",
"temperatureC": 14,
"summary": "Bracing"
},
{
"date": "2022-01-08",
"temperatureC": -13,
"summary": "Freezing"
},
{
"date": "2022-01-09",
"temperatureC": -16,
"summary": "Balmy"
},
{
"date": "2022-01-10",
"temperatureC": -2,
"summary": "Chilly"
}
]

View File

@@ -0,0 +1,123 @@
/// <summary>
/// EN: ASP.NET Core BFF (Backend for Frontend) with YARP Reverse Proxy.
/// VI: ASP.NET Core BFF (Backend for Frontend) với YARP Reverse Proxy.
/// </summary>
using Microsoft.AspNetCore.Rewrite;
var builder = WebApplication.CreateBuilder(args);
// ═══════════════════════════════════════════════════════════════════════════════
// EN: Add services to the container
// VI: Thêm các services vào container
// ═══════════════════════════════════════════════════════════════════════════════
// EN: Load YARP configuration from yarp.json
// VI: Load cấu hình YARP từ yarp.json
builder.Configuration.AddJsonFile("yarp.json", optional: false, reloadOnChange: true);
// EN: Add YARP Reverse Proxy
// VI: Thêm YARP Reverse Proxy
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
// EN: Add OpenAPI/Swagger support
// VI: Thêm hỗ trợ OpenAPI/Swagger
builder.Services.AddOpenApi();
// EN: Add CORS for Blazor WebAssembly client
// VI: Thêm CORS cho Blazor WebAssembly client
builder.Services.AddCors(options =>
{
options.AddPolicy("BlazorClient", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// EN: Add health checks
// VI: Thêm health checks
builder.Services.AddHealthChecks();
var app = builder.Build();
// ═══════════════════════════════════════════════════════════════════════════════
// EN: Configure the HTTP request pipeline
// VI: Cấu hình HTTP request pipeline
// ═══════════════════════════════════════════════════════════════════════════════
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
// EN: Enable CORS
// VI: Kích hoạt CORS
// EN: Enable CORS
// VI: Kích hoạt CORS
app.UseCors("BlazorClient");
// EN: Rewrite localized framework/content requests to root
// VI: Viết lại các yêu cầu framework/content từ đường dẫn ngôn ngữ về root
var rewriteOptions = new RewriteOptions()
.AddRewrite(@"^(en-US|vi-VN)/(_framework|_content)/(.*)", "$2/$3", skipRemainingRules: true);
app.UseRewriter(rewriteOptions);
// EN: Serve static files with fingerprinting support (.NET 10+)
// VI: Phục vụ static files với hỗ trợ fingerprinting (.NET 10+)
app.MapStaticAssets();
// EN: Map health check endpoint
// VI: Map endpoint health check
app.MapHealthChecks("/health");
// EN: Map YARP Reverse Proxy routes to microservices
// VI: Map các routes YARP Reverse Proxy đến microservices
app.MapReverseProxy();
// EN: Localization Support - Serve index.html with dynamic base tag for specific cultures
// VI: Hỗ trợ đa ngôn ngữ - Phục vụ index.html với base tag động cho các ngôn ngữ cụ thể
var supportedCultures = new[] { "en-US", "vi-VN" };
var localizationOptions = new RequestLocalizationOptions()
.SetDefaultCulture("en-US")
.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures);
app.UseRequestLocalization(localizationOptions);
// Handle mapped culture routes (e.g. /en-US/home, /vi-VN/solutions)
app.Map("{culture:regex(^(en-US|vi-VN)$)}/{**slug}", async (string culture, HttpContext context, IWebHostEnvironment env) =>
{
// Try to find index.html
var fileInfo = env.WebRootFileProvider.GetFileInfo("index.html");
if (!fileInfo.Exists)
{
// In Development with Hosted Blazor, index.html might not be in Server's wwwroot strictly directly depending on setup,
// but typically it is served via StaticFiles/BlazorFrameworkFiles.
// If we can't find it easily via IWebHostEnvironment in Dev, we might fail.
// However, for this task let's assume standard structure or handle gracefully.
return Results.NotFound("index.html not found in wwwroot. Ensure the Client project is built.");
}
using var stream = fileInfo.CreateReadStream();
using var reader = new StreamReader(stream);
var html = await reader.ReadToEndAsync();
// Replace base tag: <base href="/" /> -> <base href="/vi-VN/" />
// Be robust with spaces or standard format
var modifiedHtml = html.Replace("<base href=\"/\" />", $"<base href=\"/{culture}/\" />")
.Replace("<base href=\"/\"/>", $"<base href=\"/{culture}/\" />");
return Results.Content(modifiedHtml, "text/html");
});
// EN: Fallback to index.html for SPA routing (default culture)
// VI: Fallback đến index.html cho SPA routing (ngôn ngữ mặc định)
app.MapFallbackToFile("index.html");
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5092",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7228;http://localhost:5092",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WebClientTpos.Shared\WebClientTpos.Shared.csproj" />
<ProjectReference Include="..\WebClientTpos.Client\WebClientTpos.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@WebClientTpos.Server_HostAddress = http://localhost:5091
GET {{WebClientTpos.Server_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,91 @@
{
"ReverseProxy": {
"Routes": {
"iam-route": {
"ClusterId": "iam-cluster",
"Match": {
"Path": "/api/iam/{**catch-all}"
},
"Transforms": [
{
"PathRemovePrefix": "/api/iam"
}
]
},
"auth-route": {
"ClusterId": "iam-cluster",
"Match": {
"Path": "/api/auth/{**catch-all}"
},
"Transforms": [
{
"PathPattern": "/api/v1/auth/{**catch-all}"
}
]
},
"merchant-route": {
"ClusterId": "merchant-cluster",
"Match": {
"Path": "/api/merchants/{**catch-all}"
},
"Transforms": [
{
"PathRemovePrefix": "/api/merchants"
}
]
},
"catalog-route": {
"ClusterId": "catalog-cluster",
"Match": {
"Path": "/api/catalog/{**catch-all}"
},
"Transforms": [
{
"PathRemovePrefix": "/api/catalog"
}
]
},
"order-route": {
"ClusterId": "order-cluster",
"Match": {
"Path": "/api/orders/{**catch-all}"
},
"Transforms": [
{
"PathRemovePrefix": "/api/orders"
}
]
}
},
"Clusters": {
"iam-cluster": {
"Destinations": {
"destination1": {
"Address": "http://localhost:5101"
}
}
},
"merchant-cluster": {
"Destinations": {
"destination1": {
"Address": "http://localhost:5102"
}
}
},
"catalog-cluster": {
"Destinations": {
"destination1": {
"Address": "http://localhost:5103"
}
}
},
"order-cluster": {
"Destinations": {
"destination1": {
"Address": "http://localhost:5104"
}
}
}
}
}
}

View File

@@ -0,0 +1,58 @@
namespace WebClientTpos.Shared;
/// <summary>
/// EN: Standard API response wrapper for consistent response format.
/// VI: Wrapper response API chuẩn cho định dạng response nhất quán.
/// </summary>
/// <typeparam name="T">The type of data in the response.</typeparam>
public class ApiResponse<T>
{
/// <summary>
/// EN: Indicates if the request was successful.
/// VI: Cho biết request có thành công không.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// EN: The response data.
/// VI: Dữ liệu response.
/// </summary>
public T? Data { get; set; }
/// <summary>
/// EN: Error message if request failed.
/// VI: Thông báo lỗi nếu request thất bại.
/// </summary>
public string? Error { get; set; }
/// <summary>
/// EN: Creates a successful response with data.
/// VI: Tạo response thành công với dữ liệu.
/// </summary>
public static ApiResponse<T> Ok(T data) => new() { Success = true, Data = data };
/// <summary>
/// EN: Creates a failed response with error message.
/// VI: Tạo response thất bại với thông báo lỗi.
/// </summary>
public static ApiResponse<T> Fail(string error) => new() { Success = false, Error = error };
}
/// <summary>
/// EN: Non-generic API response for operations without return data.
/// VI: API response không generic cho các operation không có dữ liệu trả về.
/// </summary>
public class ApiResponse : ApiResponse<object>
{
/// <summary>
/// EN: Creates a successful response.
/// VI: Tạo response thành công.
/// </summary>
public static new ApiResponse Ok() => new() { Success = true };
/// <summary>
/// EN: Creates a failed response with error message.
/// VI: Tạo response thất bại với thông báo lỗi.
/// </summary>
public static new ApiResponse Fail(string error) => new() { Success = false, Error = error };
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace WebClientTpos.Shared.DTOs;
/// <summary>
/// EN: Change password DTO for authenticated users.
/// VI: DTO cho đổi mật khẩu của người dùng đã xác thực.
/// </summary>
public class ChangePasswordDto
{
[Required(ErrorMessage = "Current password is required")]
public string CurrentPassword { get; set; } = string.Empty;
[Required(ErrorMessage = "New password is required")]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\da-zA-Z]).+$",
ErrorMessage = "Password must contain uppercase, lowercase, digit, and special character")]
public string NewPassword { get; set; } = string.Empty;
[Required(ErrorMessage = "Confirm password is required")]
[Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")]
public string ConfirmPassword { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace WebClientTpos.Shared.DTOs;
/// <summary>
/// EN: Forgot password request DTO.
/// VI: DTO cho yêu cầu quên mật khẩu.
/// </summary>
public class ForgotPasswordDto
{
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "Invalid email address")]
public string Email { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,72 @@
using System.ComponentModel.DataAnnotations;
namespace WebClientTpos.Shared.DTOs;
/// <summary>
/// EN: Product data transfer object with validation.
/// VI: DTO sản phẩm với validation.
/// </summary>
/// <remarks>
/// EN: This DTO is shared between Client and Server for consistent validation.
/// VI: DTO này được chia sẻ giữa Client và Server để validation nhất quán.
/// </remarks>
public class ProductDto
{
/// <summary>
/// EN: Product name, required with length constraints.
/// VI: Tên sản phẩm, bắt buộc với ràng buộc độ dài.
/// </summary>
[Required(ErrorMessage = "Tên sản phẩm là bắt buộc / Name is required")]
[StringLength(100, MinimumLength = 3, ErrorMessage = "Tên phải từ 3-100 ký tự / Name must be 3-100 chars")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// EN: Product description.
/// VI: Mô tả sản phẩm.
/// </summary>
[StringLength(500, ErrorMessage = "Mô tả tối đa 500 ký tự / Max 500 characters")]
public string? Description { get; set; }
/// <summary>
/// EN: Product price, must be positive.
/// VI: Giá sản phẩm, phải dương.
/// </summary>
[Required(ErrorMessage = "Giá là bắt buộc / Price is required")]
[Range(0.01, 1_000_000_000, ErrorMessage = "Giá phải từ 0.01 đến 1 tỷ / Price must be 0.01 to 1B")]
public decimal Price { get; set; }
/// <summary>
/// EN: Stock quantity.
/// VI: Số lượng tồn kho.
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "Số lượng không âm / Quantity must be non-negative")]
public int Quantity { get; set; }
}
/// <summary>
/// EN: Create product request DTO.
/// VI: DTO request tạo sản phẩm.
/// </summary>
public class CreateProductDto : ProductDto
{
/// <summary>
/// EN: Product category ID.
/// VI: ID danh mục sản phẩm.
/// </summary>
[Required(ErrorMessage = "Danh mục là bắt buộc / Category is required")]
public Guid CategoryId { get; set; }
}
/// <summary>
/// EN: Update product request DTO.
/// VI: DTO request cập nhật sản phẩm.
/// </summary>
public class UpdateProductDto : ProductDto
{
/// <summary>
/// EN: Product ID.
/// VI: ID sản phẩm.
/// </summary>
[Required]
public Guid Id { get; set; }
}

View File

@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace WebClientTpos.Shared.DTOs;
/// <summary>
/// EN: Reset password DTO.
/// VI: DTO cho đặt lại mật khẩu.
/// </summary>
public class ResetPasswordDto
{
[Required(ErrorMessage = "Token is required")]
public string Token { get; set; } = string.Empty;
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "Invalid email address")]
public string Email { get; set; } = string.Empty;
[Required(ErrorMessage = "Password is required")]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\da-zA-Z]).+$",
ErrorMessage = "Password must contain uppercase, lowercase, digit, and special character")]
public string NewPassword { get; set; } = string.Empty;
[Required(ErrorMessage = "Confirm password is required")]
[Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")]
public string ConfirmPassword { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,93 @@
using System.ComponentModel.DataAnnotations;
namespace WebClientTpos.Shared.DTOs;
/// <summary>
/// EN: User registration DTO with validation.
/// VI: DTO đăng ký user với validation.
/// </summary>
public class RegisterDto
{
/// <summary>
/// EN: User email address.
/// VI: Địa chỉ email user.
/// </summary>
[Required(ErrorMessage = "Email là bắt buộc / Email is required")]
[EmailAddress(ErrorMessage = "Email không hợp lệ / Invalid email format")]
public string Email { get; set; } = string.Empty;
/// <summary>
/// EN: User password with strength requirements.
/// VI: Mật khẩu user với yêu cầu độ mạnh.
/// </summary>
[Required(ErrorMessage = "Mật khẩu là bắt buộc / Password is required")]
[StringLength(100, MinimumLength = 8, ErrorMessage = "Mật khẩu phải từ 8-100 ký tự / Password must be 8-100 chars")]
[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$",
ErrorMessage = "Mật khẩu phải chữ hoa, chữ thường số / Password must have upper, lower and digit")]
public string Password { get; set; } = string.Empty;
/// <summary>
/// EN: Password confirmation must match.
/// VI: Xác nhận mật khẩu phải khớp.
/// </summary>
[Required(ErrorMessage = "Xác nhận mật khẩu là bắt buộc / Confirm password is required")]
[Compare(nameof(Password), ErrorMessage = "Mật khẩu không khớp / Passwords do not match")]
public string ConfirmPassword { get; set; } = string.Empty;
/// <summary>
/// EN: User display name.
/// VI: Tên hiển thị của user.
/// </summary>
[Required(ErrorMessage = "Tên là bắt buộc / Name is required")]
[StringLength(50, MinimumLength = 2, ErrorMessage = "Tên phải từ 2-50 ký tự / Name must be 2-50 chars")]
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// EN: Accept terms of service.
/// VI: Chấp nhận điều khoản dịch vụ.
/// </summary>
[Range(typeof(bool), "true", "true", ErrorMessage = "Bạn phải chấp nhận Điều khoản dịch vụ / You must accept the Terms of Service")]
public bool AcceptTerms { get; set; }
}
/// <summary>
/// EN: User login DTO.
/// VI: DTO đăng nhập user.
/// </summary>
public class LoginDto
{
/// <summary>
/// EN: User email address.
/// VI: Địa chỉ email user.
/// </summary>
[Required(ErrorMessage = "Email là bắt buộc / Email is required")]
[EmailAddress(ErrorMessage = "Email không hợp lệ / Invalid email format")]
public string Email { get; set; } = string.Empty;
/// <summary>
/// EN: User password.
/// VI: Mật khẩu user.
/// </summary>
[Required(ErrorMessage = "Mật khẩu là bắt buộc / Password is required")]
public string Password { get; set; } = string.Empty;
/// <summary>
/// EN: Remember me option.
/// VI: Tùy chọn ghi nhớ đăng nhập.
/// </summary>
public bool RememberMe { get; set; }
}
/// <summary>
/// EN: User profile DTO.
/// VI: DTO hồ sơ user.
/// </summary>
public class UserProfileDto
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? AvatarUrl { get; set; }
public DateTime CreatedAt { get; set; }
public bool EmailVerified { get; set; }
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>