feat: enhance API error handling and UI improvements
Some checks failed
lotteryfront CI / build (push) Has been cancelled

- Updated `getPublicCurrencies` and `getPlayerAuthCaptcha` functions to include `skipGlobalServerError` option, improving error handling for API requests.
- Refactored the NotFoundPage component to enhance mobile responsiveness and UI consistency, integrating `PlayerMobileViewport`.
- Improved the NetworkStatusBanner and OfflineBanner components by adding player banner height reference for better layout management.
- Updated Wallet components to utilize `PlayerMoneyDisplay` for consistent currency representation and added credit mode handling in various screens.
- Enhanced the WalletScreen and Transfer screens with loading panels and credit guard logic for better user experience during wallet operations.
This commit is contained in:
2026-06-22 10:50:52 +08:00
parent 34b851d7e1
commit ecb032f8cc
43 changed files with 756 additions and 297 deletions

View File

@@ -1,37 +1,12 @@
"use client";
import { Bell } from "lucide-react";
import Link from "next/link";
import { type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
import { playerHeaderControl } from "@/lib/player-spacing";
import { cn } from "@/lib/utils";
type PlayerNotificationBellProps = {
className?: string;
};
/** 顶栏铃铛:待对账划转提醒 */
export function PlayerNotificationBell({ className }: PlayerNotificationBellProps): ReactElement {
const { t } = useTranslation("common");
const { hasUnread } = usePendingWalletReconcile();
return (
<Link
href="/notifications"
className={cn(
playerHeaderControl,
"relative size-8 rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]",
className,
)}
aria-label={t("navigation.notifications")}
>
<Bell className="size-4" aria-hidden />
{hasUnread ? (
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-[#ff143d]" />
) : null}
</Link>
);
/** 顶栏铃铛:暂隐藏,待接入通用通知中心后再开放 */
export function PlayerNotificationBell(_props: PlayerNotificationBellProps): ReactElement | null {
return null;
}

View File

@@ -7,7 +7,7 @@ import { ChevronLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/language-switcher";
import { PlayerNotificationBell } from "@/components/layout/player-notification-bell";
import { usePlayerStickyTopStyle } from "@/hooks/use-player-sticky-top-style";
import {
playerHeaderControl,
playerPageHeader,
@@ -33,12 +33,13 @@ export function PlayerPanel({
}: PlayerPanelProps) {
const { t: tp } = useTranslation("player");
const resolvedBackLabel = backLabel ?? tp("panel.home");
const stickyTopStyle = usePlayerStickyTopStyle();
return (
<div className={cn("mx-auto w-full max-w-[480px]", containerClassName)}>
<section
className={cn(
"bg-white text-slate-900 min-h-screen",
"bg-white text-slate-900",
"px-3 pb-6",
className,
)}
@@ -46,29 +47,29 @@ export function PlayerPanel({
<header
className={cn(
playerPageHeader,
"sticky top-0 z-50 bg-white/95 backdrop-blur pt-2 pb-2 -mx-3 px-3",
"sticky z-50 grid grid-cols-[minmax(0,38%)_minmax(0,1fr)_minmax(0,48%)] items-center gap-1 bg-white/95 pt-2 pb-2 -mx-3 px-3 backdrop-blur",
)}
style={stickyTopStyle}
>
<div className="relative z-[1] flex min-w-0 max-w-[38%] shrink-0 justify-start">
<div className="flex min-w-0 justify-start">
<Link
href={backHref}
className={cn(
playerHeaderControl,
"gap-0.5 rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]",
)}
aria-label={resolvedBackLabel}
>
<ChevronLeft className="size-4 shrink-0" aria-hidden />
<span className="max-w-[4.5rem] truncate">{resolvedBackLabel}</span>
<span className="max-w-[5.5rem] truncate">{resolvedBackLabel}</span>
</Link>
</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]"
>
<h1 className="min-w-0 truncate px-1 text-center text-base font-black leading-tight text-[#0b3f96]">
{title}
</h1>
<div className="relative z-[1] flex min-w-0 max-w-[48%] shrink-0 items-center justify-end gap-0.5">
<div className="flex min-w-0 items-center justify-end gap-0.5">
<LanguageSwitcher
variant="minimal"
menuAlign="end"
@@ -78,7 +79,6 @@ export function PlayerPanel({
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:py-0 [&_button]:text-xs",
)}
/>
<PlayerNotificationBell />
</div>
</header>
{children}

View File

@@ -4,7 +4,10 @@ import { WifiOff, RefreshCw, AlertCircle } from "lucide-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { usePlayerBannerHeightRef } from "@/hooks/use-player-banner-height-ref";
import { useWebSocketManager } from "@/hooks/use-websocket-manager";
import { playerViewportColumnClass } from "@/lib/player-viewport";
import { useErrorStore } from "@/stores/error-store";
import { cn } from "@/lib/utils";
// 颜色常量(来自用户需求)
@@ -21,32 +24,36 @@ const COLORS = {
*/
export function NetworkStatusBanner(): React.ReactElement | null {
const { showDegradedBanner, mode, reconnect } = useWebSocketManager();
const browserOffline = useErrorStore((state) => state.isOffline);
const bannerRef = usePlayerBannerHeightRef("network");
const { t } = useTranslation("player");
const handleReconnectClick = useCallback(() => {
reconnect();
}, [reconnect]);
// 只有在降级模式或离线模式下才显示
if (!showDegradedBanner) {
// 浏览器离线时由 OfflineBanner 统一提示,避免重复
if (!showDegradedBanner || browserOffline) {
return null;
}
const isOffline = mode === "offline";
return (
<div
className={cn(
"sticky top-0 z-50 flex items-center justify-center gap-2 px-4 py-2 text-sm",
"animate-in slide-in-from-top-2 duration-200",
)}
style={{
backgroundColor: isOffline ? COLORS.error : COLORS.warning,
color: "#fff",
}}
role="status"
aria-live="polite"
>
<div ref={bannerRef} className="sticky top-0 z-50 w-full">
<div
className={cn(
playerViewportColumnClass,
"flex items-center justify-center gap-2 px-4 py-2 text-sm",
"animate-in slide-in-from-top-2 duration-200",
)}
style={{
backgroundColor: isOffline ? COLORS.error : COLORS.warning,
color: "#fff",
}}
role="status"
aria-live="polite"
>
{isOffline ? (
<>
<WifiOff className="size-4 shrink-0" aria-hidden />
@@ -74,6 +81,7 @@ export function NetworkStatusBanner(): React.ReactElement | null {
</button>
</>
)}
</div>
</div>
);
}

View File

@@ -4,6 +4,8 @@ import { WifiOff, RefreshCw } from "lucide-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { usePlayerBannerHeightRef } from "@/hooks/use-player-banner-height-ref";
import { playerViewportColumnClass } from "@/lib/player-viewport";
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
import { cn } from "@/lib/utils";
@@ -14,6 +16,7 @@ import { cn } from "@/lib/utils";
*/
export function OfflineBanner(): React.ReactElement | null {
const isOffline = useErrorStore((state) => state.isOffline);
const bannerRef = usePlayerBannerHeightRef("offline");
const { t } = useTranslation("player");
const handleReconnect = useCallback(() => {
@@ -29,28 +32,31 @@ export function OfflineBanner(): React.ReactElement | null {
}
return (
<div
className={cn(
"sticky top-0 z-[60] flex items-center justify-center gap-2 px-4 py-3 text-sm",
"animate-in slide-in-from-top-full duration-300 ease-out",
)}
style={{
backgroundColor: ERROR_COLORS.error,
color: "#fff",
}}
role="alert"
aria-live="assertive"
>
<WifiOff className="size-4 shrink-0" aria-hidden />
<span className="font-medium">{t("network.offline")}</span>
<button
onClick={handleReconnect}
className="ml-3 flex items-center gap-1.5 rounded-md bg-white/20 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/30 focus:outline-none focus:ring-2 focus:ring-white/50 active:bg-white/25"
aria-label={t("network.reconnect")}
<div ref={bannerRef} className="sticky top-0 z-[60] w-full">
<div
className={cn(
playerViewportColumnClass,
"flex items-center justify-center gap-2 px-4 py-3 text-sm",
"animate-in slide-in-from-top-full duration-300 ease-out",
)}
style={{
backgroundColor: ERROR_COLORS.error,
color: "#fff",
}}
role="alert"
aria-live="assertive"
>
<RefreshCw className="size-3.5" aria-hidden />
{t("network.reconnect")}
</button>
<WifiOff className="size-4 shrink-0" aria-hidden />
<span className="font-medium">{t("network.offline")}</span>
<button
onClick={handleReconnect}
className="ml-3 flex items-center gap-1.5 rounded-md bg-white/20 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/30 focus:outline-none focus:ring-2 focus:ring-white/50 active:bg-white/25"
aria-label={t("network.reconnect")}
>
<RefreshCw className="size-3.5" aria-hidden />
{t("network.reconnect")}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { formatMinorAsCurrency } from "@/lib/money";
import { cn } from "@/lib/utils";
type PlayerMoneyDisplayProps = {
amountMinor: number;
currency: string;
className?: string;
};
/** 玩家端金额展示:自适应字号 + 防大额截断 */
export function PlayerMoneyDisplay({
amountMinor,
currency,
className,
}: PlayerMoneyDisplayProps) {
return (
<p
className={cn(
"font-black leading-none tabular-nums tracking-normal break-all",
"text-[clamp(1.125rem,5vw,1.5rem)]",
className,
)}
>
{formatMinorAsCurrency(amountMinor, currency)}
</p>
);
}