feat: enhance player panel and draw status handling

- Refactored PlayerPanel layout for improved title positioning and responsiveness.
- Added new function to check if betting is blocked based on hall status.
- Updated HallDrawPanel to utilize the new betting status check and display appropriate messages.
- Enhanced i18n support with new notices for review and non-bettable states across multiple languages.
This commit is contained in:
2026-05-25 15:35:50 +08:00
parent 3649bb9300
commit ca3a1db770
9 changed files with 38 additions and 43 deletions

View File

@@ -6,7 +6,6 @@ import type { ReactNode } from "react";
import { Bell, ChevronLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CurrencySwitcher } from "@/components/currency-switcher";
import { LanguageSwitcher } from "@/components/language-switcher";
import {
playerHeaderControl,
@@ -48,10 +47,10 @@ export function PlayerPanel({
<header
className={cn(
playerPageHeader,
"sticky top-0 z-50 overflow-visible bg-white/95 backdrop-blur pt-2 pb-2 -mx-3 px-3",
"sticky top-0 z-50 bg-white/95 backdrop-blur pt-2 pb-2 -mx-3 px-3",
)}
>
<div className="flex min-w-0 justify-start">
<div className="relative z-[1] flex min-w-0 max-w-[38%] shrink-0 justify-start">
<Link
href={backHref}
className={cn(
@@ -64,22 +63,13 @@ export function PlayerPanel({
</Link>
</div>
<div className="min-w-0 max-w-[min(52vw,12rem)] justify-self-center text-center">
<h1 className="truncate text-base font-black leading-tight text-[#0b3f96]">
{title}
</h1>
</div>
<h1
className="pointer-events-none absolute top-1/2 right-[5.25rem] left-[5.25rem] z-0 -translate-y-1/2 truncate text-center text-base font-black leading-tight text-[#0b3f96]"
>
{title}
</h1>
<div className="flex min-w-0 items-center justify-end gap-1">
<CurrencySwitcher
variant="minimal"
menuAlign="end"
showLabel
className={cn(
playerHeaderControl,
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:py-0 [&_button]:text-xs [&_button]:font-bold [&_button]:text-[#0b3f96]",
)}
/>
<div className="relative z-[1] flex min-w-0 max-w-[48%] shrink-0 items-center justify-end gap-0.5">
<LanguageSwitcher
variant="minimal"
menuAlign="end"

View File

@@ -14,6 +14,11 @@ export function isHallSealedCountdownUi(status: string): boolean {
return status === "closing" || status === "closed";
}
/** 不可下注时展示阻断提示条 */
export function isHallBlockedForBetting(status: string): boolean {
return status !== "open";
}
/** 对齐界面文档 §4.2 状态文案与 PRD 期号状态 */
export function drawStatusHud(status: string): DrawStatusHud {
switch (status) {

View File

@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { drawStatusHud, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
import { drawStatusHud, isHallBlockedForBetting, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
import { formatSecondsClock } from "@/lib/format-gmt";
import { formatLotteryInstant } from "@/lib/player-datetime";
@@ -64,7 +64,9 @@ function CloseTime({
let seconds = 0;
let label = t("draw.closesIn");
if (hud.countdownKind === "close") {
if (hud.countdownKind === "none") {
label = t(hud.labelKey, { defaultValue: hud.labelKey });
} else if (hud.countdownKind === "close") {
seconds = Math.max(0, Math.ceil(((payload.close_time ? Date.parse(payload.close_time) : 0) - nowMs) / 1000));
} else if (hud.countdownKind === "draw") {
seconds = Math.max(0, Math.ceil(((payload.draw_time ? Date.parse(payload.draw_time) : 0) - nowMs) / 1000));
@@ -77,7 +79,7 @@ function CloseTime({
return (
<>
<span className="text-lg font-black tabular-nums text-[#ff143d]">
{formatSecondsClock(seconds)}
{hud.countdownKind === "none" ? "--:--" : formatSecondsClock(seconds)}
</span>
<span className="mt-1 text-[11px] text-slate-500">{label}</span>
</>
@@ -127,6 +129,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
const hud = drawStatusHud(display.status);
const sealedUi = isHallSealedCountdownUi(display.status);
const blockedUi = isHallBlockedForBetting(display.status);
return (
<section
className={cn(
@@ -163,10 +166,14 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
/>
</div>
</div>
{sealedUi ? (
{blockedUi ? (
<div className="flex items-center gap-2 border-t border-red-100 bg-red-50 px-3 py-2 text-xs font-medium text-red-600">
<TimerReset className="size-4" aria-hidden />
{t("draw.sealedNotice")}
<TimerReset className="size-4 shrink-0" aria-hidden />
{display.status === "review"
? t("draw.reviewNotice")
: sealedUi
? t("draw.sealedNotice")
: t("draw.notBettableNotice")}
</div>
) : (
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500">

View File

@@ -53,17 +53,6 @@ const resources = {
Record<(typeof namespaces)[number], Record<string, unknown>>
>;
function resolveInitialLanguage(): AppLanguage {
// SSR 始终使用默认语言,避免首屏渲染依赖浏览器状态
if (typeof window === "undefined") {
return DEFAULT_LANGUAGE;
}
const stored = window.localStorage.getItem("i18nextLng");
const preferred = normalizeLanguage(stored ?? window.navigator.language);
return preferred;
}
export function syncDocumentLanguage(lang: AppLanguage): void {
if (typeof document === "undefined") return;
@@ -83,17 +72,15 @@ export function syncPreferredLanguage(): void {
}
if (!i18n.isInitialized) {
const initialLng = resolveInitialLanguage();
void i18n.use(initReactI18next).init({
resources,
fallbackLng: DEFAULT_LANGUAGE,
supportedLngs: ["en", "ne", "zh"],
defaultNS: "common",
ns: [...namespaces],
/** 始终先用默认语言完成 SSR / 首次 hydration避免首屏文本不一致 */
/** 与 SSR 一致首屏固定默认语言hydration 后再由 syncPreferredLanguage 切换 */
load: "languageOnly",
lng: initialLng,
lng: DEFAULT_LANGUAGE,
interpolation: {
escapeValue: false,

View File

@@ -81,6 +81,8 @@
"hall": "Betting Hall",
"noIssue": "No available issue. Please try again later.",
"sealedNotice": "Closed. Please wait for the next issue.",
"reviewNotice": "Draw results are pending admin review. The next issue opens after publish.",
"notBettableNotice": "Betting is not available right now. Please try again later.",
"status": {
"pending": "Not started",
"open": "Open",

View File

@@ -81,6 +81,8 @@
"hall": "बेटिङ हल",
"noIssue": "उपलब्ध इश्यू छैन। कृपया पछि प्रयास गर्नुहोस्।",
"sealedNotice": "बन्द भयो। कृपया अर्को इश्यू पर्खनुहोस्।",
"reviewNotice": "नतिजा प्रशासक समीक्षामा छ। प्रकाशन पछि अर्को इश्यू खुल्छ।",
"notBettableNotice": "अहिले बाजी लगाउन मिल्दैन। कृपया पछि प्रयास गर्नुहोस्।",
"status": {
"pending": "सुरु भएको छैन",
"open": "खुला",

View File

@@ -81,6 +81,8 @@
"hall": "下注大厅",
"noIssue": "暂无可用期号,请稍后再试",
"sealedNotice": "已封盘,请等待下一期。",
"reviewNotice": "开奖结果待后台审核发布,审核通过后将开启下一期。",
"notBettableNotice": "当前不可下注,请稍后再试。",
"status": {
"pending": "未开始",
"open": "可下注",

View File

@@ -1,8 +1,8 @@
/** 玩家端统一紧凑间距(页面壳、区块堆叠、区块间距) */
export const playerPageInset = "px-3 pb-6 pt-2";
/** 顶栏:左右等宽列 + 居中标题,避免返回键与标题高低不齐 */
/** 顶栏:左右列 + 标题绝对居中,避免右侧控件挤压/遮挡标题 */
export const playerPageHeader =
"mb-2 grid min-h-9 grid-cols-[1fr_auto_1fr] items-center gap-x-2";
"relative mb-2 flex min-h-9 items-center justify-between gap-2";
/** 顶栏左右控件统一高度 */
export const playerHeaderControl =
"inline-flex h-8 shrink-0 items-center justify-center";

View File

@@ -28,8 +28,8 @@ interface ErrorState {
}
export const useErrorStore = create<ErrorState>((set, get) => ({
// 初始状态
isOffline: typeof window !== "undefined" ? !navigator.onLine : false,
// 首屏与 SSR 一致为 false真实离线状态由 useNetworkStatus 在 mount 后同步
isOffline: false,
isServerError: false,
serverErrorMessage: "服务器暂时不可用,请稍后重试",
currentErrorType: null,