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:
@@ -6,7 +6,6 @@ import type { ReactNode } from "react";
|
|||||||
import { Bell, ChevronLeft } from "lucide-react";
|
import { Bell, ChevronLeft } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CurrencySwitcher } from "@/components/currency-switcher";
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import {
|
import {
|
||||||
playerHeaderControl,
|
playerHeaderControl,
|
||||||
@@ -48,10 +47,10 @@ export function PlayerPanel({
|
|||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
playerPageHeader,
|
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
|
<Link
|
||||||
href={backHref}
|
href={backHref}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -64,22 +63,13 @@ export function PlayerPanel({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 max-w-[min(52vw,12rem)] justify-self-center text-center">
|
<h1
|
||||||
<h1 className="truncate text-base font-black leading-tight text-[#0b3f96]">
|
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>
|
{title}
|
||||||
</div>
|
</h1>
|
||||||
|
|
||||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
<div className="relative z-[1] flex min-w-0 max-w-[48%] shrink-0 items-center justify-end gap-0.5">
|
||||||
<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]",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<LanguageSwitcher
|
<LanguageSwitcher
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
menuAlign="end"
|
menuAlign="end"
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export function isHallSealedCountdownUi(status: string): boolean {
|
|||||||
return status === "closing" || status === "closed";
|
return status === "closing" || status === "closed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 不可下注时展示阻断提示条 */
|
||||||
|
export function isHallBlockedForBetting(status: string): boolean {
|
||||||
|
return status !== "open";
|
||||||
|
}
|
||||||
|
|
||||||
/** 对齐界面文档 §4.2 状态文案与 PRD 期号状态 */
|
/** 对齐界面文档 §4.2 状态文案与 PRD 期号状态 */
|
||||||
export function drawStatusHud(status: string): DrawStatusHud {
|
export function drawStatusHud(status: string): DrawStatusHud {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||||
import { formatSecondsClock } from "@/lib/format-gmt";
|
import { formatSecondsClock } from "@/lib/format-gmt";
|
||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||||
@@ -64,7 +64,9 @@ function CloseTime({
|
|||||||
let seconds = 0;
|
let seconds = 0;
|
||||||
let label = t("draw.closesIn");
|
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));
|
seconds = Math.max(0, Math.ceil(((payload.close_time ? Date.parse(payload.close_time) : 0) - nowMs) / 1000));
|
||||||
} else if (hud.countdownKind === "draw") {
|
} else if (hud.countdownKind === "draw") {
|
||||||
seconds = Math.max(0, Math.ceil(((payload.draw_time ? Date.parse(payload.draw_time) : 0) - nowMs) / 1000));
|
seconds = Math.max(0, Math.ceil(((payload.draw_time ? Date.parse(payload.draw_time) : 0) - nowMs) / 1000));
|
||||||
@@ -77,7 +79,7 @@ function CloseTime({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="text-lg font-black tabular-nums text-[#ff143d]">
|
<span className="text-lg font-black tabular-nums text-[#ff143d]">
|
||||||
{formatSecondsClock(seconds)}
|
{hud.countdownKind === "none" ? "--:--" : formatSecondsClock(seconds)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 text-[11px] text-slate-500">{label}</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 hud = drawStatusHud(display.status);
|
||||||
const sealedUi = isHallSealedCountdownUi(display.status);
|
const sealedUi = isHallSealedCountdownUi(display.status);
|
||||||
|
const blockedUi = isHallBlockedForBetting(display.status);
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -163,10 +166,14 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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 />
|
<TimerReset className="size-4 shrink-0" aria-hidden />
|
||||||
{t("draw.sealedNotice")}
|
{display.status === "review"
|
||||||
|
? t("draw.reviewNotice")
|
||||||
|
: sealedUi
|
||||||
|
? t("draw.sealedNotice")
|
||||||
|
: t("draw.notBettableNotice")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500">
|
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500">
|
||||||
|
|||||||
@@ -53,17 +53,6 @@ const resources = {
|
|||||||
Record<(typeof namespaces)[number], Record<string, unknown>>
|
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 {
|
export function syncDocumentLanguage(lang: AppLanguage): void {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
@@ -83,17 +72,15 @@ export function syncPreferredLanguage(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!i18n.isInitialized) {
|
if (!i18n.isInitialized) {
|
||||||
const initialLng = resolveInitialLanguage();
|
|
||||||
|
|
||||||
void i18n.use(initReactI18next).init({
|
void i18n.use(initReactI18next).init({
|
||||||
resources,
|
resources,
|
||||||
fallbackLng: DEFAULT_LANGUAGE,
|
fallbackLng: DEFAULT_LANGUAGE,
|
||||||
supportedLngs: ["en", "ne", "zh"],
|
supportedLngs: ["en", "ne", "zh"],
|
||||||
defaultNS: "common",
|
defaultNS: "common",
|
||||||
ns: [...namespaces],
|
ns: [...namespaces],
|
||||||
/** 始终先用默认语言完成 SSR / 首次 hydration,避免首屏文本不一致 */
|
/** 与 SSR 一致:首屏固定默认语言,hydration 后再由 syncPreferredLanguage 切换 */
|
||||||
load: "languageOnly",
|
load: "languageOnly",
|
||||||
lng: initialLng,
|
lng: DEFAULT_LANGUAGE,
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
"hall": "Betting Hall",
|
"hall": "Betting Hall",
|
||||||
"noIssue": "No available issue. Please try again later.",
|
"noIssue": "No available issue. Please try again later.",
|
||||||
"sealedNotice": "Closed. Please wait for the next issue.",
|
"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": {
|
"status": {
|
||||||
"pending": "Not started",
|
"pending": "Not started",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
"hall": "बेटिङ हल",
|
"hall": "बेटिङ हल",
|
||||||
"noIssue": "उपलब्ध इश्यू छैन। कृपया पछि प्रयास गर्नुहोस्।",
|
"noIssue": "उपलब्ध इश्यू छैन। कृपया पछि प्रयास गर्नुहोस्।",
|
||||||
"sealedNotice": "बन्द भयो। कृपया अर्को इश्यू पर्खनुहोस्।",
|
"sealedNotice": "बन्द भयो। कृपया अर्को इश्यू पर्खनुहोस्।",
|
||||||
|
"reviewNotice": "नतिजा प्रशासक समीक्षामा छ। प्रकाशन पछि अर्को इश्यू खुल्छ।",
|
||||||
|
"notBettableNotice": "अहिले बाजी लगाउन मिल्दैन। कृपया पछि प्रयास गर्नुहोस्।",
|
||||||
"status": {
|
"status": {
|
||||||
"pending": "सुरु भएको छैन",
|
"pending": "सुरु भएको छैन",
|
||||||
"open": "खुला",
|
"open": "खुला",
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
"hall": "下注大厅",
|
"hall": "下注大厅",
|
||||||
"noIssue": "暂无可用期号,请稍后再试",
|
"noIssue": "暂无可用期号,请稍后再试",
|
||||||
"sealedNotice": "已封盘,请等待下一期。",
|
"sealedNotice": "已封盘,请等待下一期。",
|
||||||
|
"reviewNotice": "开奖结果待后台审核发布,审核通过后将开启下一期。",
|
||||||
|
"notBettableNotice": "当前不可下注,请稍后再试。",
|
||||||
"status": {
|
"status": {
|
||||||
"pending": "未开始",
|
"pending": "未开始",
|
||||||
"open": "可下注",
|
"open": "可下注",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/** 玩家端统一紧凑间距(页面壳、区块堆叠、区块间距) */
|
/** 玩家端统一紧凑间距(页面壳、区块堆叠、区块间距) */
|
||||||
export const playerPageInset = "px-3 pb-6 pt-2";
|
export const playerPageInset = "px-3 pb-6 pt-2";
|
||||||
/** 顶栏:左右等宽列 + 居中标题,避免返回键与标题高低不齐 */
|
/** 顶栏:左右分列 + 标题绝对居中,避免右侧控件挤压/遮挡标题 */
|
||||||
export const playerPageHeader =
|
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 =
|
export const playerHeaderControl =
|
||||||
"inline-flex h-8 shrink-0 items-center justify-center";
|
"inline-flex h-8 shrink-0 items-center justify-center";
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ interface ErrorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useErrorStore = create<ErrorState>((set, get) => ({
|
export const useErrorStore = create<ErrorState>((set, get) => ({
|
||||||
// 初始状态
|
// 首屏与 SSR 一致为 false;真实离线状态由 useNetworkStatus 在 mount 后同步
|
||||||
isOffline: typeof window !== "undefined" ? !navigator.onLine : false,
|
isOffline: false,
|
||||||
isServerError: false,
|
isServerError: false,
|
||||||
serverErrorMessage: "服务器暂时不可用,请稍后重试",
|
serverErrorMessage: "服务器暂时不可用,请稍后重试",
|
||||||
currentErrorType: null,
|
currentErrorType: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user