feat: Set up initial WebClientTpos .NET project structure, including client, server, assets, and documentation.
This commit is contained in:
66
apps/web-client-tpos-net/Dockerfile
Normal file
66
apps/web-client-tpos-net/Dockerfile
Normal 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"]
|
||||
79
apps/web-client-tpos-net/README.md
Normal file
79
apps/web-client-tpos-net/README.md
Normal 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/).
|
||||
7
apps/web-client-tpos-net/WebClientTpos.slnx
Normal file
7
apps/web-client-tpos-net/WebClientTpos.slnx
Normal 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>
|
||||
99
apps/web-client-tpos-net/docs/en/ARCHITECTURE.md
Normal file
99
apps/web-client-tpos-net/docs/en/ARCHITECTURE.md
Normal 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
|
||||
65
apps/web-client-tpos-net/docs/en/README.md
Normal file
65
apps/web-client-tpos-net/docs/en/README.md
Normal 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
|
||||
99
apps/web-client-tpos-net/docs/vi/ARCHITECTURE.md
Normal file
99
apps/web-client-tpos-net/docs/vi/ARCHITECTURE.md
Normal 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ẻ
|
||||
65
apps/web-client-tpos-net/docs/vi/README.md
Normal file
65
apps/web-client-tpos-net/docs/vi/README.md
Normal 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
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
52
apps/web-client-tpos-net/src/WebClientTpos.Client/Program.cs
Normal file
52
apps/web-client-tpos-net/src/WebClientTpos.Client/Program.cs
Normal 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();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
123
apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs
Normal file
123
apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs
Normal 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();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
@WebClientTpos.Server_HostAddress = http://localhost:5091
|
||||
|
||||
GET {{WebClientTpos.Server_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
91
apps/web-client-tpos-net/src/WebClientTpos.Server/yarp.json
Normal file
91
apps/web-client-tpos-net/src/WebClientTpos.Server/yarp.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 có chữ hoa, chữ thường và 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; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user