fix(frontend): resolve 4 P2 architecture issues (Wave 3)

FRONT-I-01: Extract Auth components to Razor Class Library packages/blazor-ui/
- Created GoodGo.BlazorUi RCL (net10.0, MudBlazor 8.15) at packages/blazor-ui/
- Moved AuthButton, AuthCard, AuthInput, OtpInput, BrandPanel, SocialLogin, LanguageSwitcher
- Referenced RCL from WebClientTpos.Client via ProjectReference
- Added GoodGo.BlazorUi.Components.Auth/Common namespaces to _Imports.razor

FRONT-I-02: Add ARIA/accessibility attributes (WCAG 2.1 AA)
- AuthButton: aria-label, aria-busy, aria-disabled, aria-hidden on decorative icons
- OtpInput: role=group, aria-label per digit, autocomplete=one-time-code
- PosLayout: aria-expanded + aria-controls on sidebar/order toggles, aria-label on all icon buttons

FRONT-I-03: Implement Style Dictionary design token pipeline
- Created packages/design-tokens/ with token JSON (color, spacing, typography, border)
- Style Dictionary config outputs: CSS custom properties → wwwroot/css/tokens.generated.css
- Second output: C# constants → packages/blazor-ui/DesignTokens/DesignTokens.g.cs
- Added tokens:build script to root package.json
- Added tokens.generated.css link to index.html (before app.css for cascade correctness)

FRONT-I-04: Replace eval() in OtpInput with safe JS interop
- Created wwwroot/js/otp-input.js with window.focusOtpInput(index) helper
- Replaced JS.InvokeVoidAsync("eval", ...) with JS.InvokeVoidAsync("focusOtpInput", index)
- Eliminates CSP-violating eval(), improves security and debuggability

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-03-23 09:50:13 +07:00
parent 97b54ebd39
commit af0461f233
21 changed files with 350 additions and 18 deletions

View File

@@ -23,8 +23,12 @@
<header class="pos-status-bar">
<div class="pos-status-bar__left">
@* EN: Hamburger menu — visible on tablet/mobile only / VI: Menu hamburger — chi hien thi tren tablet/mobile *@
<button class="pos-mobile-toggle" @onclick="ToggleSidebar" title="Menu">
<i data-lucide="@(_sidebarOpen ? "x" : "menu")" style="width:20px;height:20px;"></i>
<button class="pos-mobile-toggle"
@onclick="ToggleSidebar"
aria-label="@(_sidebarOpen ? "Đóng menu" : "Mở menu")"
aria-expanded="@(_sidebarOpen ? "true" : "false")"
aria-controls="pos-sidebar">
<i data-lucide="@(_sidebarOpen ? "x" : "menu")" style="width:20px;height:20px;" aria-hidden="true"></i>
</button>
<span class="pos-status-bar__logo">GoodGo POS</span>
<span class="pos-status-bar__store">@StoreName</span>
@@ -37,15 +41,19 @@
<span class="pos-status-bar__time">@_currentTime</span>
@* EN: Order panel toggle — visible on tablet/mobile when order panel is hidden
VI: Nút mở panel đơn hàng — hiện trên tablet/mobile khi panel đơn hàng ẩn *@
<button class="pos-order-toggle" @onclick="ToggleOrderPanel" title="Đơn hàng">
<i data-lucide="shopping-cart" style="width:18px;height:18px;"></i>
<button class="pos-order-toggle"
@onclick="ToggleOrderPanel"
aria-label="@(_orderCount > 0 ? $"Đơn hàng ({_orderCount})" : "Đơn hàng")"
aria-expanded="@(_orderPanelOpen ? "true" : "false")"
aria-controls="pos-order-drawer-content">
<i data-lucide="shopping-cart" style="width:18px;height:18px;" aria-hidden="true"></i>
@if (_orderCount > 0)
{
<span class="pos-order-toggle__badge">@_orderCount</span>
<span class="pos-order-toggle__badge" aria-hidden="true">@_orderCount</span>
}
</button>
<button class="admin-icon-btn pos-admin-btn" @onclick="GoToPortal" title="Quản lý">
<i data-lucide="settings"></i>
<button class="admin-icon-btn pos-admin-btn" @onclick="GoToPortal" aria-label="Quản lý">
<i data-lucide="settings" aria-hidden="true"></i>
</button>
</div>
</header>
@@ -60,11 +68,13 @@
@* EN: Sidebar navigation — collapsible on tablet/mobile
VI: Sidebar điều hướng — thu gọn trên tablet/mobile *@
<nav class="pos-sidebar @(_sidebarOpen ? "pos-sidebar--open" : "")">
<nav id="pos-sidebar"
class="pos-sidebar @(_sidebarOpen ? "pos-sidebar--open" : "")"
aria-label="POS navigation">
<div class="pos-sidebar__header">
<span style="font-size:15px;font-weight:700;color:var(--pos-orange-primary);">Menu</span>
<button class="pos-sidebar__close" @onclick="CloseSidebar">
<i data-lucide="x" style="width:18px;height:18px;"></i>
<button class="pos-sidebar__close" @onclick="CloseSidebar" aria-label="Đóng menu">
<i data-lucide="x" style="width:18px;height:18px;" aria-hidden="true"></i>
</button>
</div>
<div class="pos-sidebar__nav">
@@ -113,8 +123,8 @@
<div class="pos-order-drawer @(_orderPanelOpen ? "pos-order-drawer--open" : "")">
<div class="pos-order-drawer__header">
<span style="font-size:15px;font-weight:700;">Đơn hàng</span>
<button class="pos-order-drawer__close" @onclick="CloseOrderPanel">
<i data-lucide="x" style="width:18px;height:18px;"></i>
<button class="pos-order-drawer__close" @onclick="CloseOrderPanel" aria-label="Đóng panel đơn hàng">
<i data-lucide="x" style="width:18px;height:18px;" aria-hidden="true"></i>
</button>
</div>
<div class="pos-order-drawer__content" id="pos-order-drawer-content">

