feat: Restructure authentication UI to dedicated tPOS pages and update client assets and configurations.

This commit is contained in:
Ho Ngoc Hai
2026-02-12 01:27:12 +07:00
parent 78f4a296be
commit f678a1a69a
33 changed files with 119 additions and 100 deletions

View File

@@ -1,5 +1,6 @@
@using System.Globalization
@inject NavigationManager Navigation
@inject IJSRuntime JS
<MudMenu Dense="true" AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopRight" LockScroll="true">
<ActivatorContent>
@@ -29,39 +30,37 @@
@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";
var culture = CultureInfo.CurrentUICulture.Name;
return culture.StartsWith("vi", StringComparison.OrdinalIgnoreCase) ? "VI" : "EN";
}
private void SwitchLanguage(string targetCulture)
private async Task SwitchLanguage(string targetCulture)
{
var uri = new Uri(Navigation.Uri);
var path = uri.PathAndQuery;
// Save to localStorage for persistence across page reloads
await JS.InvokeVoidAsync("localStorage.setItem", "aPOS_culture", targetCulture);
// Force full page reload to reinitialize the WASM app with new culture
var currentUri = Navigation.Uri;
var uri = new Uri(currentUri);
// Build clean URL (strip any culture segments from path)
var path = uri.AbsolutePath;
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)))
// Remove existing culture segment if present
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);
segments = segments.Skip(1).ToArray();
}
else
{
if (path == "/") path = "";
newPath = $"/{targetCulture}{path}";
}
Navigation.NavigateTo(newPath, forceLoad: true);
var cleanPath = "/" + string.Join('/', segments);
if (cleanPath == "/") cleanPath = "";
// Navigate to root with forceLoad to reinitialize WASM
Navigation.NavigateTo($"{cleanPath}", forceLoad: true);
}
}

View File

@@ -7,7 +7,7 @@ public class LocalizationCache
{
private readonly HttpClient _httpClient;
private Dictionary<string, string> _strings = new();
private bool _isLoaded;
private string _loadedCulture = "";
public LocalizationCache(HttpClient httpClient)
{
@@ -25,21 +25,22 @@ public class LocalizationCache
public async Task LoadAsync(CultureInfo culture)
{
if (_isLoaded) return; // Or check if culture changed
var cultureName = culture.Name;
// Fallback for simple culture codes
if (cultureName == "vi") cultureName = "vi-VN";
if (cultureName == "en") cultureName = "en-US";
// Skip reload only if same culture is already loaded
if (_loadedCulture == cultureName) return;
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;
_loadedCulture = cultureName;
}
}
catch (Exception ex)

View File

@@ -1,4 +1,5 @@
@page "/"
@page "/{culture}"
@inject IStringLocalizer<Home> L
@inject IJSRuntime JS
@@ -33,7 +34,7 @@
</div>
<div class="hero-mockup">
<span>@L["HeroMockup_Alt"]</span>
<img src="/images/home/pos-dashboard.png" alt="@L["HeroMockup_Alt"]" class="hero-mockup-img" />
</div>
</section>
@@ -93,13 +94,18 @@
@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 class="tpos-industry-img">
<img src="@ind.Image" alt="@L[ind.Title]" />
</div>
<div class="tpos-industry-content">
<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>
}
@@ -159,9 +165,9 @@
<p class="tpos-pricing-desc">@L["Plan_Starter_Desc"]</p>
<hr class="tpos-pricing-divider" />
<ul class="tpos-pricing-features">
<li class="tpos-pricing-feature"><span class="check-icon green"></span> @L["Plan_Starter_Feature1"]</li>
<li class="tpos-pricing-feature"><span class="check-icon green"></span> @L["Plan_Starter_Feature2"]</li>
<li class="tpos-pricing-feature"><span class="check-icon green"></span> @L["Plan_Starter_Feature3"]</li>
<li class="tpos-pricing-feature"><i data-lucide="check" class="lucide-check green"></i> @L["Plan_Starter_Feature1"]</li>
<li class="tpos-pricing-feature"><i data-lucide="check" class="lucide-check green"></i> @L["Plan_Starter_Feature2"]</li>
<li class="tpos-pricing-feature"><i data-lucide="check" class="lucide-check green"></i> @L["Plan_Starter_Feature3"]</li>
</ul>
<a href="#" class="btn-outline btn-full">@L["Plan_Starter_CTA"]</a>
</div>
@@ -177,9 +183,9 @@
<p class="tpos-pricing-desc">@L["Plan_Pro_Desc"]</p>
<hr class="tpos-pricing-divider" />
<ul class="tpos-pricing-features">
<li class="tpos-pricing-feature"><span class="check-icon accent"></span> @L["Plan_Pro_Feature1"]</li>
<li class="tpos-pricing-feature"><span class="check-icon accent"></span> @L["Plan_Pro_Feature2"]</li>
<li class="tpos-pricing-feature"><span class="check-icon accent"></span> @L["Plan_Pro_Feature3"]</li>
<li class="tpos-pricing-feature"><i data-lucide="check" class="lucide-check accent"></i> @L["Plan_Pro_Feature1"]</li>
<li class="tpos-pricing-feature"><i data-lucide="check" class="lucide-check accent"></i> @L["Plan_Pro_Feature2"]</li>
<li class="tpos-pricing-feature"><i data-lucide="check" class="lucide-check accent"></i> @L["Plan_Pro_Feature3"]</li>
</ul>
<a href="#" class="btn-accent btn-full">@L["Plan_Pro_CTA"]</a>
</div>
@@ -194,9 +200,9 @@
<p class="tpos-pricing-desc">@L["Plan_Enterprise_Desc"]</p>
<hr class="tpos-pricing-divider" />
<ul class="tpos-pricing-features">
<li class="tpos-pricing-feature"><span class="check-icon green"></span> @L["Plan_Enterprise_Feature1"]</li>
<li class="tpos-pricing-feature"><span class="check-icon green"></span> @L["Plan_Enterprise_Feature2"]</li>
<li class="tpos-pricing-feature"><span class="check-icon green"></span> @L["Plan_Enterprise_Feature3"]</li>
<li class="tpos-pricing-feature"><i data-lucide="check" class="lucide-check green"></i> @L["Plan_Enterprise_Feature1"]</li>
<li class="tpos-pricing-feature"><i data-lucide="check" class="lucide-check green"></i> @L["Plan_Enterprise_Feature2"]</li>
<li class="tpos-pricing-feature"><i data-lucide="check" class="lucide-check green"></i> @L["Plan_Enterprise_Feature3"]</li>
</ul>
<a href="#" class="btn-outline btn-full">@L["Plan_Enterprise_CTA"]</a>
</div>
@@ -287,6 +293,8 @@
</footer>
@code {
[Parameter] public string? culture { get; set; }
// Initialize Lucide icons after render
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -309,14 +317,14 @@
];
// Industry cards data (5 industries matching Pencil design)
private record IndustryItem(string Title, string Desc, string Chips);
private record IndustryItem(string Title, string Desc, string Chips, string Image);
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_Restaurant_Title", "Industry_Restaurant_Desc", "Industry_Restaurant_Chips", "/images/home/fnb-ai.png"),
new("Industry_Bar_Title", "Industry_Bar_Desc", "Industry_Bar_Chips", "/images/home/bar-ai.png"),
new("Industry_Karaoke_Title", "Industry_Karaoke_Desc", "Industry_Karaoke_Chips", "/images/home/karaoke-ai.png"),
new("Industry_Coffee_Title", "Industry_Coffee_Desc", "Industry_Coffee_Chips", "/images/home/coffee-ai.png"),
new("Industry_Spa_Title", "Industry_Spa_Desc", "Industry_Spa_Chips", "/images/home/spa-ai.png"),
];
// Add-on items with icons (matching Pencil design)

