feat: enhance API error handling and UI improvements
Some checks failed
lotteryfront CI / build (push) Has been cancelled
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/components/player-money-display.tsx
Normal file
29
src/components/player-money-display.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user