View File

@@ -17,6 +17,11 @@
<ItemGroup>
<ProjectReference Include="..\WebClientTpos.Shared\WebClientTpos.Shared.csproj" />
<!--
EN: Shared Blazor UI component library (Auth components, LanguageSwitcher, etc.)
VI: Thư viện component Blazor dùng chung (Auth components, LanguageSwitcher, v.v.)
-->
<ProjectReference Include="..\..\..\..\packages\blazor-ui\GoodGo.BlazorUi.csproj" />
</ItemGroup>
</Project>

View File

@@ -13,4 +13,6 @@
@using WebClientTpos.Shared.DTOs
@using WebClientTpos.Client.Components
@using Microsoft.Extensions.Localization
@using GoodGo.BlazorUi.Components.Auth
@using GoodGo.BlazorUi.Components.Common

View File

@@ -55,6 +55,14 @@
<!-- VI: CSS MudBlazor -->
<link href="/_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<!--
EN: Design tokens (auto-generated by Style Dictionary — run `pnpm --filter @goodgo/design-tokens build`).
Must be loaded FIRST so downstream CSS can reference --gds-* custom properties.
VI: Design tokens (tự động tạo bởi Style Dictionary — chạy `pnpm --filter @goodgo/design-tokens build`).
Phải load ĐẦU TIÊN để CSS downstream có thể tham chiếu --gds-* custom properties.
-->
<link rel="stylesheet" href="/css/tokens.generated.css" />
<!-- EN: Custom CSS -->
<!-- VI: CSS tùy chỉnh -->
<link rel="stylesheet" href="/css/app.css" />
@@ -119,6 +127,10 @@
<!-- EN: POS helpers (receipt printing, etc.) -->
<!-- VI: Hàm hỗ trợ POS (in hóa đơn, v.v.) -->
<script src="/js/pos-helpers.js"></script>
<!-- EN: OTP input focus helpers (safe JS interop, no eval) -->
<!-- VI: Hàm hỗ trợ focus OTP input (JS interop an toàn, không dùng eval) -->
<script src="/js/otp-input.js"></script>
</body>
</html>

View File

@@ -0,0 +1,18 @@
/**
* EN: OTP input focus helpers — safe replacement for eval()-based focus management.
* VI: Hàm hỗ trợ focus OTP input — thay thế an toàn cho cách dùng eval() để quản lý focus.
*
* Used by OtpInput.razor via IJSRuntime.InvokeVoidAsync.
*/
/**
* EN: Focus the OTP input at the given index.
* VI: Focus vào ô OTP tại vị trí chỉ định.
* @param {number} index - Zero-based index of the OTP input to focus
*/
window.focusOtpInput = function (index) {
var el = document.getElementById('otp-' + index);
if (el) {
el.focus();
}
};

View File

@@ -25,6 +25,7 @@
"scripts": {
"dev": "pnpm --parallel -r dev",
"build": "pnpm -r build",
"tokens:build": "pnpm --filter @goodgo/design-tokens build",
"test": "pnpm -r --filter='!@goodgo/service-template' test",
"lint": "pnpm -r lint",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",

View File

@@ -5,15 +5,18 @@
<button type="@ButtonType"
class="auth-btn auth-btn--@Variant @CssClass"
disabled="@Disabled"
disabled="@(Disabled || Loading)"
aria-label="@AriaLabel"
aria-busy="@(Loading ? "true" : "false")"
aria-disabled="@((Disabled || Loading) ? "true" : "false")"
@onclick="OnClick">
@if (Loading)
{
<span class="spinner-small"></span>
<span class="spinner-small" aria-hidden="true"></span>
}
@if (IconName != null)
{
<i data-lucide="@IconName"></i>
<i data-lucide="@IconName" aria-hidden="true"></i>
}
@ChildContent
</button>
@@ -61,6 +64,12 @@
/// </summary>
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
/// <summary>
/// EN: Accessible label for screen readers (required when button has no visible text).
/// VI: Nhãn accessible cho screen reader (bắt buộc khi button không có text hiển thị).
/// </summary>
[Parameter] public string? AriaLabel { get; set; }
/// <summary>
/// EN: Button content.
/// VI: Nội dung button.

View File

@@ -5,7 +5,9 @@
@inject IJSRuntime JS
<div class="auth-otp-group">
<div class="auth-otp-group"
role="group"
aria-label="@GroupAriaLabel">
@for (int i = 0; i < DigitCount; i++)
{
var index = i;
@@ -13,6 +15,8 @@
type="text"
inputmode="numeric"
maxlength="1"
autocomplete="one-time-code"
aria-label="@($"Digit {index + 1} of {DigitCount}")"
class="auth-otp-input @(UseBlueTheme ? "auth-otp-input--blue" : "") @(!string.IsNullOrEmpty(_digits[index]) ? "auth-otp-input--filled" : "")"
value="@_digits[index]"
@oninput="(e) => HandleInput(e, index)"
@@ -35,6 +39,12 @@
/// </summary>
[Parameter] public bool UseBlueTheme { get; set; }
/// <summary>
/// EN: Accessible group label for screen readers.
/// VI: Nhãn nhóm accessible cho screen reader.
/// </summary>
[Parameter] public string GroupAriaLabel { get; set; } = "Enter OTP code";
/// <summary>
/// EN: Callback when all digits are entered.
/// VI: Callback khi tất cả chữ số được nhập.
@@ -64,7 +74,7 @@
{
// EN: Auto-advance to next input
// VI: Tự động chuyển sang ô tiếp theo
await JS.InvokeVoidAsync("eval", $"document.getElementById('otp-{index + 1}')?.focus()");
await JS.InvokeVoidAsync("focusOtpInput", index + 1);
}
// EN: Check if all digits are filled
@@ -83,7 +93,7 @@
// EN: Move to previous input on backspace
// VI: Chuyển về ô trước khi nhấn backspace
_digits[index - 1] = "";
await JS.InvokeVoidAsync("eval", $"document.getElementById('otp-{index - 1}')?.focus()");
await JS.InvokeVoidAsync("focusOtpInput", index - 1);
}
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<!--
EN: Razor Class Library — shared Blazor UI components for GoodGo platform.
Consumed by web-client-tpos-net and web-client-base-net.
VI: Razor Class Library — thư viện component Blazor dùng chung cho nền tảng GoodGo.
Được dùng bởi web-client-tpos-net và web-client-base-net.
-->
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AddRazorSupportForMvc>false</AddRazorSupportForMvc>
<AssemblyName>GoodGo.BlazorUi</AssemblyName>
<RootNamespace>GoodGo.BlazorUi</RootNamespace>
<Version>1.0.0</Version>
<Description>Shared Blazor UI component library for GoodGo platform</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Localization.Abstractions" Version="10.0.2" />
<PackageReference Include="MudBlazor" Version="8.15.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using MudBlazor
@using GoodGo.BlazorUi
@using GoodGo.BlazorUi.Components.Auth
@using GoodGo.BlazorUi.Components.Common

View File

@@ -0,0 +1,14 @@
{
"name": "@goodgo/design-tokens",
"version": "1.0.0",
"description": "GoodGo design token pipeline — single source of truth for CSS vars and C# constants",
"private": true,
"type": "module",
"scripts": {
"build": "style-dictionary build --config style-dictionary.config.mjs",
"watch": "node --watch-path=./tokens style-dictionary.config.mjs"
},
"devDependencies": {
"style-dictionary": "^4.3.0"
}
}

View File

@@ -0,0 +1,109 @@
/**
* EN: Style Dictionary configuration for GoodGo design token pipeline.
* Transforms token JSON → CSS custom properties + C# constants.
* Single source of truth: packages/design-tokens/tokens/*.json
*
* VI: Cấu hình Style Dictionary cho pipeline design token GoodGo.
* Chuyển đổi JSON token → CSS custom properties + C# constants.
* Nguồn duy nhất: packages/design-tokens/tokens/*.json
*
* Usage: node --input-type=module style-dictionary.config.mjs
* Or: npx style-dictionary build --config style-dictionary.config.mjs
*/
import StyleDictionary from 'style-dictionary';
// ─── Custom transform: camelCase → kebab-case for CSS custom properties ───────
StyleDictionary.registerTransform({
name: 'name/goodgo/css',
type: 'name',
transform: (token) => {
// Flatten path, convert camelCase to kebab-case, join with '-'
return token.path
.map((part) => part.replace(/([A-Z])/g, '-$1').toLowerCase())
.join('-');
},
});
// ─── Custom formatter: C# static class with const string fields ───────────────
StyleDictionary.registerFormat({
name: 'csharp/goodgo-tokens',
format: ({ dictionary }) => {
const indent = ' ';
const lines = dictionary.allTokens.map((token) => {
const name = token.path
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join('');
const comment = token.comment ? ` // ${token.comment}` : '';
return `${indent}public const string ${name} = "${token.value}";${comment}`;
});
return [
'// <auto-generated>',
'// EN: This file is auto-generated by the Style Dictionary pipeline.',
'// Do NOT edit manually. Update tokens/*.json and re-run the build.',
'// VI: File này được tạo tự động bởi Style Dictionary pipeline.',
'// KHÔNG chỉnh sửa thủ công. Cập nhật tokens/*.json và build lại.',
'// </auto-generated>',
'',
'namespace GoodGo.BlazorUi.DesignTokens;',
'',
'/// <summary>',
'/// EN: Auto-generated design token constants. Maps 1:1 with CSS custom properties.',
'/// VI: Hằng số design token tự động. Ánh xạ 1:1 với CSS custom properties.',
'/// </summary>',
'public static class DesignTokens',
'{',
...lines,
'}',
'',
].join('\n');
},
});
// ─── Main config ──────────────────────────────────────────────────────────────
export default {
source: ['tokens/**/*.json'],
platforms: {
/**
* EN: CSS custom properties — consumed by app.css and component stylesheets.
* Output: apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/tokens.generated.css
* VI: CSS custom properties — dùng trong app.css và stylesheet component.
*/
css: {
transformGroup: 'css',
transforms: ['name/goodgo/css', 'attribute/cti'],
prefix: 'gds',
buildPath: '../../apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/',
files: [
{
destination: 'tokens.generated.css',
format: 'css/variables',
options: {
outputReferences: false,
selector: ':root',
},
filter: (token) => token.attributes?.category !== 'primitive',
},
],
},
/**
* EN: C# constants — consumed by Blazor components for type-safe token access.
* Output: packages/blazor-ui/DesignTokens/DesignTokens.g.cs
* VI: C# constants — dùng trong Blazor component để truy cập token an toàn.
*/
csharp: {
transforms: ['attribute/cti'],
buildPath: '../blazor-ui/DesignTokens/',
files: [
{
destination: 'DesignTokens.g.cs',
format: 'csharp/goodgo-tokens',
filter: (token) => token.attributes?.category !== 'primitive',
},
],
},
},
};

View File

@@ -0,0 +1,10 @@
{
"border": {
"radius": {
"base": { "value": "6px", "comment": "Default border radius" },
"lg": { "value": "10px", "comment": "Large border radius" },
"xl": { "value": "14px", "comment": "XL border radius" },
"2xl": { "value": "20px", "comment": "2XL border radius" }
}
}
}

View File

@@ -0,0 +1,72 @@
{
"color": {
"primitive": {
"neutral": {
"0": { "value": "#ffffff", "comment": "Pure white" },
"50": { "value": "#fafafa", "comment": "Near white" },
"100": { "value": "#ADADB0", "comment": "Light gray" },
"200": { "value": "#8B8B90", "comment": "Medium gray" },
"300": { "value": "#6B6B70", "comment": "Muted gray" },
"400": { "value": "#3A3A3E", "comment": "Dark gray" },
"500": { "value": "#2A2A2E", "comment": "Darker gray" },
"600": { "value": "#1F1F23", "comment": "Surface border" },
"700": { "value": "#1A1A1D", "comment": "Elevated surface" },
"800": { "value": "#111113", "comment": "Base surface" },
"900": { "value": "#0A0A0B", "comment": "Page background" },
"950": { "value": "#050506", "comment": "Near black" }
},
"accent": {
"400": { "value": "#FF8A4C", "comment": "Orange light" },
"500": { "value": "#FF5C00", "comment": "Orange primary" },
"600": { "value": "#E05200", "comment": "Orange dark" }
},
"success": {
"500": { "value": "#22C55E", "comment": "Success green" }
},
"white": { "value": "#ffffff" },
"black": { "value": "#000000" },
"overlay": { "value": "rgba(0, 0, 0, 0.6)" }
},
"semantic": {
"bg": {
"page": { "value": "{color.primitive.neutral.900.value}", "comment": "Page background" },
"surface": { "value": "{color.primitive.neutral.800.value}", "comment": "Card / panel surface" },
"elevated": { "value": "{color.primitive.neutral.700.value}", "comment": "Elevated elements" },
"interactive": { "value": "{color.primitive.neutral.500.value}", "comment": "Interactive backgrounds" },
"surfaceHover": { "value": "{color.primitive.neutral.600.value}", "comment": "Hover state" },
"overlay": { "value": "rgba(10, 10, 11, 0.9)", "comment": "Modal overlay" }
},
"text": {
"primary": { "value": "{color.primitive.white.value}", "comment": "Primary text" },
"secondary": { "value": "{color.primitive.neutral.100.value}", "comment": "Secondary text" },
"tertiary": { "value": "{color.primitive.neutral.200.value}", "comment": "Tertiary / helper text" },
"disabled": { "value": "{color.primitive.neutral.300.value}", "comment": "Disabled text" },
"muted": { "value": "rgba(255, 255, 255, 0.8)", "comment": "Muted white text" },
"inverse": { "value": "{color.primitive.neutral.900.value}", "comment": "Inverse (dark on light)" }
},
"accent": {
"primary": { "value": "{color.primitive.accent.500.value}", "comment": "Brand orange" },
"light": { "value": "{color.primitive.accent.400.value}", "comment": "Light orange" },
"glow": { "value": "rgba(255, 92, 0, 0.15)", "comment": "Subtle glow" },
"glowStrong": { "value": "rgba(255, 92, 0, 0.3)", "comment": "Strong glow" }
},
"border": {
"subtle": { "value": "{color.primitive.neutral.600.value}", "comment": "Subtle divider" },
"default": { "value": "{color.primitive.neutral.500.value}", "comment": "Default border" },
"strong": { "value": "{color.primitive.neutral.400.value}", "comment": "Strong border" }
},
"action": {
"primaryBg": { "value": "{color.primitive.accent.500.value}", "comment": "CTA button bg" },
"primaryBgHover": { "value": "{color.primitive.accent.600.value}", "comment": "CTA button hover" },
"primaryText": { "value": "{color.primitive.white.value}", "comment": "CTA button text" },
"secondaryBg": { "value": "transparent", "comment": "Ghost button bg" },
"secondaryBgHover": { "value": "rgba(255, 255, 255, 0.05)", "comment": "Ghost button hover" },
"secondaryText": { "value": "{color.primitive.white.value}", "comment": "Ghost button text" },
"secondaryBorder": { "value": "{color.primitive.neutral.500.value}","comment": "Ghost button border" }
},
"status": {
"success": { "value": "{color.primitive.success.500.value}", "comment": "Success state" }
}
}
}
}

View File

@@ -0,0 +1,16 @@
{
"spacing": {
"1": { "value": "0.25rem", "comment": "4px" },
"2": { "value": "0.5rem", "comment": "8px" },
"3": { "value": "0.75rem", "comment": "12px" },
"4": { "value": "1rem", "comment": "16px" },
"5": { "value": "1.25rem", "comment": "20px" },
"6": { "value": "1.5rem", "comment": "24px" },
"8": { "value": "2rem", "comment": "32px" },
"10": { "value": "2.5rem", "comment": "40px" },
"12": { "value": "3rem", "comment": "48px" },
"16": { "value": "4rem", "comment": "64px" },
"20": { "value": "5rem", "comment": "80px" },
"24": { "value": "6rem", "comment": "96px" }
}
}

View File

@@ -0,0 +1,8 @@
{
"font": {
"family": {
"heading": { "value": "'Roboto', system-ui, sans-serif", "comment": "Heading / display font" },
"body": { "value": "'Roboto', system-ui, sans-serif", "comment": "Body text font" }
}
}
}