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:
@@ -23,6 +23,7 @@ type HallBetResultDialogProps = {
|
||||
currencyCode: string;
|
||||
data: TicketPlaceData | null;
|
||||
jackpotEnabled?: boolean;
|
||||
creditMode?: boolean;
|
||||
};
|
||||
|
||||
const SUCCESS_ITEM_STATUSES = new Set(["pending_draw", "placed"]);
|
||||
@@ -34,6 +35,7 @@ export function HallBetResultDialog({
|
||||
currencyCode,
|
||||
data,
|
||||
jackpotEnabled = false,
|
||||
creditMode = false,
|
||||
}: HallBetResultDialogProps) {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
@@ -178,7 +180,10 @@ export function HallBetResultDialog({
|
||||
<span className="font-mono font-black text-[#0b3f96]">{data.order_no}</span>
|
||||
</p>
|
||||
<p>
|
||||
{t("hall.result.balanceAfter")}:{" "}
|
||||
{creditMode
|
||||
? t("hall.result.creditBalanceAfter", { defaultValue: "可用信用" })
|
||||
: t("hall.result.balanceAfter")}
|
||||
:{" "}
|
||||
<span className="font-semibold text-slate-950">
|
||||
{formatMinorAsCurrency(data.balance_after, currencyCode)}
|
||||
</span>
|
||||
@@ -200,7 +205,9 @@ export function HallBetResultDialog({
|
||||
<table className="min-w-[430px] w-full border-collapse text-xs">
|
||||
<thead className="bg-[#f4f7fd] text-[#304f86]">
|
||||
<tr>
|
||||
<th className="w-10 border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">No.</th>
|
||||
<th className="w-10 border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">
|
||||
{t("hall.table.no")}
|
||||
</th>
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">
|
||||
{t("hall.result.number")}
|
||||
</th>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
type PlayCatalogRefreshSource,
|
||||
} from "@/lib/play-catalog-events";
|
||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
||||
@@ -441,6 +443,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const { display, isBettable, reload: reloadDraw } = drawLive;
|
||||
const { t } = useTranslation("player");
|
||||
const { activeCurrency: currencyParam } = useActivePlayerCurrency();
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
||||
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
|
||||
@@ -1241,7 +1244,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
<button
|
||||
key={`fav-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||
className="inline-flex h-7 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||
onPointerDown={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
current.number = number;
|
||||
@@ -1568,6 +1571,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
currencyCode={currencyCode}
|
||||
data={resultData}
|
||||
jackpotEnabled={Boolean(jackpot?.enabled)}
|
||||
creditMode={creditMode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -207,7 +207,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
||||
/>
|
||||
<Hourglass
|
||||
className={cn(
|
||||
"absolute right-2 top-1/2 size-5 -translate-y-1/2",
|
||||
"absolute right-0.5 top-1/2 size-4 -translate-y-1/2 opacity-80",
|
||||
sealedUi ? "text-[#ff143d]" : "text-red-300",
|
||||
)}
|
||||
aria-hidden
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CurrencySwitcher } from "@/components/currency-switcher";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { PlayerNotificationBell } from "@/components/layout/player-notification-bell";
|
||||
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
||||
import { usePlayerStickyTopStyle } from "@/hooks/use-player-sticky-top-style";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
||||
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
||||
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
|
||||
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||
import { useJackpotBurstLive } from "@/features/hall/use-jackpot-burst-live";
|
||||
import { playerHeaderControl, playerPageInset } from "@/lib/player-spacing";
|
||||
import { playerHeaderControl, playerPageHeader, playerPageInset } from "@/lib/player-spacing";
|
||||
import { playerViewportColumnClass } from "@/lib/player-viewport";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -25,22 +27,29 @@ export function HallScreen() {
|
||||
const drawLive = useHallDrawLive();
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
|
||||
const stickyTopStyle = usePlayerStickyTopStyle();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[480px]">
|
||||
<div className={playerViewportColumnClass}>
|
||||
<section className={cn("bg-white text-slate-900", playerPageInset)}>
|
||||
<header className="relative z-20 mb-2 flex min-h-9 items-center gap-2 overflow-visible">
|
||||
<header
|
||||
className={cn(
|
||||
playerPageHeader,
|
||||
"sticky z-20 mb-2 flex min-h-9 items-center gap-2 overflow-visible bg-white/95 pt-2 pb-2 -mx-3 px-3 backdrop-blur",
|
||||
)}
|
||||
style={stickyTopStyle}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Nlotto"
|
||||
width={243}
|
||||
height={84}
|
||||
className="h-8 w-auto max-w-[min(100%,200px)] object-contain object-left"
|
||||
className="h-7 w-auto max-w-[min(100%,160px)] object-contain object-left sm:h-8 sm:max-w-[min(100%,200px)]"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
||||
<CurrencySwitcher
|
||||
variant="minimal"
|
||||
menuAlign="end"
|
||||
@@ -65,10 +74,11 @@ export function HallScreen() {
|
||||
playerHeaderControl,
|
||||
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2.5 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]",
|
||||
)}
|
||||
aria-label={tp("nav.rules")}
|
||||
>
|
||||
{tp("nav.rules")}
|
||||
<BookOpen className="size-3.5 shrink-0 sm:hidden" aria-hidden />
|
||||
<span className="hidden sm:inline">{tp("nav.rules")}</span>
|
||||
</Link>
|
||||
<PlayerNotificationBell />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSWRConfig } from "swr";
|
||||
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { PlayerMoneyDisplay } from "@/components/player-money-display";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
TransferInDialog,
|
||||
@@ -65,29 +66,6 @@ export function HallWalletStrip() {
|
||||
const balanceMinor = Number(balance?.balance ?? 0);
|
||||
const headlineMinor = isCreditPlayer ? availableMinor : balanceMinor;
|
||||
const transferInLotteryMinor = isCreditPlayer ? availableMinor : balanceMinor;
|
||||
// #region agent log
|
||||
if (typeof window !== "undefined" && balance && !loading) {
|
||||
fetch("http://127.0.0.1:7696/ingest/e56128e6-898b-4d61-b06d-2aefe0923744", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7fd0fc" },
|
||||
body: JSON.stringify({
|
||||
sessionId: "7fd0fc",
|
||||
runId: "hall-wallet-display-post-fix",
|
||||
hypothesisId: "H3",
|
||||
location: "hall-wallet-strip.tsx:render",
|
||||
message: "hall wallet headline amounts",
|
||||
data: {
|
||||
isCreditPlayer,
|
||||
availableMinor,
|
||||
balanceMinor,
|
||||
headlineMinor,
|
||||
mismatchWithWalletPage: !isCreditPlayer && headlineMinor !== balanceMinor,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
// #endregion
|
||||
const mainMinor =
|
||||
balance?.main_balance === null || balance?.main_balance === undefined
|
||||
? null
|
||||
@@ -115,7 +93,7 @@ export function HallWalletStrip() {
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className="flex size-13 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
||||
<Wallet className="size-7" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -127,10 +105,21 @@ export function HallWalletStrip() {
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||
) : (
|
||||
<p className="mt-1 text-2xl font-black leading-none tabular-nums tracking-normal">
|
||||
{formatMinorAsCurrency(headlineMinor, currency)}
|
||||
</p>
|
||||
<PlayerMoneyDisplay
|
||||
amountMinor={headlineMinor}
|
||||
currency={currency}
|
||||
className="mt-1 text-white"
|
||||
/>
|
||||
)}
|
||||
{isCreditPlayer && !loading && balance ? (
|
||||
<p className="mt-2 text-xs text-white/75">
|
||||
{t("wallet.creditSummary", {
|
||||
defaultValue: "授信 {{limit}} · 已用 {{used}}",
|
||||
limit: formatMinorAsCurrency(balance.credit_limit ?? 0, currency),
|
||||
used: formatMinorAsCurrency(balance.used_credit ?? 0, currency),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CalendarRange, ChevronDown, Search } from "lucide-react";
|
||||
import { CalendarRange, Check, ChevronDown, Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getDrawCurrent } from "@/api/draw";
|
||||
import { getTicketItems } from "@/api/ticket-items";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
@@ -203,10 +202,7 @@ export function TicketOrdersListScreen() {
|
||||
}, [fetchPage, lastPage, loading, loadingMore, page]);
|
||||
|
||||
return (
|
||||
<PlayerPanel
|
||||
title={t("orders.title")}
|
||||
containerClassName="max-w-[720px]"
|
||||
>
|
||||
<PlayerPanel title={t("orders.title")}>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-[#dfe9f8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.05)] sm:p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -381,7 +377,17 @@ export function TicketOrdersListScreen() {
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Checkbox className="size-3.5" checked={checked} />
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-3.5 shrink-0 items-center justify-center rounded border",
|
||||
checked
|
||||
? "border-[#0b56b7] bg-[#0b56b7] text-white"
|
||||
: "border-slate-300 bg-white",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{checked ? <Check className="size-2.5" strokeWidth={3} /> : null}
|
||||
</span>
|
||||
<span className="truncate">{t(`ticketStatus.${status}`, { defaultValue: status })}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
@@ -502,7 +503,7 @@ export function EntryGate() {
|
||||
size="lg"
|
||||
type="button"
|
||||
>
|
||||
<Loader2 className="size-4" aria-hidden />
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
{t("failure.reenter")}
|
||||
</Button>
|
||||
{MAIN_SITE_URL !== "" ? (
|
||||
|
||||
@@ -9,14 +9,17 @@ import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import {
|
||||
pendingReconcileDescriptionKey,
|
||||
pendingReconcileTitleKey,
|
||||
} from "@/lib/pending-reconcile-notification";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function NotificationsScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
const { pending, unreadPending, unreadCount, loading, markAsRead, markAllAsRead } =
|
||||
usePendingWalletReconcile();
|
||||
const unreadSet = new Set(unreadPending.map((item) => item.transfer_no));
|
||||
@@ -24,6 +27,14 @@ export function NotificationsScreen() {
|
||||
return (
|
||||
<PlayerPanel title={t("notifications.title")} backHref="/hall">
|
||||
<div className="space-y-3">
|
||||
{creditMode ? (
|
||||
<div className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
|
||||
{t("notifications.creditEmptyHint", {
|
||||
defaultValue: "信用盘无主站划转,暂无待对账通知。",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between rounded-xl border border-[#dce7f7] bg-[#f8fbff] px-3 py-2.5">
|
||||
<p className="text-sm font-semibold text-[#0b3f96]">
|
||||
{t("notifications.unreadCount", { count: unreadCount })}
|
||||
@@ -126,6 +137,8 @@ export function NotificationsScreen() {
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
|
||||
@@ -113,19 +113,13 @@ export function PlayerLoginScreen(): React.ReactElement {
|
||||
|
||||
const usernameIssue = validatePlayerLoginUsername(username);
|
||||
if (usernameIssue === "invalid_charset") {
|
||||
toast.error(
|
||||
t("login.usernameInvalidCharset", {
|
||||
defaultValue: "账号只能使用字母、数字、点(.)、下划线和连字符",
|
||||
}),
|
||||
);
|
||||
toast.error(t("login.usernameInvalidCharset"));
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordIssue = validatePlayerLoginPassword(password);
|
||||
if (passwordIssue === "too_short") {
|
||||
toast.error(
|
||||
t("login.passwordMinLength", { defaultValue: "密码至少需要 6 个字符" }),
|
||||
);
|
||||
toast.error(t("login.passwordMinLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import { norm4d } from "@/lib/norm-4d";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { DrawResultDetailPayload } from "@/types/api/draw-results";
|
||||
@@ -218,18 +219,16 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
{[
|
||||
["1st", data.results["1st"]],
|
||||
["2nd", data.results["2nd"]],
|
||||
["3rd", data.results["3rd"]],
|
||||
].map(([label, value]) => (
|
||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||
<div
|
||||
key={label}
|
||||
key={tier}
|
||||
className="rounded-lg border border-[#edf2f8] bg-white py-2 shadow-[0_4px_12px_rgba(15,23,42,0.03)]"
|
||||
>
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{label}</p>
|
||||
<p className="text-[10px] font-bold text-[#7890b8]">
|
||||
{t(resultsPrizeLabelKey(tier))}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||||
{value}
|
||||
{data.results[tier]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
|
||||
const RESULTS_PAGE_SIZE = 10;
|
||||
@@ -292,15 +293,13 @@ export function DrawResultsListScreen() {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
{[
|
||||
["1st", row.results["1st"]],
|
||||
["2nd", row.results["2nd"]],
|
||||
["3rd", row.results["3rd"]],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{label}</p>
|
||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||
<div key={tier} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
||||
<p className="text-[10px] font-bold text-[#7890b8]">
|
||||
{t(resultsPrizeLabelKey(tier))}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||||
{value}
|
||||
{row.results[tier]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useSWRConfig } from "swr";
|
||||
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { WalletTransferCreditGuard } from "@/features/wallet/wallet-transfer-credit-guard";
|
||||
import { WalletTransferLoadingPanel } from "@/features/wallet/wallet-transfer-loading-panel";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||
@@ -24,7 +25,6 @@ export function TransferInScreen() {
|
||||
() => getWalletBalance({ currency }),
|
||||
);
|
||||
|
||||
// 币种切换时 SWR key 自动变化,此处仅处理全局事件触发的刷新
|
||||
useEffect(() => {
|
||||
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
||||
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||
@@ -37,24 +37,21 @@ export function TransferInScreen() {
|
||||
}, [mutate, currency, router]);
|
||||
|
||||
if (loading && !balance) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
return <WalletTransferLoadingPanel titleKey="wallet.transferInTitle" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TransferInPage
|
||||
currency={currency}
|
||||
lotteryMinor={Number(balance?.balance ?? 0)}
|
||||
mainMinor={
|
||||
balance?.main_balance === null || balance?.main_balance === undefined
|
||||
? null
|
||||
: Number(balance.main_balance)
|
||||
}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
<WalletTransferCreditGuard balance={balance} loading={loading}>
|
||||
<TransferInPage
|
||||
currency={currency}
|
||||
lotteryMinor={Number(balance?.balance ?? 0)}
|
||||
mainMinor={
|
||||
balance?.main_balance === null || balance?.main_balance === undefined
|
||||
? null
|
||||
: Number(balance.main_balance)
|
||||
}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</WalletTransferCreditGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useSWRConfig } from "swr";
|
||||
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { WalletTransferCreditGuard } from "@/features/wallet/wallet-transfer-credit-guard";
|
||||
import { WalletTransferLoadingPanel } from "@/features/wallet/wallet-transfer-loading-panel";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||
@@ -24,7 +25,6 @@ export function TransferOutScreen() {
|
||||
() => getWalletBalance({ currency }),
|
||||
);
|
||||
|
||||
// 币种切换时 SWR key 自动变化,此处仅处理全局事件触发的刷新
|
||||
useEffect(() => {
|
||||
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
||||
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||
@@ -37,19 +37,16 @@ export function TransferOutScreen() {
|
||||
}, [mutate, currency, router]);
|
||||
|
||||
if (loading && !balance) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
return <WalletTransferLoadingPanel titleKey="wallet.transferOutTitle" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TransferOutPage
|
||||
currency={currency}
|
||||
availableMinor={Number(balance?.available_balance ?? 0)}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
<WalletTransferCreditGuard balance={balance} loading={loading}>
|
||||
<TransferOutPage
|
||||
currency={currency}
|
||||
availableMinor={Number(balance?.available_balance ?? 0)}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</WalletTransferCreditGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ type WalletLogsBlockProps = {
|
||||
creditMode?: boolean;
|
||||
};
|
||||
|
||||
/** 类型筛选 + 列表(待对账见顶栏通知铃铛) */
|
||||
/** 类型筛选 + 列表(钱包盘待对账见钱包页横幅) */
|
||||
export function WalletLogsBlock({
|
||||
logs,
|
||||
logsLoading,
|
||||
|
||||
@@ -142,7 +142,6 @@ export function WalletLogsScreen() {
|
||||
onFilterChange={setFilter}
|
||||
currency={currency}
|
||||
creditMode={creditMode}
|
||||
title={t("wallet.typeFilter")}
|
||||
/>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
|
||||
45
src/features/wallet/wallet-pending-reconcile-banner.tsx
Normal file
45
src/features/wallet/wallet-pending-reconcile-banner.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
||||
|
||||
/** 钱包盘:顶栏铃铛隐藏后,在钱包页展示待对账提醒入口 */
|
||||
export function WalletPendingReconcileBanner() {
|
||||
const { t } = useTranslation("player");
|
||||
const { pending, unreadCount, hasPending } = usePendingWalletReconcile();
|
||||
|
||||
if (!hasPending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-950">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-amber-900">{t("wallet.pendingTitle")}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-amber-950/85">
|
||||
{t("wallet.pendingDescription")}
|
||||
</p>
|
||||
{unreadCount > 0 ? (
|
||||
<p className="mt-1.5 text-xs font-semibold text-amber-800">
|
||||
{t("notifications.unreadCount", { count: unreadCount })}
|
||||
</p>
|
||||
) : null}
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="mt-2 inline-flex text-xs font-bold text-[#0b56b7] underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("wallet.viewPendingReconcile", {
|
||||
defaultValue: "查看待对账详情({{count}})",
|
||||
count: pending.length,
|
||||
})}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
TransferInDialog,
|
||||
TransferOutDialog,
|
||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||
import { WalletPendingReconcileBanner } from "@/features/wallet/wallet-pending-reconcile-banner";
|
||||
import { PlayerMoneyDisplay } from "@/components/player-money-display";
|
||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||
import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
@@ -36,8 +38,8 @@ export function WalletScreen() {
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const fetchPassRef = useRef(true);
|
||||
const filterInitializedRef = useRef(false);
|
||||
const prevFilterRef = useRef("");
|
||||
|
||||
const loadLogs = useCallback(async (targetPage = 1, append = false) => {
|
||||
const nextLogs = await getWalletLogs({
|
||||
@@ -60,21 +62,21 @@ export function WalletScreen() {
|
||||
|
||||
void (async () => {
|
||||
setError(null);
|
||||
if (fetchPassRef.current) {
|
||||
setLoading(true);
|
||||
fetchPassRef.current = false;
|
||||
} else {
|
||||
setLogsLoading(true);
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
// 并行请求余额和日志,避免瀑布式串行等待
|
||||
const [b, nextLogs] = await Promise.all([
|
||||
getWalletBalance({ currency }),
|
||||
loadLogs(1, false),
|
||||
getWalletLogs({
|
||||
page: 1,
|
||||
size: WALLET_LOGS_PAGE_SIZE,
|
||||
type: filter || undefined,
|
||||
currency,
|
||||
}),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setBalance(b);
|
||||
setLogs(nextLogs);
|
||||
dispatchWalletLogsRefresh(nextLogs.pending_reconcile ?? []);
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(formatWalletClientError(e, t));
|
||||
@@ -90,7 +92,50 @@ export function WalletScreen() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currency, loadLogs, t]);
|
||||
}, [currency, t]); // eslint-disable-line react-hooks/exhaustive-deps -- 币种切换整页刷新;流水筛选见下方 effect
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterInitializedRef.current) {
|
||||
filterInitializedRef.current = true;
|
||||
prevFilterRef.current = filter;
|
||||
return;
|
||||
}
|
||||
if (prevFilterRef.current === filter) {
|
||||
return;
|
||||
}
|
||||
prevFilterRef.current = filter;
|
||||
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setLogsLoading(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const nextLogs = await getWalletLogs({
|
||||
page: 1,
|
||||
size: WALLET_LOGS_PAGE_SIZE,
|
||||
type: filter || undefined,
|
||||
currency,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setLogs(nextLogs);
|
||||
dispatchWalletLogsRefresh(nextLogs.pending_reconcile ?? []);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(formatWalletClientError(e, t));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currency, filter, t]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
setError(null);
|
||||
@@ -181,7 +226,7 @@ export function WalletScreen() {
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className="flex size-13 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
||||
<Wallet className="size-7" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -193,9 +238,11 @@ export function WalletScreen() {
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||
) : (
|
||||
<p className="mt-1 text-2xl font-black leading-none tabular-nums tracking-normal">
|
||||
{formatMinorAsCurrency(displayMinor, currency)}
|
||||
</p>
|
||||
<PlayerMoneyDisplay
|
||||
amountMinor={displayMinor}
|
||||
currency={currency}
|
||||
className="mt-1 text-white"
|
||||
/>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/75">
|
||||
{isCreditPlayer
|
||||
@@ -220,7 +267,9 @@ export function WalletScreen() {
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<>
|
||||
<WalletPendingReconcileBanner />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TransferInDialog
|
||||
idPrefix="wallet-"
|
||||
currency={currency}
|
||||
@@ -245,6 +294,7 @@ export function WalletScreen() {
|
||||
triggerClassName="h-14 rounded-2xl text-base font-black"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<WalletLogsBlock
|
||||
|
||||
57
src/features/wallet/wallet-transfer-credit-guard.tsx
Normal file
57
src/features/wallet/wallet-transfer-credit-guard.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
|
||||
type WalletTransferCreditGuardProps = {
|
||||
balance: WalletBalanceData | null | undefined;
|
||||
loading: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/** 信用盘玩家不可主站划转:加载完成后重定向回钱包页 */
|
||||
export function WalletTransferCreditGuard({
|
||||
balance,
|
||||
loading,
|
||||
children,
|
||||
}: WalletTransferCreditGuardProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !balance) {
|
||||
return;
|
||||
}
|
||||
if (isCreditFundingPlayer(balance)) {
|
||||
router.replace("/wallet");
|
||||
}
|
||||
}, [balance, loading, router]);
|
||||
|
||||
if (loading || !balance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isCreditFundingPlayer(balance)) {
|
||||
return (
|
||||
<PlayerPanel
|
||||
title={t("wallet.creditTitle", { defaultValue: "信用" })}
|
||||
backHref="/wallet"
|
||||
backLabel={t("wallet.creditTitle", { defaultValue: "信用" })}
|
||||
>
|
||||
<p className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
|
||||
{t("wallet.creditNoTransferHint", {
|
||||
defaultValue:
|
||||
"由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
|
||||
})}
|
||||
</p>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
29
src/features/wallet/wallet-transfer-loading-panel.tsx
Normal file
29
src/features/wallet/wallet-transfer-loading-panel.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
|
||||
type WalletTransferLoadingPanelProps = {
|
||||
titleKey: "wallet.transferInTitle" | "wallet.transferOutTitle";
|
||||
};
|
||||
|
||||
export function WalletTransferLoadingPanel({ titleKey }: WalletTransferLoadingPanelProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
const backLabel = creditMode
|
||||
? t("wallet.creditTitle", { defaultValue: "信用" })
|
||||
: t("wallet.title");
|
||||
|
||||
return (
|
||||
<PlayerPanel title={t(titleKey)} backHref="/wallet" backLabel={backLabel}>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user