View File

@@ -5,6 +5,7 @@ using WebClientTpos.Client;
using WebClientTpos.Client.Localization;
using Microsoft.Extensions.Localization;
using System.Globalization;
using Microsoft.JSInterop;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
@@ -27,22 +28,23 @@ builder.Services.AddSingleton<IStringLocalizerFactory, JsonStringLocalizerFactor
// 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
// Default culture is Vietnamese
var culture = new CultureInfo("vi-VN");
if (baseAddress.Contains("/vi-VN/", StringComparison.OrdinalIgnoreCase))
// Try reading saved culture preference from localStorage
try
{
culture = new CultureInfo("vi-VN");
}
else if (baseAddress.Contains("/vi/", StringComparison.OrdinalIgnoreCase))
{
culture = new CultureInfo("vi-VN");
var jsRuntime = host.Services.GetRequiredService<IJSRuntime>();
var savedCulture = await jsRuntime.InvokeAsync<string?>("localStorage.getItem", "aPOS_culture");
if (!string.IsNullOrEmpty(savedCulture))
{
try { culture = new CultureInfo(savedCulture); } catch { }
}
}
catch { /* JS not available yet during startup, use default */ }
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;

View File

@@ -340,21 +340,20 @@ a {
.hero-mockup {
width: 100%;
max-width: 900px;
height: 400px;
max-width: 1000px;
margin: var(--space-16) auto 0;
background: var(--bg-surface);
border: 1px solid var(--border-default);
border: 1px solid var(--border-subtle);
border-radius: var(--border-radius-2xl);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: 0.875rem;
overflow: hidden;
box-shadow: 0 0 60px rgba(255, 92, 0, 0.08);
}
.hero-mockup-img {
width: 100%;
height: auto;
display: block;
}
/* ═════════════════════════════════════════════════════════════════════════
8. TRUST SECTION
═════════════════════════════════════════════════════════════════════════ */
@@ -531,25 +530,34 @@ a {
}
.tpos-industry-card {
background: var(--bg-surface);
background: var(--bg-page);
border: 1px solid var(--border-subtle);
border-radius: var(--border-radius-xl);
padding: var(--space-8);
position: relative;
overflow: hidden;
transition: all 0.25s ease;
display: flex;
flex-direction: column;
}
.tpos-industry-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--brand-gradient);
opacity: 0;
transition: opacity 0.25s ease;
.tpos-industry-img {
width: 100%;
height: 200px;
overflow: hidden;
}
.tpos-industry-img img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tpos-industry-content {
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.tpos-industry-card:hover {
@@ -558,10 +566,6 @@ a {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
.tpos-industry-card:hover::before {
opacity: 1;
}
.tpos-industry-title {
font-size: 1.125rem;
font-weight: 700;
@@ -741,18 +745,23 @@ a {
color: var(--text-secondary);
}
.tpos-pricing-feature .check-icon {
font-size: 1rem;
.tpos-pricing-feature .lucide-check {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 2px;
margin-top: 1px;
background-color: transparent !important;
background: none !important;
}
.tpos-pricing-feature .check-icon.green {
color: var(--success);
.tpos-pricing-feature .lucide-check.green {
color: var(--success) !important;
stroke: var(--success);
}
.tpos-pricing-feature .check-icon.accent {
color: var(--accent-primary);
.tpos-pricing-feature .lucide-check.accent {
color: var(--accent-primary) !important;
stroke: var(--accent-primary);
}
.tpos-pricing-divider {

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB