feat(player): improve wallet activity and session handling
Some checks failed
lotteryfront CI / build (push) Has been cancelled
Some checks failed
lotteryfront CI / build (push) Has been cancelled
This commit is contained in:
@@ -9,6 +9,7 @@ export type GetTicketItemsParams = {
|
|||||||
page?: number;
|
page?: number;
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
draw_no?: string;
|
draw_no?: string;
|
||||||
|
ticket_no?: string;
|
||||||
order_no?: string;
|
order_no?: string;
|
||||||
number?: string;
|
number?: string;
|
||||||
status?: string[];
|
status?: string[];
|
||||||
@@ -27,6 +28,7 @@ export function getTicketItems(
|
|||||||
page: params?.page,
|
page: params?.page,
|
||||||
per_page: params?.per_page,
|
per_page: params?.per_page,
|
||||||
draw_no: params?.draw_no,
|
draw_no: params?.draw_no,
|
||||||
|
ticket_no: params?.ticket_no,
|
||||||
order_no: params?.order_no,
|
order_no: params?.order_no,
|
||||||
number: params?.number,
|
number: params?.number,
|
||||||
status: params?.status,
|
status: params?.status,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { PlayerAppShell } from "@/components/layout/player-app-shell";
|
import { PlayerAppShell } from "@/components/layout/player-app-shell";
|
||||||
|
import { PlayEffectiveWsListener } from "@/components/play-effective-ws-listener";
|
||||||
|
import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener";
|
||||||
|
import { TokenSilentRefresh } from "@/components/token-silent-refresh";
|
||||||
|
import { PlayerAuthGate } from "@/features/player/player-auth-gate";
|
||||||
import { HydratePlayerAuth } from "@/features/player/hydrate-player-auth";
|
import { HydratePlayerAuth } from "@/features/player/hydrate-player-auth";
|
||||||
|
|
||||||
export default function PlayerMainLayout({
|
export default function PlayerMainLayout({
|
||||||
@@ -7,9 +11,12 @@ export default function PlayerMainLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<PlayerAuthGate>
|
||||||
<HydratePlayerAuth />
|
<HydratePlayerAuth />
|
||||||
|
<PlayerBalanceWsListener />
|
||||||
|
<PlayEffectiveWsListener />
|
||||||
|
<TokenSilentRefresh />
|
||||||
<PlayerAppShell>{children}</PlayerAppShell>
|
<PlayerAppShell>{children}</PlayerAppShell>
|
||||||
</>
|
</PlayerAuthGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,16 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hall-summary-grid {
|
||||||
|
grid-template-columns: repeat(var(--hall-summary-columns), minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1500px) {
|
||||||
|
.hall-summary-grid {
|
||||||
|
grid-template-columns: repeat(var(--hall-summary-columns-wide), minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 玩家端 Toast:顶部居中、紧凑尺寸(位置见 components/ui/sonner.tsx) */
|
/* 玩家端 Toast:顶部居中、紧凑尺寸(位置见 components/ui/sonner.tsx) */
|
||||||
@@ -335,3 +345,19 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes jackpot-strip-sweep {
|
||||||
|
0%,
|
||||||
|
20% {
|
||||||
|
transform: translateX(0) rotate(12deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(500%) rotate(12deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,17 +10,29 @@ import {
|
|||||||
import { syncPreferredLanguage } from "@/i18n";
|
import { syncPreferredLanguage } from "@/i18n";
|
||||||
|
|
||||||
const I18nHydrationContext = createContext(false);
|
const I18nHydrationContext = createContext(false);
|
||||||
|
const hydrationListeners = new Set<() => void>();
|
||||||
|
let hydrationReady = false;
|
||||||
|
let hydrationSyncPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function ensurePreferredLanguageSynced(): void {
|
||||||
|
if (hydrationSyncPromise) return;
|
||||||
|
|
||||||
|
hydrationSyncPromise = syncPreferredLanguage()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => {
|
||||||
|
hydrationReady = true;
|
||||||
|
hydrationListeners.forEach((listener) => listener());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function subscribeI18nHydration(onStoreChange: () => void): () => void {
|
function subscribeI18nHydration(onStoreChange: () => void): () => void {
|
||||||
queueMicrotask(() => {
|
hydrationListeners.add(onStoreChange);
|
||||||
syncPreferredLanguage();
|
ensurePreferredLanguageSynced();
|
||||||
onStoreChange();
|
return () => hydrationListeners.delete(onStoreChange);
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getI18nHydratedSnapshot(): boolean {
|
function getI18nHydratedSnapshot(): boolean {
|
||||||
return true;
|
return hydrationReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getI18nHydratedServerSnapshot(): boolean {
|
function getI18nHydratedServerSnapshot(): boolean {
|
||||||
@@ -42,4 +54,4 @@ export function I18nHydrationProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
export function useI18nHydrated(): boolean {
|
export function useI18nHydrated(): boolean {
|
||||||
return useContext(I18nHydrationContext);
|
return useContext(I18nHydrationContext);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const tabs = [
|
|||||||
labelKey: "nav.wallet",
|
labelKey: "nav.wallet",
|
||||||
creditLabelKey: "nav.credit",
|
creditLabelKey: "nav.credit",
|
||||||
labelDefault: "钱包",
|
labelDefault: "钱包",
|
||||||
creditLabelDefault: "信用",
|
creditLabelDefault: "额度",
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const navItems = [
|
|||||||
labelKey: "nav.wallet",
|
labelKey: "nav.wallet",
|
||||||
creditLabelKey: "nav.credit",
|
creditLabelKey: "nav.credit",
|
||||||
labelDefault: "钱包",
|
labelDefault: "钱包",
|
||||||
creditLabelDefault: "信用",
|
creditLabelDefault: "额度",
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAmount, formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PlayerMoneyDisplayProps = {
|
type PlayerMoneyDisplayProps = {
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
showCurrencyCode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 玩家端金额展示:自适应字号 + 防大额截断 */
|
/** 玩家端金额展示:自适应字号 + 防大额截断 */
|
||||||
@@ -14,6 +15,7 @@ export function PlayerMoneyDisplay({
|
|||||||
amountMinor,
|
amountMinor,
|
||||||
currency,
|
currency,
|
||||||
className,
|
className,
|
||||||
|
showCurrencyCode = true,
|
||||||
}: PlayerMoneyDisplayProps) {
|
}: PlayerMoneyDisplayProps) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
@@ -23,7 +25,9 @@ export function PlayerMoneyDisplay({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatMinorAsCurrency(amountMinor, currency)}
|
{showCurrencyCode
|
||||||
|
? formatMinorAsCurrency(amountMinor, currency)
|
||||||
|
: formatMinorAmount(amountMinor)}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import { I18nHydrationProvider } from "@/components/i18n-hydration-provider";
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { ErrorProvider } from "@/components/error-provider";
|
import { ErrorProvider } from "@/components/error-provider";
|
||||||
import { IframeBridge } from "@/components/iframe-bridge";
|
import { IframeBridge } from "@/components/iframe-bridge";
|
||||||
import { PlayEffectiveWsListener } from "@/components/play-effective-ws-listener";
|
|
||||||
import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener";
|
|
||||||
import { TokenSilentRefresh } from "@/components/token-silent-refresh";
|
|
||||||
import "@/i18n";
|
import "@/i18n";
|
||||||
|
|
||||||
type ProvidersProps = {
|
type ProvidersProps = {
|
||||||
@@ -21,13 +18,7 @@ export function Providers({ children }: ProvidersProps): ReactNode {
|
|||||||
<I18nHydrationProvider>
|
<I18nHydrationProvider>
|
||||||
<ErrorProvider>
|
<ErrorProvider>
|
||||||
{/* iframe 通信桥接 - 支持主站嵌入 */}
|
{/* iframe 通信桥接 - 支持主站嵌入 */}
|
||||||
<IframeBridge>
|
<IframeBridge>{children}</IframeBridge>
|
||||||
{children}
|
|
||||||
<PlayerBalanceWsListener />
|
|
||||||
<PlayEffectiveWsListener />
|
|
||||||
{/* Token 静默续签(无 UI) */}
|
|
||||||
<TokenSilentRefresh />
|
|
||||||
</IframeBridge>
|
|
||||||
</ErrorProvider>
|
</ErrorProvider>
|
||||||
</I18nHydrationProvider>
|
</I18nHydrationProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -257,10 +257,9 @@ export function HallBetPreviewDialog({
|
|||||||
</td>
|
</td>
|
||||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
||||||
<span className="block font-mono text-base font-black text-slate-950">{ln.number}</span>
|
<span className="block font-mono text-base font-black text-slate-950">{ln.number}</span>
|
||||||
<span className="text-[11px] font-bold text-[#0755c7]">{playLabel(ln.play_code, t)}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold text-slate-700">
|
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold text-[#0755c7]">
|
||||||
{ln.play_code}
|
{playLabel(ln.play_code, t)}
|
||||||
</td>
|
</td>
|
||||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
||||||
<span className="inline-flex rounded-full bg-[#eef5ff] px-2 py-0.5 font-black text-[#0b56b7]">
|
<span className="inline-flex rounded-full bg-[#eef5ff] px-2 py-0.5 font-black text-[#0b56b7]">
|
||||||
|
|||||||
@@ -55,13 +55,16 @@ export function digitSlotOptions(category: PlayHallCategory): number[] {
|
|||||||
return [0, 1, 2, 3];
|
return [0, 1, 2, 3];
|
||||||
}
|
}
|
||||||
|
|
||||||
function digitSlotLabel(category: PlayHallCategory, slot: number): string {
|
function digitSlotLabel(
|
||||||
const labels: Record<PlayHallCategory, Record<number, string>> = {
|
category: PlayHallCategory,
|
||||||
D2: { 2: "十", 3: "个" },
|
slot: number,
|
||||||
D3: { 1: "百", 2: "十", 3: "个" },
|
t: HallTranslate,
|
||||||
D4: { 0: "千", 1: "百", 2: "十", 3: "个" },
|
): string {
|
||||||
};
|
if (!digitSlotOptions(category).includes(slot)) return String(slot + 1);
|
||||||
return labels[category][slot] ?? String(slot + 1);
|
|
||||||
|
return t(`hall.table.digitPosition.${slot}`, {
|
||||||
|
defaultValue: String(10 ** (3 - slot)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function playColumnHeaderLabel(
|
export function playColumnHeaderLabel(
|
||||||
@@ -72,7 +75,7 @@ export function playColumnHeaderLabel(
|
|||||||
): string {
|
): string {
|
||||||
if (digitSlot !== undefined) {
|
if (digitSlot !== undefined) {
|
||||||
const kind = play.play_code === "digit_big" ? "big" : "small";
|
const kind = play.play_code === "digit_big" ? "big" : "small";
|
||||||
return `${t(`hall.table.digitShort.${kind}`)}·${digitSlotLabel(category, digitSlot)}`;
|
return `${t(`hall.table.digitShort.${kind}`)}·${digitSlotLabel(category, digitSlot, t)}`;
|
||||||
}
|
}
|
||||||
return playLabel(play.play_code, t);
|
return playLabel(play.play_code, t);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
} from "@/features/hall/selection-type";
|
} from "@/features/hall/selection-type";
|
||||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||||
@@ -436,11 +437,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
}
|
}
|
||||||
}, [currencyParam]);
|
}, [currencyParam]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void loadCatalog();
|
||||||
void loadCatalog();
|
void refreshWallet();
|
||||||
void refreshWallet();
|
|
||||||
});
|
|
||||||
}, [loadCatalog, refreshWallet]);
|
}, [loadCatalog, refreshWallet]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1500,18 +1499,22 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
<section className="space-y-3" aria-label={t("hall.aria")}>
|
<section className="space-y-3" aria-label={t("hall.aria")}>
|
||||||
|
|
||||||
{jackpot?.enabled ? (
|
{jackpot?.enabled ? (
|
||||||
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 via-white to-[#f8fbff] px-3 py-2.5">
|
<div className="relative overflow-hidden rounded-xl border border-[#d6b74e]/70 bg-[linear-gradient(115deg,#06183c_0%,#0a3a89_50%,#071f50_100%)] px-3 py-3 text-white shadow-[0_10px_28px_rgba(7,46,112,0.2)] sm:px-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="pointer-events-none absolute -left-10 top-1/2 size-28 -translate-y-1/2 rounded-full bg-[#2d79ff]/25 blur-2xl motion-safe:animate-[jackpot-amount-row-glow_2.8s_ease-in-out_infinite]" />
|
||||||
<div>
|
<div className="pointer-events-none absolute -right-5 -top-16 size-36 rounded-full bg-[#f4ca54]/20 blur-2xl motion-safe:animate-[jackpot-amount-row-glow_3.2s_ease-in-out_infinite]" />
|
||||||
<p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
|
<div className="pointer-events-none absolute -inset-y-10 -left-1/3 w-1/3 rotate-12 bg-gradient-to-r from-transparent via-white/20 to-transparent blur-sm motion-safe:animate-[jackpot-strip-sweep_4s_ease-in-out_infinite]" />
|
||||||
|
|
||||||
|
<div className="relative flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="w-fit bg-gradient-to-r from-[#fff1ad] via-white to-[#e2bd45] bg-[length:200%_auto] bg-clip-text text-[11px] font-black uppercase tracking-[0.16em] text-transparent motion-safe:animate-[jackpot-shimmer_3s_linear_infinite]">
|
||||||
{t("results.jackpotLabel", { defaultValue: "Jackpot" })}
|
{t("results.jackpotLabel", { defaultValue: "Jackpot" })}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-xl font-black tabular-nums text-[#07459f]">
|
<p className="mt-0.5 truncate font-mono text-xl font-black tabular-nums text-white drop-shadow-[0_0_12px_rgba(245,216,122,0.35)] sm:text-2xl motion-safe:animate-[jackpot-amount-glow_2.4s_ease-in-out_infinite]">
|
||||||
{formatMinorAsCurrency(jackpot.current_amount_minor, jackpot.currency_code)}
|
{formatMinorAmount(jackpot.current_amount_minor)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{jackpot.draws_since_last_burst !== null ? (
|
{jackpot.draws_since_last_burst !== null ? (
|
||||||
<p className="rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-800">
|
<p className="ml-auto rounded-full border border-[#f4d66f]/35 bg-white/10 px-3 py-1.5 text-xs font-bold text-[#ffe8a0] shadow-[inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-sm">
|
||||||
{t("results.jackpotGap", {
|
{t("results.jackpotGap", {
|
||||||
count: jackpot.draws_since_last_burst,
|
count: jackpot.draws_since_last_burst,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
|
||||||
import { getPlayEffective } from "@/api/play";
|
import { getPlayEffective } from "@/api/play";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -99,10 +100,8 @@ export function HallPlayCatalogPanel() {
|
|||||||
}
|
}
|
||||||
}, [currencyParam, t]);
|
}, [currencyParam, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
|
||||||
});
|
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
import { formatMinorAmount } from "@/lib/money";
|
import { formatMinorAmount } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -7,6 +9,11 @@ export type HallSummaryItem = {
|
|||||||
totalMinor: number;
|
totalMinor: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function balancedColumnCount(itemCount: number, maxColumns: number) {
|
||||||
|
const rowCount = Math.max(1, Math.ceil(itemCount / maxColumns));
|
||||||
|
return Math.max(1, Math.ceil(itemCount / rowCount));
|
||||||
|
}
|
||||||
|
|
||||||
export function HallPlaySummaryGrid({
|
export function HallPlaySummaryGrid({
|
||||||
items,
|
items,
|
||||||
className,
|
className,
|
||||||
@@ -14,12 +21,18 @@ export function HallPlaySummaryGrid({
|
|||||||
items: HallSummaryItem[];
|
items: HallSummaryItem[];
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const gridStyle = {
|
||||||
|
"--hall-summary-columns": balancedColumnCount(items.length, 10),
|
||||||
|
"--hall-summary-columns-wide": balancedColumnCount(items.length, 16),
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid grid-cols-6 overflow-hidden rounded-lg border border-[#dfe6f0] bg-[#f8fafc] text-center tabular-nums shadow-sm sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-[repeat(auto-fit,minmax(3.5rem,1fr))]",
|
"hall-summary-grid grid grid-cols-6 overflow-hidden rounded-lg border border-[#dfe6f0] bg-[#f8fafc] text-center tabular-nums shadow-sm sm:grid-cols-8 md:grid-cols-10",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={gridStyle}
|
||||||
>
|
>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const hasValue = item.totalMinor > 0;
|
const hasValue = item.totalMinor > 0;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { useApiQuery } from "@/hooks/use-api-query";
|
import { useApiQuery } from "@/hooks/use-api-query";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
|
||||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -97,6 +96,7 @@ export function HallWalletStrip() {
|
|||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 1023px) 100vw, 1400px"
|
sizes="(max-width: 1023px) 100vw, 1400px"
|
||||||
|
loading="eager"
|
||||||
className="pointer-events-none object-cover object-center"
|
className="pointer-events-none object-cover object-center"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
@@ -111,20 +111,9 @@ export function HallWalletStrip() {
|
|||||||
>
|
>
|
||||||
<Wallet className={cn(isMobile ? "size-5.5" : "size-5.5")} aria-hidden />
|
<Wallet className={cn(isMobile ? "size-5.5" : "size-5.5")} aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="min-w-0 flex-1">
|
||||||
className={cn(
|
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
"min-w-0 flex-1",
|
<p className={cn("shrink-0 font-semibold text-white/90", isMobile ? "text-[13px]" : "text-xs xl:text-sm")}>
|
||||||
!isMobile && "flex items-center gap-3 overflow-hidden",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"min-w-0",
|
|
||||||
!isMobile &&
|
|
||||||
"flex shrink-0 flex-col items-start gap-1 overflow-hidden xl:flex-row xl:items-baseline xl:gap-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className={cn("font-semibold text-white/90", isMobile ? "text-[13px]" : "text-xs xl:text-sm")}>
|
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -138,27 +127,14 @@ export function HallWalletStrip() {
|
|||||||
<PlayerMoneyDisplay
|
<PlayerMoneyDisplay
|
||||||
amountMinor={headlineMinor}
|
amountMinor={headlineMinor}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
|
showCurrencyCode={false}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-white",
|
"text-white",
|
||||||
isMobile ? "mt-0.5" : "text-lg xl:text-xl",
|
isMobile ? "text-lg" : "text-lg xl:text-xl",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isCreditPlayer && !loading && balance ? (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-white/80",
|
|
||||||
isMobile ? "mt-1 text-[11px]" : "hidden min-w-0 truncate text-xs xl:block",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { ticketDetailHref } from "@/features/orders/group-ticket-items";
|
import { ticketDetailHref } from "@/features/orders/group-ticket-items";
|
||||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||||
@@ -118,10 +119,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
}
|
}
|
||||||
}, [ticketNo, t]);
|
}, [ticketNo, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
|
||||||
});
|
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { OrderMetaLine } from "@/features/orders/order-meta-line";
|
|||||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
|
||||||
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
|
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
@@ -61,6 +62,12 @@ function providerLabel(providerName?: string | null, providerCode?: string | nul
|
|||||||
return providerName || providerCode || "-";
|
return providerName || providerCode || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupProviderLabel(items: TicketItemListRow[]): string {
|
||||||
|
return Array.from(
|
||||||
|
new Set(items.map((item) => providerLabel(item.provider_name, item.provider_code))),
|
||||||
|
).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
export function TicketOrdersListScreen() {
|
export function TicketOrdersListScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -170,7 +177,7 @@ export function TicketOrdersListScreen() {
|
|||||||
[fromDate, queryDrawNo, queryNumber, queryStatuses, t, toDate],
|
[fromDate, queryDrawNo, queryNumber, queryStatuses, t, toDate],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void getDrawCurrent()
|
void getDrawCurrent()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const tz = res.data?.schedule_timezone?.trim();
|
const tz = res.data?.schedule_timezone?.trim();
|
||||||
@@ -186,7 +193,7 @@ export function TicketOrdersListScreen() {
|
|||||||
[scheduleTimezone],
|
[scheduleTimezone],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
if (!initialLoadDone.current) {
|
if (!initialLoadDone.current) {
|
||||||
initialLoadDone.current = true;
|
initialLoadDone.current = true;
|
||||||
void fetchPage(1, false);
|
void fetchPage(1, false);
|
||||||
@@ -606,6 +613,8 @@ export function TicketOrdersListScreen() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs font-semibold text-[#7890b8]">
|
<p className="mt-2 text-xs font-semibold text-[#7890b8]">
|
||||||
{t("orders.betItems")} · {group.items.length}
|
{t("orders.betItems")} · {group.items.length}
|
||||||
|
{" · "}
|
||||||
|
{groupProviderLabel(group.items)}
|
||||||
</p>
|
</p>
|
||||||
{group.status === "partial_failed" ? (
|
{group.status === "partial_failed" ? (
|
||||||
<p className="mt-2 text-xs font-bold text-amber-700">
|
<p className="mt-2 text-xs font-bold text-amber-700">
|
||||||
@@ -750,7 +759,14 @@ export function TicketOrdersListScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-2.5" />
|
<TableCell className="px-3 py-2.5 align-middle">
|
||||||
|
<p
|
||||||
|
className="truncate text-sm font-semibold text-[#0b56b7]"
|
||||||
|
title={groupProviderLabel(group.items)}
|
||||||
|
>
|
||||||
|
{groupProviderLabel(group.items)}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="px-3 py-2.5 text-right align-middle">
|
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||||
<p className="text-sm font-black tabular-nums text-slate-900">
|
<p className="text-sm font-black tabular-nums text-slate-900">
|
||||||
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
import { getPublicCurrencies } from "@/api/currency";
|
|
||||||
import { getPlayerMe } from "@/api/player";
|
import { getPlayerMe } from "@/api/player";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { ensureCurrencyCatalogLoaded } from "@/hooks/use-currency-catalog";
|
||||||
import { loadCurrencyDisplayFormat } from "@/lib/currency-display-settings";
|
import { loadCurrencyDisplayFormat } from "@/lib/currency-display-settings";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
@@ -16,9 +15,8 @@ export function HydratePlayerAuth(): null {
|
|||||||
(state) => state.restoreBearerToken,
|
(state) => state.restoreBearerToken,
|
||||||
);
|
);
|
||||||
const setProfile = usePlayerSessionStore((state) => state.setProfile);
|
const setProfile = usePlayerSessionStore((state) => state.setProfile);
|
||||||
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
usePlayerSessionStore.getState().reconcileSelectedCurrency();
|
usePlayerSessionStore.getState().reconcileSelectedCurrency();
|
||||||
void loadCurrencyDisplayFormat();
|
void loadCurrencyDisplayFormat();
|
||||||
const refreshFormat = setInterval(() => {
|
const refreshFormat = setInterval(() => {
|
||||||
@@ -30,8 +28,7 @@ export function HydratePlayerAuth(): null {
|
|||||||
try {
|
try {
|
||||||
if (usePlayerSessionStore.getState().currencies.length === 0) {
|
if (usePlayerSessionStore.getState().currencies.length === 0) {
|
||||||
try {
|
try {
|
||||||
const currencies = await getPublicCurrencies();
|
await ensureCurrencyCatalogLoaded();
|
||||||
setCurrencies(currencies.items);
|
|
||||||
} catch {
|
} catch {
|
||||||
// 币种元数据失败时不影响登录态恢复。
|
// 币种元数据失败时不影响登录态恢复。
|
||||||
}
|
}
|
||||||
@@ -50,7 +47,7 @@ export function HydratePlayerAuth(): null {
|
|||||||
return () => {
|
return () => {
|
||||||
clearInterval(refreshFormat);
|
clearInterval(refreshFormat);
|
||||||
};
|
};
|
||||||
}, [restoreBearerToken, setCurrencies, setProfile]);
|
}, [restoreBearerToken, setProfile]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/features/player/player-auth-gate.tsx
Normal file
55
src/features/player/player-auth-gate.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
|
function subscribeHydration(onStoreChange: () => void): () => void {
|
||||||
|
let cancelled = false;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!cancelled) onStoreChange();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHydratedSnapshot(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerHydratedSnapshot(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerAuthGate({ children }: { children: ReactNode }): ReactNode {
|
||||||
|
const router = useRouter();
|
||||||
|
const bearerToken = usePlayerSessionStore((state) => state.bearerToken);
|
||||||
|
const hasHydrated = useSyncExternalStore(
|
||||||
|
subscribeHydration,
|
||||||
|
getHydratedSnapshot,
|
||||||
|
getServerHydratedSnapshot,
|
||||||
|
);
|
||||||
|
const redirectStarted = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasHydrated || bearerToken || redirectStarted.current) return;
|
||||||
|
redirectStarted.current = true;
|
||||||
|
router.replace("/login");
|
||||||
|
}, [bearerToken, hasHydrated, router]);
|
||||||
|
|
||||||
|
if (!hasHydrated || !bearerToken) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-1 items-center justify-center bg-white"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Loader2 className="size-8 animate-spin text-red-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ export function PlayerLoginForm({
|
|||||||
}: PlayerLoginFormProps) {
|
}: PlayerLoginFormProps) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const isDesktop = variant === "desktop";
|
const isDesktop = variant === "desktop";
|
||||||
|
const fieldIdPrefix = isDesktop ? "login-desktop" : "login-mobile";
|
||||||
|
const usernameId = `${fieldIdPrefix}-user`;
|
||||||
|
const passwordId = `${fieldIdPrefix}-pass`;
|
||||||
|
const captchaId = `${fieldIdPrefix}-captcha`;
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"h-10 rounded-xl border-gray-200 bg-white text-base shadow-none";
|
"h-10 rounded-xl border-gray-200 bg-white text-base shadow-none";
|
||||||
@@ -55,7 +59,7 @@ export function PlayerLoginForm({
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="login-user" className="text-gray-700">
|
<Label htmlFor={usernameId} className="text-gray-700">
|
||||||
{t("login.username")}
|
{t("login.username")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -64,7 +68,7 @@ export function PlayerLoginForm({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
id="login-user"
|
id={usernameId}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => onUsernameChange(e.target.value)}
|
onChange={(e) => onUsernameChange(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -76,7 +80,7 @@ export function PlayerLoginForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="login-pass" className="text-gray-700">
|
<Label htmlFor={passwordId} className="text-gray-700">
|
||||||
{t("login.password")}
|
{t("login.password")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -85,7 +89,7 @@ export function PlayerLoginForm({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
id="login-pass"
|
id={passwordId}
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => onPasswordChange(e.target.value)}
|
onChange={(e) => onPasswordChange(e.target.value)}
|
||||||
@@ -110,7 +114,7 @@ export function PlayerLoginForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="login-captcha" className="text-gray-700">
|
<Label htmlFor={captchaId} className="text-gray-700">
|
||||||
{t("login.captcha")}
|
{t("login.captcha")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex items-stretch gap-3">
|
<div className="flex items-stretch gap-3">
|
||||||
@@ -120,7 +124,7 @@ export function PlayerLoginForm({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
id="login-captcha"
|
id={captchaId}
|
||||||
name="captcha"
|
name="captcha"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={captchaCode}
|
value={captchaCode}
|
||||||
@@ -164,4 +168,4 @@ export function PlayerLoginForm({
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
|
import { useI18nHydrated } from "@/components/i18n-hydration-provider";
|
||||||
import { useHydrationSafeEntryT } from "@/i18n/hydration-t";
|
import { useHydrationSafeEntryT } from "@/i18n/hydration-t";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ function stripSearchParamFromBrowserUrl(name: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerLoginScreen(): React.ReactElement {
|
export function PlayerLoginScreen(): React.ReactElement {
|
||||||
const { t: tErrors } = useTranslation("entry");
|
const i18nHydrated = useI18nHydrated();
|
||||||
const t = useHydrationSafeEntryT();
|
const t = useHydrationSafeEntryT();
|
||||||
const tRef = useRef(t);
|
const tRef = useRef(t);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,8 +43,9 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken);
|
const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken);
|
||||||
const setProfile = usePlayerSessionStore((s) => s.setProfile);
|
const setProfile = usePlayerSessionStore((s) => s.setProfile);
|
||||||
const clearBearerToken = usePlayerSessionStore((s) => s.clearBearerToken);
|
const clearBearerToken = usePlayerSessionStore((s) => s.clearBearerToken);
|
||||||
const sessionExpiredHandled = useRef(false);
|
const sessionStatusHandled = useRef(false);
|
||||||
const passwordChangedHandled = useRef(false);
|
const passwordChangedHandled = useRef(false);
|
||||||
|
const captchaInitialized = useRef(false);
|
||||||
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -71,29 +72,28 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
if (captchaInitialized.current) return;
|
||||||
|
captchaInitialized.current = true;
|
||||||
void (async () => {
|
void loadCaptcha();
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await loadCaptcha();
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [loadCaptcha]);
|
}, [loadCaptcha]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionExpiredHandled.current) return;
|
if (!i18nHydrated) return;
|
||||||
if (searchParams.get("session") !== "expired") return;
|
if (sessionStatusHandled.current) return;
|
||||||
|
const sessionStatus = searchParams.get("session");
|
||||||
|
if (sessionStatus !== "expired" && sessionStatus !== "replaced") return;
|
||||||
|
|
||||||
sessionExpiredHandled.current = true;
|
sessionStatusHandled.current = true;
|
||||||
clearBearerToken();
|
clearBearerToken();
|
||||||
toast.error(tErrors("errors.sessionExpired"));
|
toast.error(
|
||||||
|
t(
|
||||||
|
sessionStatus === "replaced"
|
||||||
|
? "errors.sessionReplaced"
|
||||||
|
: "errors.sessionExpired",
|
||||||
|
),
|
||||||
|
);
|
||||||
stripSearchParamFromBrowserUrl("session");
|
stripSearchParamFromBrowserUrl("session");
|
||||||
}, [clearBearerToken, searchParams, tErrors]);
|
}, [clearBearerToken, i18nHydrated, searchParams, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (passwordChangedHandled.current) return;
|
if (passwordChangedHandled.current) return;
|
||||||
@@ -118,7 +118,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!captchaCode.trim()) {
|
if (!captchaCode.trim()) {
|
||||||
toast.error(t("login.captchaRequired"));
|
toast.error(t("login.captchaCodeRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-pan
|
|||||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
|
import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
|
||||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
@@ -69,10 +70,8 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
}
|
}
|
||||||
}, [activeCurrency, drawNo, t]);
|
}, [activeCurrency, drawNo, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
|
||||||
});
|
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { PlayerListPagination } from "@/components/layout/player-list-pagination
|
|||||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||||
|
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
@@ -100,10 +101,8 @@ export function DrawResultsListScreen() {
|
|||||||
}
|
}
|
||||||
}, [activeCurrency, businessDate, t]);
|
}, [activeCurrency, businessDate, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void fetchList(1, false);
|
||||||
void fetchList(1, false);
|
|
||||||
});
|
|
||||||
}, [fetchList]);
|
}, [fetchList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BriefcaseBusiness, CheckCircle2, ChevronDown, Clock3, RefreshCw, XIcon
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getTicketDrawMyMatch, getTicketItems } from "@/api/ticket-items";
|
import { getTicketItems } from "@/api/ticket-items";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -30,6 +30,27 @@ type WinningCheckResult = {
|
|||||||
tickets: TicketItemListRow[];
|
tickets: TicketItemListRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function matchForTickets(
|
||||||
|
drawNo: string,
|
||||||
|
tickets: TicketItemListRow[],
|
||||||
|
): TicketDrawMyMatchPayload {
|
||||||
|
const winningTickets = tickets.filter(
|
||||||
|
(ticket) => ticket.win_amount > 0 || ticket.jackpot_win_amount > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
draw_no: drawNo,
|
||||||
|
hit_numbers_4d: [],
|
||||||
|
total_win_minor: tickets.reduce((sum, ticket) => sum + ticket.win_amount, 0),
|
||||||
|
total_jackpot_win_minor: tickets.reduce(
|
||||||
|
(sum, ticket) => sum + ticket.jackpot_win_amount,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
winning_ticket_count: winningTickets.length,
|
||||||
|
has_bets: tickets.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type DrawWinningCheckPanelProps = {
|
export type DrawWinningCheckPanelProps = {
|
||||||
drawNo: string;
|
drawNo: string;
|
||||||
businessDate?: string | null;
|
businessDate?: string | null;
|
||||||
@@ -66,15 +87,15 @@ export function DrawWinningCheckPanel({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [match, tickets] = await Promise.all([
|
const isTicketNumber = /^TK\d+$/i.test(normalizedTicketNo);
|
||||||
getTicketDrawMyMatch(drawNo),
|
const ticketResult = await getTicketItems({
|
||||||
getTicketItems({
|
draw_no: drawNo,
|
||||||
draw_no: drawNo,
|
ticket_no: isTicketNumber ? normalizedTicketNo.toUpperCase() : undefined,
|
||||||
number: normalizedTicketNo,
|
number: isTicketNumber ? undefined : normalizedTicketNo,
|
||||||
per_page: 10,
|
per_page: 50,
|
||||||
page: 1,
|
page: 1,
|
||||||
}),
|
});
|
||||||
]);
|
const match = matchForTickets(drawNo, ticketResult.items);
|
||||||
const next = {
|
const next = {
|
||||||
draw: {
|
draw: {
|
||||||
draw_id: "",
|
draw_id: "",
|
||||||
@@ -88,7 +109,7 @@ export function DrawWinningCheckPanel({
|
|||||||
result_items: [],
|
result_items: [],
|
||||||
} satisfies DrawResultListItem,
|
} satisfies DrawResultListItem,
|
||||||
match,
|
match,
|
||||||
tickets: tickets.items,
|
tickets: ticketResult.items,
|
||||||
};
|
};
|
||||||
setResult(next);
|
setResult(next);
|
||||||
setRecent((current) =>
|
setRecent((current) =>
|
||||||
@@ -331,7 +352,11 @@ function WinningResultDialog({
|
|||||||
className="h-12 rounded-xl bg-[#07459f] text-base font-black text-white hover:bg-[#063b88]"
|
className="h-12 rounded-xl bg-[#07459f] text-base font-black text-white hover:bg-[#063b88]"
|
||||||
render={
|
render={
|
||||||
<Link
|
<Link
|
||||||
href={`/orders?draw_no=${encodeURIComponent(data.draw.draw_no)}&number=${encodeURIComponent(query)}`}
|
href={
|
||||||
|
/^TK\d+$/i.test(query) && firstTicket
|
||||||
|
? `/orders/${encodeURIComponent(firstTicket.ticket_no)}`
|
||||||
|
: `/orders?draw_no=${encodeURIComponent(data.draw.draw_no)}&number=${encodeURIComponent(query)}`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -351,4 +376,4 @@ function WinningResultDialog({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BookOpenText, Loader2, TriangleAlert } from "lucide-react";
|
import { BookOpenText, Loader2, TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
@@ -8,6 +8,16 @@ import { getPublicSettings } from "@/api";
|
|||||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
import { resolvePlayRulesHtml } from "@/lib/play-rules-html";
|
import { resolvePlayRulesHtml } from "@/lib/play-rules-html";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
|
||||||
|
const DEFAULT_RULE_SECTIONS = [
|
||||||
|
["dimensions", ["d4", "d3", "d2"]],
|
||||||
|
["bigSmall", ["big", "small"]],
|
||||||
|
["positions", ["d4", "d3", "d2"]],
|
||||||
|
["box", ["straight", "box", "ibox", "mbox", "roll", "half"]],
|
||||||
|
["attributes", ["headTail", "oddEven", "digitSize"]],
|
||||||
|
["wallet", ["rebate", "jackpot", "close", "soldOut"]],
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function PlayRulesScreen() {
|
export function PlayRulesScreen() {
|
||||||
const { t, i18n } = useTranslation("player");
|
const { t, i18n } = useTranslation("player");
|
||||||
@@ -15,11 +25,13 @@ export function PlayRulesScreen() {
|
|||||||
const [contentState, setContentState] = useState<"html" | "empty" | "error">("empty");
|
const [contentState, setContentState] = useState<"html" | "empty" | "error">("empty");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
async function loadRules() {
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getPublicSettings("frontend");
|
const res = await getPublicSettings("frontend");
|
||||||
const html = resolvePlayRulesHtml(res.items, i18n.language);
|
const html = resolvePlayRulesHtml(res.items, i18n.language);
|
||||||
|
if (cancelled) return;
|
||||||
if (html) {
|
if (html) {
|
||||||
setHtmlContent(html);
|
setHtmlContent(html);
|
||||||
setContentState("html");
|
setContentState("html");
|
||||||
@@ -28,13 +40,17 @@ export function PlayRulesScreen() {
|
|||||||
setContentState("empty");
|
setContentState("empty");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if (cancelled) return;
|
||||||
setHtmlContent(null);
|
setHtmlContent(null);
|
||||||
setContentState("error");
|
setContentState("error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
})();
|
||||||
void loadRules();
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [i18n.language, t]);
|
}, [i18n.language, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,26 +66,62 @@ export function PlayRulesScreen() {
|
|||||||
<div className="flex min-h-[18rem] items-center justify-center">
|
<div className="flex min-h-[18rem] items-center justify-center">
|
||||||
<Loader2 className="size-6 animate-spin text-slate-400" />
|
<Loader2 className="size-6 animate-spin text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
) : contentState !== "html" ? (
|
) : contentState === "html" ? (
|
||||||
<div className="flex min-h-[18rem] flex-col items-center justify-center px-4 text-center">
|
|
||||||
<span className="flex size-12 items-center justify-center rounded-full bg-[#eef4ff] text-[#2d63e2]">
|
|
||||||
{contentState === "error" ? (
|
|
||||||
<TriangleAlert className="size-5" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<BookOpenText className="size-5" aria-hidden />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<p className="mt-3 text-sm font-bold text-slate-700">
|
|
||||||
{contentState === "error"
|
|
||||||
? t("rules.loadFailed", { defaultValue: "规则加载失败" })
|
|
||||||
: t("rules.empty", { defaultValue: "暂无玩法规则说明" })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
<div
|
||||||
className="prose prose-sm max-w-none text-slate-700 lg:prose-base lg:leading-7"
|
className="prose prose-sm max-w-none text-slate-700 lg:prose-base lg:leading-7"
|
||||||
dangerouslySetInnerHTML={{ __html: htmlContent || "" }}
|
dangerouslySetInnerHTML={{ __html: htmlContent || "" }}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{contentState === "error" ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||||
|
<TriangleAlert className="size-4 shrink-0" aria-hidden />
|
||||||
|
{t("rules.loadFailed", { defaultValue: "规则加载失败" })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-[#dbe7fb] bg-[#f6f9ff] p-4 lg:p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white text-[#2d63e2] shadow-sm">
|
||||||
|
<BookOpenText className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-black text-[#0b3f96]">
|
||||||
|
{t("rules.quick.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||||
|
{t("rules.quick.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{DEFAULT_RULE_SECTIONS.map(([section, items]) => (
|
||||||
|
<section
|
||||||
|
key={section}
|
||||||
|
className="rounded-2xl border border-[#e5edf8] bg-white p-4 shadow-[0_6px_20px_rgba(15,23,42,0.04)] lg:p-5"
|
||||||
|
>
|
||||||
|
<h2 className="font-black text-[#0b3f96]">
|
||||||
|
{t(`rules.sections.${section}.title`)}
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-3 space-y-2.5 text-sm leading-6 text-slate-600">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item} className="flex gap-2">
|
||||||
|
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-[#e5002c]" aria-hidden />
|
||||||
|
<span>{t(`rules.sections.${section}.${item}`)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 rounded-2xl border border-[#e5edf8] bg-[#f8fbff] p-4 text-xs leading-5 text-slate-500 lg:p-5">
|
||||||
|
<p>{t("rules.footer.config")}</p>
|
||||||
|
<p>{t("rules.footer.phaseTwo")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useMemo, type Ref } from "react";
|
import { useMemo, type Ref } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -27,7 +29,6 @@ export const CREDIT_FLOW_FILTERS: { value: string; labelKey: string }[] = [
|
|||||||
{ value: "", labelKey: "wallet.creditFlow.all" },
|
{ value: "", labelKey: "wallet.creditFlow.all" },
|
||||||
{ value: "bet", labelKey: "wallet.creditFlow.bet" },
|
{ value: "bet", labelKey: "wallet.creditFlow.bet" },
|
||||||
{ value: "game_settlement", labelKey: "wallet.creditFlow.game_settlement" },
|
{ value: "game_settlement", labelKey: "wallet.creditFlow.game_settlement" },
|
||||||
{ value: "rebate", labelKey: "wallet.creditFlow.rebate" },
|
|
||||||
{ value: "bill_settlement", labelKey: "wallet.creditFlow.bill_settlement" },
|
{ value: "bill_settlement", labelKey: "wallet.creditFlow.bill_settlement" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -40,8 +41,8 @@ const FLOW_LABEL_FALLBACKS: Record<string, string> = {
|
|||||||
"wallet.flow.refund": "退款",
|
"wallet.flow.refund": "退款",
|
||||||
"wallet.flow.reversal": "冲正",
|
"wallet.flow.reversal": "冲正",
|
||||||
"wallet.creditFlow.all": "全部",
|
"wallet.creditFlow.all": "全部",
|
||||||
"wallet.creditFlow.bet": "下注冻结",
|
"wallet.creditFlow.bet": "下注",
|
||||||
"wallet.creditFlow.game_settlement": "开奖结算",
|
"wallet.creditFlow.game_settlement": "开奖结果",
|
||||||
"wallet.creditFlow.rebate": "账期回水",
|
"wallet.creditFlow.rebate": "账期回水",
|
||||||
"wallet.creditFlow.credit_release": "释额",
|
"wallet.creditFlow.credit_release": "释额",
|
||||||
"wallet.creditFlow.bill_settlement": "账期收付",
|
"wallet.creditFlow.bill_settlement": "账期收付",
|
||||||
@@ -135,7 +136,7 @@ export function WalletLogsBlock({
|
|||||||
const resolvedTitle =
|
const resolvedTitle =
|
||||||
title
|
title
|
||||||
?? (creditMode
|
?? (creditMode
|
||||||
? t("wallet.creditFlowsTitle", { defaultValue: "信用流水" })
|
? t("wallet.creditFlowsTitle", { defaultValue: "额度与结算记录" })
|
||||||
: t("wallet.flowsTitle", { defaultValue: "钱包流水" }));
|
: t("wallet.flowsTitle", { defaultValue: "钱包流水" }));
|
||||||
const filters = useMemo(() => {
|
const filters = useMemo(() => {
|
||||||
const source = creditMode ? CREDIT_FLOW_FILTERS : WALLET_FLOW_FILTERS;
|
const source = creditMode ? CREDIT_FLOW_FILTERS : WALLET_FLOW_FILTERS;
|
||||||
@@ -197,7 +198,10 @@ export function WalletLogsBlock({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
<div className="lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||||
<div className="hidden lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(8rem,0.9fr)_minmax(7rem,0.8fr)_minmax(7rem,0.8fr)_minmax(6rem,0.7fr)] lg:items-center lg:gap-3 lg:border-b lg:border-[#e9eff8] lg:bg-[#f8fbff] lg:px-5 lg:py-3">
|
<div className={creditMode
|
||||||
|
? "hidden lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)] lg:items-center lg:gap-3 lg:border-b lg:border-[#e9eff8] lg:bg-[#f8fbff] lg:px-5 lg:py-3"
|
||||||
|
: "hidden lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(8rem,0.9fr)_minmax(7rem,0.8fr)_minmax(7rem,0.8fr)_minmax(6rem,0.7fr)] lg:items-center lg:gap-3 lg:border-b lg:border-[#e9eff8] lg:bg-[#f8fbff] lg:px-5 lg:py-3"}
|
||||||
|
>
|
||||||
<p className="player-pc-table-head font-bold text-[#59739f]">
|
<p className="player-pc-table-head font-bold text-[#59739f]">
|
||||||
{t("wallet.logType", { defaultValue: "类型" })}
|
{t("wallet.logType", { defaultValue: "类型" })}
|
||||||
</p>
|
</p>
|
||||||
@@ -215,17 +219,25 @@ export function WalletLogsBlock({
|
|||||||
? t("wallet.creditAvailableAfter")
|
? t("wallet.creditAvailableAfter")
|
||||||
: t("wallet.balanceAfter")}
|
: t("wallet.balanceAfter")}
|
||||||
</p>
|
</p>
|
||||||
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
|
{!creditMode ? (
|
||||||
{t("orders.status")}
|
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
|
||||||
</p>
|
{t("orders.status")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2 lg:space-y-0">
|
<ul className="space-y-2 lg:space-y-0">
|
||||||
{logs.items.map((row) => (
|
{logs.items.map((row) => creditMode ? (
|
||||||
|
<CreditActivityRow
|
||||||
|
key={row.log_id}
|
||||||
|
item={row}
|
||||||
|
currency={currency}
|
||||||
|
settlementTimeZone={settlementTimeZone}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<LogRow
|
<LogRow
|
||||||
key={row.log_id}
|
key={row.log_id}
|
||||||
item={row}
|
item={row}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
creditMode={creditMode}
|
|
||||||
settlementTimeZone={settlementTimeZone}
|
settlementTimeZone={settlementTimeZone}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -272,6 +284,158 @@ export function WalletLogsBlock({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function creditActivityLabel(
|
||||||
|
item: WalletLogItem,
|
||||||
|
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||||
|
): string {
|
||||||
|
const labels: Record<string, { key: string; fallback: string }> = {
|
||||||
|
bet_pending: { key: "wallet.activity.bet", fallback: "下注" },
|
||||||
|
settled_loss: { key: "wallet.activity.settledLoss", fallback: "未中奖" },
|
||||||
|
settled_win: { key: "wallet.activity.settledWin", fallback: "中奖" },
|
||||||
|
settled_even: { key: "wallet.activity.result", fallback: "开奖结果" },
|
||||||
|
bet_refund: { key: "wallet.activity.refunded", fallback: "已退款" },
|
||||||
|
period_paid: { key: "wallet.activity.periodPaid", fallback: "已付款" },
|
||||||
|
period_received: { key: "wallet.activity.periodReceived", fallback: "已收款" },
|
||||||
|
};
|
||||||
|
const resolved = labels[item.biz_type];
|
||||||
|
if (!resolved) {
|
||||||
|
return t("wallet.activity.result", { defaultValue: "开奖结果" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return t(resolved.key, { defaultValue: resolved.fallback });
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreditActivityRow({
|
||||||
|
item,
|
||||||
|
currency,
|
||||||
|
settlementTimeZone,
|
||||||
|
}: {
|
||||||
|
item: WalletLogItem;
|
||||||
|
currency: string;
|
||||||
|
settlementTimeZone?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
const ccy = item.currency_code || currency;
|
||||||
|
const amount = Number(item.net_amount ?? item.amount ?? 0);
|
||||||
|
const amountTone = amount > 0
|
||||||
|
? "text-emerald-700"
|
||||||
|
: amount < 0
|
||||||
|
? "text-[#e5002c]"
|
||||||
|
: "text-[#32518d]";
|
||||||
|
const amountLabel = amount === 0
|
||||||
|
? formatMinorAsCurrency(0, ccy)
|
||||||
|
: `${amount > 0 ? "+" : "−"}${formatMinorAsCurrency(Math.abs(amount), ccy)}`;
|
||||||
|
const timeLabel = settlementTimeZone
|
||||||
|
? formatLotteryInstantInTimeZone(item.created_at, settlementTimeZone)
|
||||||
|
: formatPlayerInstant(item.created_at);
|
||||||
|
const balanceLabel = item.balance_after == null
|
||||||
|
? "—"
|
||||||
|
: formatMinorAsCurrency(item.balance_after, ccy);
|
||||||
|
const refLabel = item.draw_no
|
||||||
|
? `${item.draw_no}${item.order_no ? ` · ${item.order_no}` : ""}`
|
||||||
|
: item.order_no ?? (item.settlement_bill_id ? `#${item.settlement_bill_id}` : "—");
|
||||||
|
const detailHref = item.ticket_no
|
||||||
|
? `/orders/${encodeURIComponent(item.ticket_no)}`
|
||||||
|
: item.draw_no
|
||||||
|
? `/orders?draw_no=${encodeURIComponent(item.draw_no)}`
|
||||||
|
: null;
|
||||||
|
const showStatus = item.activity_status === "pending" || item.activity_status === "reversed";
|
||||||
|
const statusLabel = item.activity_status === "reversed"
|
||||||
|
? t("wallet.activity.reversed", { defaultValue: "已冲正" })
|
||||||
|
: item.activity_kind === "bet"
|
||||||
|
? t("wallet.activity.awaitingDraw", { defaultValue: "待开奖" })
|
||||||
|
: t("wallet.activity.processing", { defaultValue: "处理中" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-2xl border border-[#e1eaf6] bg-white px-3 py-3 text-sm shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)] lg:items-center lg:gap-3 lg:rounded-none lg:border-x-0 lg:border-t-0 lg:border-b-[#edf2f8] lg:px-5 lg:py-4 lg:shadow-none lg:last:border-b-0 lg:hover:bg-[#fbfdff]">
|
||||||
|
<div className="flex items-start justify-between gap-3 lg:contents">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-base font-black leading-tight text-[#101a33] lg:text-sm">
|
||||||
|
{creditActivityLabel(item, t)}
|
||||||
|
</p>
|
||||||
|
{showStatus ? (
|
||||||
|
<span className={item.activity_status === "reversed"
|
||||||
|
? "inline-flex rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-black text-slate-600"
|
||||||
|
: "inline-flex rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10px] font-black text-blue-700"}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-xs font-medium text-slate-500 lg:hidden">{timeLabel}</p>
|
||||||
|
<p className="mt-1 truncate font-mono text-[11px] text-slate-400 lg:hidden">{refLabel}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={`shrink-0 text-right text-lg font-black tabular-nums lg:hidden ${amountTone}`}>
|
||||||
|
{amountLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="hidden text-sm text-slate-600 lg:block">{timeLabel}</p>
|
||||||
|
<div className="hidden min-w-0 lg:block">
|
||||||
|
{detailHref ? (
|
||||||
|
<Link className="block truncate font-mono text-xs text-[#32518d] hover:underline" href={detailHref}>
|
||||||
|
{refLabel}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="truncate font-mono text-xs text-slate-400">{refLabel}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={`hidden text-right text-sm font-black tabular-nums lg:block ${amountTone}`}>
|
||||||
|
{amountLabel}
|
||||||
|
</p>
|
||||||
|
<p className="hidden text-right font-mono text-sm font-black tabular-nums text-[#32518d] lg:block">
|
||||||
|
{balanceLabel}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details className="group mt-3 rounded-xl bg-[#f8fbff] lg:col-span-5 lg:mt-0">
|
||||||
|
<summary className="flex min-h-9 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-bold text-[#32518d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2d63e2] [&::-webkit-details-marker]:hidden">
|
||||||
|
{t("wallet.activity.details", { defaultValue: "查看明细" })}
|
||||||
|
<ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />
|
||||||
|
</summary>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2 border-t border-[#e1eaf6] px-3 py-3 text-xs lg:grid-cols-4">
|
||||||
|
{Number(item.stake_amount ?? 0) > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-500">{t("wallet.activity.stake", { defaultValue: "下注金额" })}</p>
|
||||||
|
<p className="mt-1 font-mono font-black text-[#101a33]">{formatMinorAsCurrency(item.stake_amount ?? 0, ccy)}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{Number(item.win_amount ?? 0) > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-500">{t("wallet.activity.win", { defaultValue: "中奖金额" })}</p>
|
||||||
|
<p className="mt-1 font-mono font-black text-emerald-700">+{formatMinorAsCurrency(item.win_amount ?? 0, ccy)}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{Number(item.rebate_amount ?? 0) > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-500">{t("wallet.activity.rebate", { defaultValue: "回水" })}</p>
|
||||||
|
<p className="mt-1 font-mono font-black text-emerald-700">+{formatMinorAsCurrency(item.rebate_amount ?? 0, ccy)}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-500">{t("wallet.creditAvailableAfter", { defaultValue: "变更后可用额度" })}</p>
|
||||||
|
<p className="mt-1 font-mono font-black text-[#32518d]">{balanceLabel}</p>
|
||||||
|
</div>
|
||||||
|
{Number(item.ticket_count ?? 0) > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-500">{t("wallet.activity.ticketCount", { defaultValue: "注单数量" })}</p>
|
||||||
|
<p className="mt-1 font-black text-[#101a33]">{t("wallet.activity.ticketCountValue", { defaultValue: "{{count}}项", count: item.ticket_count })}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{detailHref ? (
|
||||||
|
<div className="col-span-2 flex items-end justify-end lg:col-span-1">
|
||||||
|
<Link className="font-bold text-[#2d63e2] hover:underline" href={detailHref}>
|
||||||
|
{t("wallet.activity.viewOrder", { defaultValue: "查看注单" })}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LogRow({
|
export function LogRow({
|
||||||
item,
|
item,
|
||||||
currency,
|
currency,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowDownLeft, ArrowUpRight, Wallet } from "lucide-react";
|
import { ArrowDownLeft, ArrowUpRight, ChevronDown, Wallet } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@@ -18,8 +18,9 @@ import { PlayerMoneyDisplay } from "@/components/player-money-display";
|
|||||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAmount, formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { formatLotteryInstantInTimeZone, formatPlayerInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstantInTimeZone, formatPlayerInstant } from "@/lib/player-datetime";
|
||||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||||
@@ -101,91 +102,86 @@ function CreditSettlementBillsBlock({
|
|||||||
const netPending = Number(summary?.net_pending ?? 0);
|
const netPending = Number(summary?.net_pending ?? 0);
|
||||||
const pendingCount = Number(summary?.pending_count ?? 0);
|
const pendingCount = Number(summary?.pending_count ?? 0);
|
||||||
const netDirection = netPending >= 0 ? "receivable" : "payable";
|
const netDirection = netPending >= 0 ? "receivable" : "payable";
|
||||||
|
const hasBills = (data?.items.length ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full flex-col rounded-2xl border border-[#dce7f7] bg-white px-3 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:px-4 lg:py-4">
|
<section className="flex h-full flex-col rounded-2xl border border-[#dce7f7] bg-white px-3 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:px-4 lg:py-4">
|
||||||
<div className="flex shrink-0 items-start justify-between gap-3">
|
<div className="flex shrink-0 items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-sm font-black text-[#0b3f96] lg:text-base">
|
<h2 className="text-sm font-black text-[#0b3f96] lg:text-base">
|
||||||
{t("wallet.settlementTitle", { defaultValue: "我的账期账单" })}
|
{t("wallet.settlementTitle", { defaultValue: "账期结算" })}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-xs text-slate-500 lg:line-clamp-2">
|
<p className="mt-1 text-xs text-slate-500 lg:line-clamp-2">
|
||||||
{t("wallet.settlementHint", { defaultValue: "只显示未结清账单,中奖超出授信的部分在这里结算。" })}
|
{t("wallet.settlementHint", { defaultValue: "查看当前需要收取或支付的金额。" })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 rounded-full bg-[#f1f6ff] px-2.5 py-1 text-xs font-black text-[#32518d]">
|
{pendingCount > 0 ? (
|
||||||
{t("wallet.settlementPendingCount", { defaultValue: "{{count}}笔", count: pendingCount })}
|
<span className="shrink-0 rounded-full bg-[#f1f6ff] px-2.5 py-1 text-xs font-black text-[#32518d]">
|
||||||
</span>
|
{t("wallet.settlementPendingCount", { defaultValue: "{{count}}笔", count: pendingCount })}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 grid shrink-0 grid-cols-2 gap-2">
|
|
||||||
<div className="rounded-xl bg-emerald-50 px-3 py-2">
|
|
||||||
<p className="text-xs font-bold text-emerald-700">
|
|
||||||
{t("wallet.pendingReceivable", { defaultValue: "待收" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-sm font-black tabular-nums text-emerald-700">
|
|
||||||
{formatMinorAsCurrency(summary?.pending_receivable ?? 0, currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-red-50 px-3 py-2">
|
|
||||||
<p className="text-xs font-bold text-red-700">
|
|
||||||
{t("wallet.pendingPayable", { defaultValue: "待付" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-sm font-black tabular-nums text-red-700">
|
|
||||||
{formatMinorAsCurrency(summary?.pending_payable ?? 0, currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 rounded-xl bg-[#f8fbff] px-3 py-2 text-xs">
|
|
||||||
<span className="font-semibold text-slate-500">
|
|
||||||
{netDirection === "receivable"
|
|
||||||
? t("wallet.netReceivable", { defaultValue: "净待收" })
|
|
||||||
: t("wallet.netPayable", { defaultValue: "净待付" })}
|
|
||||||
</span>
|
</span>
|
||||||
<span className={netDirection === "receivable" ? "ml-2 font-mono font-black text-emerald-700" : "ml-2 font-mono font-black text-red-700"}>
|
) : null}
|
||||||
{formatMinorAsCurrency(Math.abs(netPending), currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data === null ? (
|
{data === null ? (
|
||||||
<Skeleton className="mt-3 min-h-20 w-full flex-1 rounded-xl" />
|
<Skeleton className="mt-3 min-h-20 w-full flex-1 rounded-xl" />
|
||||||
) : data.items.length === 0 ? (
|
) : !hasBills ? (
|
||||||
<p className="mt-3 flex min-h-20 flex-1 items-center justify-center rounded-xl border border-dashed border-[#dce7f7] px-3 text-center text-sm text-slate-500">
|
<p className="mt-3 flex min-h-24 flex-1 items-center justify-center rounded-xl bg-emerald-50 px-3 text-center text-sm font-bold text-emerald-700">
|
||||||
{t("wallet.noSettlementBills", { defaultValue: "暂无待结算账单" })}
|
{t("wallet.settlementClear", { defaultValue: "当前无需结算" })}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-3 min-h-0 max-h-[16rem] flex-1 space-y-2 overflow-y-auto lg:max-h-none">
|
<>
|
||||||
{data.items.map((item) => {
|
<div className={netDirection === "receivable"
|
||||||
const receivable = item.direction === "receivable";
|
? "mt-3 rounded-xl bg-emerald-50 px-3 py-3 text-emerald-700"
|
||||||
return (
|
: "mt-3 rounded-xl bg-red-50 px-3 py-3 text-red-700"}
|
||||||
<li key={item.id} className="rounded-xl border border-[#e1eaf6] px-3 py-2">
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<p className="text-xs font-bold">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
{netDirection === "receivable"
|
||||||
<span className={receivable ? "rounded-full bg-emerald-100 p-1 text-emerald-700" : "rounded-full bg-red-100 p-1 text-red-700"}>
|
? t("wallet.netReceivable", { defaultValue: "净待收" })
|
||||||
{receivable ? <ArrowDownLeft className="size-3.5" /> : <ArrowUpRight className="size-3.5" />}
|
: t("wallet.netPayable", { defaultValue: "净待付" })}
|
||||||
</span>
|
</p>
|
||||||
<div className="min-w-0">
|
<p className="mt-1 font-mono text-lg font-black tabular-nums">
|
||||||
<p className="truncate text-sm font-black text-[#101a33]">
|
{formatMinorAsCurrency(Math.abs(netPending), currency)}
|
||||||
{receivable
|
</p>
|
||||||
? t("wallet.agentPaysPlayer", { defaultValue: "代理应付玩家" })
|
</div>
|
||||||
: t("wallet.playerPaysAgent", { defaultValue: "玩家应付代理" })}
|
<details className="group mt-3 rounded-xl border border-[#e1eaf6] bg-[#f8fbff]">
|
||||||
</p>
|
<summary className="flex min-h-10 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-sm font-bold text-[#32518d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2d63e2] [&::-webkit-details-marker]:hidden">
|
||||||
<p className="mt-0.5 text-[11px] text-slate-500">
|
{t("wallet.settlementDetails", { defaultValue: "查看账期明细" })}
|
||||||
{formatPeriodRange(item, settlementTimeZone)}
|
<ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />
|
||||||
</p>
|
</summary>
|
||||||
|
<ul className="max-h-[16rem] space-y-2 overflow-y-auto border-t border-[#e1eaf6] p-2">
|
||||||
|
{data.items.map((item) => {
|
||||||
|
const receivable = item.direction === "receivable";
|
||||||
|
return (
|
||||||
|
<li key={item.id} className="rounded-lg bg-white px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className={receivable ? "rounded-full bg-emerald-100 p-1 text-emerald-700" : "rounded-full bg-red-100 p-1 text-red-700"}>
|
||||||
|
{receivable ? <ArrowDownLeft className="size-3.5" aria-hidden /> : <ArrowUpRight className="size-3.5" aria-hidden />}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-black text-[#101a33]">
|
||||||
|
{receivable
|
||||||
|
? t("wallet.agentPaysPlayer", { defaultValue: "代理应付玩家" })
|
||||||
|
: t("wallet.playerPaysAgent", { defaultValue: "玩家应付代理" })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500">
|
||||||
|
{formatPeriodRange(item, settlementTimeZone)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<p className={receivable ? "font-mono text-sm font-black text-emerald-700" : "font-mono text-sm font-black text-red-700"}>
|
||||||
|
{formatMinorAsCurrency(item.unpaid_amount, currency)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500">{settlementStatusLabel(item.status, t)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
<div className="shrink-0 text-right">
|
);
|
||||||
<p className={receivable ? "font-mono text-sm font-black text-emerald-700" : "font-mono text-sm font-black text-red-700"}>
|
})}
|
||||||
{formatMinorAsCurrency(item.unpaid_amount, currency)}
|
</ul>
|
||||||
</p>
|
</details>
|
||||||
<p className="mt-0.5 text-[11px] text-slate-500">{settlementStatusLabel(item.status, t)}</p>
|
</>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -267,7 +263,7 @@ export function WalletScreen() {
|
|||||||
return nextLogs;
|
return nextLogs;
|
||||||
}, [currency, filter]);
|
}, [currency, filter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -307,7 +303,7 @@ export function WalletScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currency, t]); // eslint-disable-line react-hooks/exhaustive-deps -- 币种切换整页刷新;流水筛选见下方 effect
|
}, [currency, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filterInitializedRef.current) {
|
if (!filterInitializedRef.current) {
|
||||||
@@ -395,10 +391,12 @@ export function WalletScreen() {
|
|||||||
const displayMinor = isCreditPlayer
|
const displayMinor = isCreditPlayer
|
||||||
? Number(balance?.available_balance ?? 0)
|
? Number(balance?.available_balance ?? 0)
|
||||||
: Number(balance?.balance ?? 0);
|
: Number(balance?.balance ?? 0);
|
||||||
|
const creditLimitMinor = Number(balance?.credit_limit ?? 0);
|
||||||
|
const creditInUseMinor = Math.max(0, creditLimitMinor - displayMinor);
|
||||||
const panelTitle = !fundingModeKnown
|
const panelTitle = !fundingModeKnown
|
||||||
? t("wallet.loadingTitle", { defaultValue: "账户资金" })
|
? t("wallet.loadingTitle", { defaultValue: "账户资金" })
|
||||||
: isCreditPlayer
|
: isCreditPlayer
|
||||||
? t("wallet.creditTitle", { defaultValue: "信用" })
|
? t("wallet.creditTitle", { defaultValue: "额度" })
|
||||||
: t("wallet.title");
|
: t("wallet.title");
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
@@ -470,6 +468,7 @@ export function WalletScreen() {
|
|||||||
src="/entry/image5.png"
|
src="/entry/image5.png"
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
|
loading="eager"
|
||||||
className="pointer-events-none object-cover object-center"
|
className="pointer-events-none object-cover object-center"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
@@ -535,6 +534,7 @@ export function WalletScreen() {
|
|||||||
src="/entry/image5.png"
|
src="/entry/image5.png"
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
|
loading="eager"
|
||||||
className="pointer-events-none object-cover object-center"
|
className="pointer-events-none object-cover object-center"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
@@ -545,7 +545,7 @@ export function WalletScreen() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-semibold text-white/90 lg:text-base">
|
<p className="text-sm font-semibold text-white/90 lg:text-base">
|
||||||
{fundingModeKnown
|
{fundingModeKnown
|
||||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
? t("wallet.creditAvailable", { defaultValue: "可用额度" })
|
||||||
: t("wallet.loadingBalance", { defaultValue: "正在加载" })}
|
: t("wallet.loadingBalance", { defaultValue: "正在加载" })}
|
||||||
</p>
|
</p>
|
||||||
{loading || !fundingModeKnown ? (
|
{loading || !fundingModeKnown ? (
|
||||||
@@ -554,40 +554,43 @@ export function WalletScreen() {
|
|||||||
<PlayerMoneyDisplay
|
<PlayerMoneyDisplay
|
||||||
amountMinor={displayMinor}
|
amountMinor={displayMinor}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
|
showCurrencyCode={false}
|
||||||
className="mt-1 text-white lg:text-[1.75rem]"
|
className="mt-1 text-white lg:text-[1.75rem]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="mt-2 text-xs text-white/75 lg:text-sm">
|
<p className="mt-2 text-xs text-white/75 lg:text-sm">
|
||||||
{!fundingModeKnown
|
{!fundingModeKnown
|
||||||
? t("wallet.loadingAccountMode", { defaultValue: "正在确认资金模式…" })
|
? t("wallet.loadingAccountMode", { defaultValue: "正在确认资金模式…" })
|
||||||
: t("wallet.creditSummary", {
|
: t("wallet.creditAvailableHint", { defaultValue: "当前可用于下注" })}
|
||||||
defaultValue: "授信 {{limit}} · 已用 {{used}}",
|
|
||||||
limit: formatMinorAsCurrency(balance?.credit_limit ?? 0, currency),
|
|
||||||
used: formatMinorAsCurrency(balance?.used_credit ?? 0, currency),
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fundingModeKnown ? (
|
{fundingModeKnown ? (
|
||||||
<div className="relative mt-4 grid shrink-0 grid-cols-2 gap-2 lg:mt-auto lg:gap-3">
|
<details className="group relative mt-4 shrink-0 rounded-xl bg-white/15 backdrop-blur-[2px] lg:mt-auto">
|
||||||
<div className="rounded-xl bg-white/15 px-3 py-2.5 backdrop-blur-[2px]">
|
<summary className="flex min-h-10 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-sm font-bold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white [&::-webkit-details-marker]:hidden">
|
||||||
<p className="text-[11px] font-bold uppercase tracking-wide text-white/75">
|
{t("wallet.creditDetails", { defaultValue: "额度详情" })}
|
||||||
{t("wallet.creditLimit", { defaultValue: "授信额度" })}
|
<ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />
|
||||||
</p>
|
</summary>
|
||||||
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
|
<div className="grid grid-cols-2 gap-2 border-t border-white/15 p-2">
|
||||||
{formatMinorAsCurrency(balance?.credit_limit ?? 0, currency)}
|
<div className="rounded-lg bg-white/10 px-3 py-2">
|
||||||
</p>
|
<p className="text-[11px] font-bold text-white/75">
|
||||||
|
{t("wallet.creditLimit", { defaultValue: "总额度" })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
|
||||||
|
{formatMinorAmount(creditLimitMinor)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white/10 px-3 py-2">
|
||||||
|
<p className="text-[11px] font-bold text-white/75">
|
||||||
|
{t("wallet.usedCredit", { defaultValue: "使用中" })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
|
||||||
|
{formatMinorAmount(creditInUseMinor)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-white/15 px-3 py-2.5 backdrop-blur-[2px]">
|
</details>
|
||||||
<p className="text-[11px] font-bold uppercase tracking-wide text-white/75">
|
|
||||||
{t("wallet.usedCredit", { defaultValue: "已用信用" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
|
|
||||||
{formatMinorAsCurrency(balance?.used_credit ?? 0, currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
40
src/hooks/use-async-effect.ts
Normal file
40
src/hooks/use-async-effect.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, type DependencyList } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defer asynchronous side effects until the current effect instance survives
|
||||||
|
* React Strict Mode's development-only setup/cleanup probe.
|
||||||
|
*/
|
||||||
|
export function useAsyncEffect(
|
||||||
|
factory: () => void | (() => void) | Promise<void>,
|
||||||
|
deps: DependencyList,
|
||||||
|
): void {
|
||||||
|
const factoryRef = useRef(factory);
|
||||||
|
useEffect(() => {
|
||||||
|
factoryRef.current = factory;
|
||||||
|
}, [factory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cleanup: void | (() => void);
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
void Promise.resolve(factoryRef.current()).then((result) => {
|
||||||
|
if (cancelled) {
|
||||||
|
result?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanup = result;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cleanup?.();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
}
|
||||||
@@ -10,9 +10,31 @@ const RETRY_DELAY_MS = 2_000;
|
|||||||
|
|
||||||
let inflightCurrencyLoad: Promise<void> | null = null;
|
let inflightCurrencyLoad: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export function ensureCurrencyCatalogLoaded(): Promise<void> {
|
||||||
|
if (usePlayerSessionStore.getState().currencies.length > 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inflightCurrencyLoad !== null) {
|
||||||
|
return inflightCurrencyLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
inflightCurrencyLoad = getPublicCurrencies()
|
||||||
|
.then((data) => {
|
||||||
|
if (data.items.length === 0) {
|
||||||
|
throw new Error("empty_currency_catalog");
|
||||||
|
}
|
||||||
|
usePlayerSessionStore.getState().setCurrencies(data.items);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightCurrencyLoad = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return inflightCurrencyLoad;
|
||||||
|
}
|
||||||
|
|
||||||
export function useCurrencyCatalog() {
|
export function useCurrencyCatalog() {
|
||||||
const currencies = usePlayerSessionStore((state) => state.currencies);
|
const currencies = usePlayerSessionStore((state) => state.currencies);
|
||||||
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
|
|
||||||
const [loadGeneration, setLoadGeneration] = useState(0);
|
const [loadGeneration, setLoadGeneration] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -20,27 +42,13 @@ export function useCurrencyCatalog() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inflightCurrencyLoad !== null) {
|
void ensureCurrencyCatalogLoaded()
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
inflightCurrencyLoad = getPublicCurrencies()
|
|
||||||
.then((data) => {
|
|
||||||
if (data.items.length > 0) {
|
|
||||||
setCurrencies(data.items);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error("empty_currency_catalog");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setLoadGeneration((current) => current + 1);
|
setLoadGeneration((current) => current + 1);
|
||||||
}, RETRY_DELAY_MS);
|
}, RETRY_DELAY_MS);
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
inflightCurrencyLoad = null;
|
|
||||||
});
|
});
|
||||||
}, [currencies.length, setCurrencies, loadGeneration]);
|
}, [currencies.length, loadGeneration]);
|
||||||
|
|
||||||
return currencies;
|
return currencies;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
import {
|
||||||
|
disconnectLotteryEcho,
|
||||||
|
getLotteryEcho,
|
||||||
|
} from "@/lib/lottery-echo";
|
||||||
|
import { parseJwtSessionVersion } from "@/lib/jwt-payload";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
@@ -17,6 +21,11 @@ type BalanceUpdateWsEvent = {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SessionReplacedWsEvent = {
|
||||||
|
player_id?: number;
|
||||||
|
session_version?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const REASON_I18N_KEY: Record<string, string> = {
|
const REASON_I18N_KEY: Record<string, string> = {
|
||||||
transfer_in: "wallet.wsReason.transferIn",
|
transfer_in: "wallet.wsReason.transferIn",
|
||||||
transfer_out: "wallet.wsReason.transferOut",
|
transfer_out: "wallet.wsReason.transferOut",
|
||||||
@@ -71,10 +80,33 @@ export function usePlayerBalanceWs(): void {
|
|||||||
toast.message(t("wallet.wsBalanceUpdated", { change: changeLabel, reason: reasonLabel }));
|
toast.message(t("wallet.wsBalanceUpdated", { change: changeLabel, reason: reasonLabel }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSessionReplaced = (evt: SessionReplacedWsEvent): void => {
|
||||||
|
const nextVersion = evt.session_version;
|
||||||
|
if (
|
||||||
|
evt.player_id !== playerId ||
|
||||||
|
typeof nextVersion !== "number" ||
|
||||||
|
!Number.isInteger(nextVersion)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = usePlayerSessionStore.getState();
|
||||||
|
const currentVersion = parseJwtSessionVersion(state.bearerToken) ?? 0;
|
||||||
|
if (nextVersion <= currentVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectLotteryEcho();
|
||||||
|
state.clearBearerToken();
|
||||||
|
window.location.replace("/login?session=replaced");
|
||||||
|
};
|
||||||
|
|
||||||
channel.listen(".balance.update", onBalanceUpdate);
|
channel.listen(".balance.update", onBalanceUpdate);
|
||||||
|
channel.listen(".session.replaced", onSessionReplaced);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
channel.stopListening(".balance.update");
|
channel.stopListening(".balance.update");
|
||||||
|
channel.stopListening(".session.replaced");
|
||||||
echo.leave(channelName);
|
echo.leave(channelName);
|
||||||
};
|
};
|
||||||
}, [activeCurrency, bearerToken, playerId, t]);
|
}, [activeCurrency, bearerToken, playerId, t]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { parseJwtExp } from "@/lib/jwt-payload";
|
import { parseJwtExp } from "@/lib/jwt-payload";
|
||||||
@@ -6,6 +6,7 @@ import { useErrorStore } from "@/stores/error-store";
|
|||||||
import { resolvePostMessageTargetOrigin } from "@/lib/iframe-origins";
|
import { resolvePostMessageTargetOrigin } from "@/lib/iframe-origins";
|
||||||
import { subscribeIframeTokenRefresh } from "@/lib/iframe-token-refresh-events";
|
import { subscribeIframeTokenRefresh } from "@/lib/iframe-token-refresh-events";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
|
||||||
/** Token 过期前警告阈值(毫秒) */
|
/** Token 过期前警告阈值(毫秒) */
|
||||||
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
||||||
@@ -106,7 +107,7 @@ export function useTokenRefresh(): {
|
|||||||
}, [clearServerError, requestParentRefresh, setServerError, t]);
|
}, [clearServerError, requestParentRefresh, setServerError, t]);
|
||||||
|
|
||||||
/** IframeBridge 统一校验父窗口消息;这里只接收已验证的续签结果。 */
|
/** IframeBridge 统一校验父窗口消息;这里只接收已验证的续签结果。 */
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
return subscribeIframeTokenRefresh(() => {
|
return subscribeIframeTokenRefresh(() => {
|
||||||
pendingRefreshRef.current = 0;
|
pendingRefreshRef.current = 0;
|
||||||
retryCountRef.current = 0;
|
retryCountRef.current = 0;
|
||||||
@@ -116,7 +117,7 @@ export function useTokenRefresh(): {
|
|||||||
/**
|
/**
|
||||||
* 自动刷新逻辑
|
* 自动刷新逻辑
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
if (!bearerToken) {
|
if (!bearerToken) {
|
||||||
if (refreshTimerRef.current) {
|
if (refreshTimerRef.current) {
|
||||||
clearTimeout(refreshTimerRef.current);
|
clearTimeout(refreshTimerRef.current);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function syncDocumentLanguage(lang: AppLanguage): void {
|
|||||||
document.documentElement.lang = lang;
|
document.documentElement.lang = lang;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncPreferredLanguage(): void {
|
export async function syncPreferredLanguage(): Promise<void> {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const stored = window.localStorage.getItem("i18nextLng");
|
const stored = window.localStorage.getItem("i18nextLng");
|
||||||
@@ -67,7 +67,7 @@ export function syncPreferredLanguage(): void {
|
|||||||
const current = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
const current = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||||
|
|
||||||
if (preferred !== current) {
|
if (preferred !== current) {
|
||||||
void i18n.changeLanguage(preferred);
|
await i18n.changeLanguage(preferred);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"captcha": "Captcha",
|
"captcha": "Captcha",
|
||||||
"captchaPlaceholder": "Enter captcha",
|
"captchaPlaceholder": "Enter captcha",
|
||||||
"captchaRequired": "Please load the captcha first",
|
"captchaRequired": "Please load the captcha first",
|
||||||
|
"captchaCodeRequired": "Please enter the captcha",
|
||||||
"captchaLoadFailed": "Failed to load captcha. Try again.",
|
"captchaLoadFailed": "Failed to load captcha. Try again.",
|
||||||
"captchaLoading": "Loading…",
|
"captchaLoading": "Loading…",
|
||||||
"captchaRefresh": "Refresh captcha",
|
"captchaRefresh": "Refresh captcha",
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
"noTokenDetail": "Please return to the main site and try again.",
|
"noTokenDetail": "Please return to the main site and try again.",
|
||||||
"sessionExpired": "Session expired",
|
"sessionExpired": "Session expired",
|
||||||
"sessionExpiredDetail": "Please return to the main site and open the lottery hall again.",
|
"sessionExpiredDetail": "Please return to the main site and open the lottery hall again.",
|
||||||
|
"sessionReplaced": "This account was signed in on another device. Please sign in again.",
|
||||||
"authFailed": "Authorization failed",
|
"authFailed": "Authorization failed",
|
||||||
"unknown": "Unknown error",
|
"unknown": "Unknown error",
|
||||||
"network": "Network error occurred",
|
"network": "Network error occurred",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"orders": "My Bets",
|
"orders": "My Bets",
|
||||||
"rules": "Rules",
|
"rules": "Rules",
|
||||||
"wallet": "Wallet",
|
"wallet": "Wallet",
|
||||||
"credit": "Credit"
|
"credit": "Limit"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Pending reconciliation",
|
"title": "Pending reconciliation",
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
"closesIn": "Closes In",
|
"closesIn": "Closes In",
|
||||||
"drawsIn": "Draws In",
|
"drawsIn": "Draws In",
|
||||||
"drawProcessing": "Drawing…",
|
"drawProcessing": "Drawing…",
|
||||||
"coolDown": "Cool Down",
|
"coolDown": "Until automatic settlement",
|
||||||
"loadFailedRefresh": "Failed to load. Pull down to refresh.",
|
"loadFailedRefresh": "Failed to load. Pull down to refresh.",
|
||||||
"issueNo": "Issue No.",
|
"issueNo": "Issue No.",
|
||||||
"hall": "Betting Hall",
|
"hall": "Betting Hall",
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
"closed": "Awaiting draw",
|
"closed": "Awaiting draw",
|
||||||
"drawing": "Drawing",
|
"drawing": "Drawing",
|
||||||
"review": "Under review",
|
"review": "Under review",
|
||||||
"cooldown": "Cool down",
|
"cooldown": "Result confirmation",
|
||||||
"settling": "Settling",
|
"settling": "Settling",
|
||||||
"settled": "Settled",
|
"settled": "Settled",
|
||||||
"cancelled": "Cancelled"
|
"cancelled": "Cancelled"
|
||||||
@@ -288,6 +288,7 @@
|
|||||||
"no": "No.",
|
"no": "No.",
|
||||||
"number": "Number",
|
"number": "Number",
|
||||||
"selectionType": "Type",
|
"selectionType": "Type",
|
||||||
|
"total": "Total",
|
||||||
"rowTotal": "Row Total",
|
"rowTotal": "Row Total",
|
||||||
"selectAllTypes": "Select all types",
|
"selectAllTypes": "Select all types",
|
||||||
"setAllTypes": "Set the type for every row",
|
"setAllTypes": "Set the type for every row",
|
||||||
@@ -338,6 +339,12 @@
|
|||||||
"big": "B",
|
"big": "B",
|
||||||
"small": "S"
|
"small": "S"
|
||||||
},
|
},
|
||||||
|
"digitPosition": {
|
||||||
|
"0": "1000s",
|
||||||
|
"1": "100s",
|
||||||
|
"2": "10s",
|
||||||
|
"3": "1s"
|
||||||
|
},
|
||||||
"filledPlayCount": "{{count}} plays filled",
|
"filledPlayCount": "{{count}} plays filled",
|
||||||
"tapToFill": "Tap to enter amounts",
|
"tapToFill": "Tap to enter amounts",
|
||||||
"rowActual": "Actual",
|
"rowActual": "Actual",
|
||||||
@@ -483,11 +490,15 @@
|
|||||||
"wallet": {
|
"wallet": {
|
||||||
"title": "Wallet",
|
"title": "Wallet",
|
||||||
"subtitle": "Balance and transfer records",
|
"subtitle": "Balance and transfer records",
|
||||||
"creditTitle": "Credit",
|
"creditTitle": "Limit",
|
||||||
"creditSubtitle": "Credit usage and billing-period activity",
|
"creditSubtitle": "Available limit and period settlement",
|
||||||
"balance": "Wallet Balance",
|
"balance": "Wallet Balance",
|
||||||
"creditAvailable": "Available credit",
|
"creditAvailable": "Available limit",
|
||||||
|
"creditAvailableHint": "Available to bet now",
|
||||||
|
"creditDetails": "Limit details",
|
||||||
"creditSummary": "Limit {{limit}} · Used {{used}}",
|
"creditSummary": "Limit {{limit}} · Used {{used}}",
|
||||||
|
"creditLimit": "Total limit",
|
||||||
|
"usedCredit": "In use",
|
||||||
"creditNoTransferHint": "Credit accounts are funded by your agent; no main-site transfers. Wins are not paid out instantly—profit and loss are settled in billing periods. Contact your agent to change your limit.",
|
"creditNoTransferHint": "Credit accounts are funded by your agent; no main-site transfers. Wins are not paid out instantly—profit and loss are settled in billing periods. Contact your agent to change your limit.",
|
||||||
"available": "Available {{amount}}",
|
"available": "Available {{amount}}",
|
||||||
"transferIn": "Transfer In",
|
"transferIn": "Transfer In",
|
||||||
@@ -497,9 +508,13 @@
|
|||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"logsTitle": "Wallet Logs",
|
"logsTitle": "Wallet Logs",
|
||||||
"logsSubtitle": "Transfer and betting records",
|
"logsSubtitle": "Transfer and betting records",
|
||||||
"creditLogsTitle": "Credit activity",
|
"creditLogsTitle": "Limit and settlement activity",
|
||||||
"creditLogsSubtitle": "Holds, releases, and billing-period payments",
|
"creditLogsSubtitle": "Bets, draw results, and period settlement",
|
||||||
"typeFilter": "Type Filter",
|
"typeFilter": "Type Filter",
|
||||||
|
"logType": "Type",
|
||||||
|
"logTime": "Time",
|
||||||
|
"logRef": "Reference",
|
||||||
|
"logAmount": "Amount",
|
||||||
"transferInTitle": "Transfer In",
|
"transferInTitle": "Transfer In",
|
||||||
"transferOutTitle": "Transfer Out",
|
"transferOutTitle": "Transfer Out",
|
||||||
"transferInSubtitle": "Add funds to lottery wallet ({{currency}})",
|
"transferInSubtitle": "Add funds to lottery wallet ({{currency}})",
|
||||||
@@ -532,17 +547,17 @@
|
|||||||
"viewPendingReconcile": "View pending reconciliation ({{count}})",
|
"viewPendingReconcile": "View pending reconciliation ({{count}})",
|
||||||
"pendingStatus": "Processing",
|
"pendingStatus": "Processing",
|
||||||
"flowsTitle": "Wallet logs",
|
"flowsTitle": "Wallet logs",
|
||||||
"creditFlowsTitle": "Credit activity",
|
"creditFlowsTitle": "Limit and settlement activity",
|
||||||
"playerChannel": {
|
"playerChannel": {
|
||||||
"credit": "Credit line: bet holds, draw settlement, and period payments (not wallet payouts)",
|
"credit": "Credit line: bet holds, draw settlement, and period payments (not wallet payouts)",
|
||||||
"wallet": "Main-site wallet player (transfers & wallet ledger)"
|
"wallet": "Main-site wallet player (transfers & wallet ledger)"
|
||||||
},
|
},
|
||||||
"creditAvailableAfter": "Available credit after",
|
"creditAvailableAfter": "Available limit after",
|
||||||
"creditReleasedAmount": "Released credit {{amount}}",
|
"creditReleasedAmount": "Released credit {{amount}}",
|
||||||
"creditPaymentRecordOnly": "Billing payment record only; not counted in available credit",
|
"creditPaymentRecordOnly": "Billing payment record only; not counted in available credit",
|
||||||
"totalRecords": "{{total}} records",
|
"totalRecords": "{{total}} records",
|
||||||
"emptyLogs": "No wallet logs",
|
"emptyLogs": "No wallet logs",
|
||||||
"emptyCreditLogs": "No credit activity",
|
"emptyCreditLogs": "No limit or settlement activity",
|
||||||
"noMoreLogs": "No more transactions",
|
"noMoreLogs": "No more transactions",
|
||||||
"balanceAfter": "Balance after",
|
"balanceAfter": "Balance after",
|
||||||
"wsBalanceUpdated": "Balance {{change}} ({{reason}})",
|
"wsBalanceUpdated": "Balance {{change}} ({{reason}})",
|
||||||
@@ -565,11 +580,11 @@
|
|||||||
},
|
},
|
||||||
"creditFlow": {
|
"creditFlow": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"bet": "Bet hold",
|
"bet": "Bet",
|
||||||
"game_settlement": "Draw settlement",
|
"game_settlement": "Draw results",
|
||||||
"rebate": "Period rebate",
|
"rebate": "Period rebate",
|
||||||
"credit_release": "Credit release",
|
"credit_release": "Credit release",
|
||||||
"bill_settlement": "Period payment",
|
"bill_settlement": "Period settlement",
|
||||||
"win_credit": "Win release",
|
"win_credit": "Win release",
|
||||||
"refund": "Period confirm release",
|
"refund": "Period confirm release",
|
||||||
"reversal": "Refund release"
|
"reversal": "Refund release"
|
||||||
@@ -612,8 +627,29 @@
|
|||||||
"1009": "The main site could not complete this transfer. Please try again later.",
|
"1009": "The main site could not complete this transfer. Please try again later.",
|
||||||
"1010": "Do not use the same idempotency key for transfers with different amounts."
|
"1010": "Do not use the same idempotency key for transfers with different amounts."
|
||||||
},
|
},
|
||||||
"settlementTitle": "My Billing Period Bills",
|
"activity": {
|
||||||
"settlementHint": "Showing only unsettled bills. Winnings exceeding your credit limit are settled here.",
|
"bet": "Bet",
|
||||||
|
"settledLoss": "Not a winner",
|
||||||
|
"settledWin": "Winner",
|
||||||
|
"result": "Draw result",
|
||||||
|
"refunded": "Refunded",
|
||||||
|
"periodPaid": "Paid",
|
||||||
|
"periodReceived": "Received",
|
||||||
|
"awaitingDraw": "Awaiting draw",
|
||||||
|
"processing": "Processing",
|
||||||
|
"reversed": "Reversed",
|
||||||
|
"details": "View details",
|
||||||
|
"stake": "Bet amount",
|
||||||
|
"win": "Win amount",
|
||||||
|
"rebate": "Rebate",
|
||||||
|
"ticketCount": "Bet lines",
|
||||||
|
"ticketCountValue": "{{count}} item(s)",
|
||||||
|
"viewOrder": "View bet"
|
||||||
|
},
|
||||||
|
"settlementTitle": "Period settlement",
|
||||||
|
"settlementHint": "See the amount currently due to you or from you.",
|
||||||
|
"settlementClear": "Nothing to settle right now",
|
||||||
|
"settlementDetails": "View period details",
|
||||||
"settlementPendingCount": "{{count}} bill(s)",
|
"settlementPendingCount": "{{count}} bill(s)",
|
||||||
"pendingReceivable": "Receivable",
|
"pendingReceivable": "Receivable",
|
||||||
"pendingPayable": "Payable",
|
"pendingPayable": "Payable",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"captcha": "क्याप्चा",
|
"captcha": "क्याप्चा",
|
||||||
"captchaPlaceholder": "क्याप्चा प्रविष्ट गर्नुहोस्",
|
"captchaPlaceholder": "क्याप्चा प्रविष्ट गर्नुहोस्",
|
||||||
"captchaRequired": "कृपया पहिले क्याप्चा लोड गर्नुहोस्",
|
"captchaRequired": "कृपया पहिले क्याप्चा लोड गर्नुहोस्",
|
||||||
|
"captchaCodeRequired": "कृपया क्याप्चा प्रविष्ट गर्नुहोस्",
|
||||||
"captchaLoadFailed": "क्याप्चा लोड असफल। फेरि प्रयास गर्नुहोस्।",
|
"captchaLoadFailed": "क्याप्चा लोड असफल। फेरि प्रयास गर्नुहोस्।",
|
||||||
"captchaLoading": "लोड हुँदै…",
|
"captchaLoading": "लोड हुँदै…",
|
||||||
"captchaRefresh": "क्याप्चा रिफ्रेस",
|
"captchaRefresh": "क्याप्चा रिफ्रेस",
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
"noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।",
|
"noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।",
|
||||||
"sessionExpired": "लगइन म्याद सकियो",
|
"sessionExpired": "लगइन म्याद सकियो",
|
||||||
"sessionExpiredDetail": "कृपया मुख्य साइटमा फर्केर लटरी हल फेरि खोल्नुहोस्।",
|
"sessionExpiredDetail": "कृपया मुख्य साइटमा फर्केर लटरी हल फेरि खोल्नुहोस्।",
|
||||||
|
"sessionReplaced": "यो खाता अर्को उपकरणमा लगइन गरिएको छ। कृपया फेरि लगइन गर्नुहोस्।",
|
||||||
"authFailed": "प्राधिकरण असफल",
|
"authFailed": "प्राधिकरण असफल",
|
||||||
"unknown": "अज्ञात त्रुटि",
|
"unknown": "अज्ञात त्रुटि",
|
||||||
"network": "नेटवर्क त्रुटि भयो",
|
"network": "नेटवर्क त्रुटि भयो",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"orders": "मेरा बेट",
|
"orders": "मेरा बेट",
|
||||||
"rules": "नियम",
|
"rules": "नियम",
|
||||||
"wallet": "वालेट",
|
"wallet": "वालेट",
|
||||||
"credit": "क्रेडिट"
|
"credit": "सीमा"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "मिलान बाँकी सूचना",
|
"title": "मिलान बाँकी सूचना",
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
"closesIn": "बन्द हुन बाँकी",
|
"closesIn": "बन्द हुन बाँकी",
|
||||||
"drawsIn": "ड्र हुन बाँकी",
|
"drawsIn": "ड्र हुन बाँकी",
|
||||||
"drawProcessing": "ड्रअ प्रक्रियामा",
|
"drawProcessing": "ड्रअ प्रक्रियामा",
|
||||||
"coolDown": "कुल डाउन",
|
"coolDown": "स्वचालित सेटलमेन्टसम्म",
|
||||||
"loadFailedRefresh": "लोड असफल भयो। तल तानेर रिफ्रेस गर्नुहोस्।",
|
"loadFailedRefresh": "लोड असफल भयो। तल तानेर रिफ्रेस गर्नुहोस्।",
|
||||||
"issueNo": "इश्यू नं.",
|
"issueNo": "इश्यू नं.",
|
||||||
"hall": "बेटिङ हल",
|
"hall": "बेटिङ हल",
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
"closed": "ड्र पर्खँदै",
|
"closed": "ड्र पर्खँदै",
|
||||||
"drawing": "ड्र हुँदैछ",
|
"drawing": "ड्र हुँदैछ",
|
||||||
"review": "समीक्षामा",
|
"review": "समीक्षामा",
|
||||||
"cooldown": "कुल डाउन",
|
"cooldown": "नतिजा पुष्टि हुँदैछ",
|
||||||
"settling": "सेटल हुँदैछ",
|
"settling": "सेटल हुँदैछ",
|
||||||
"settled": "सेटल भयो",
|
"settled": "सेटल भयो",
|
||||||
"cancelled": "रद्द"
|
"cancelled": "रद्द"
|
||||||
@@ -288,6 +288,7 @@
|
|||||||
"no": "नं.",
|
"no": "नं.",
|
||||||
"number": "नम्बर",
|
"number": "नम्बर",
|
||||||
"selectionType": "प्रकार",
|
"selectionType": "प्रकार",
|
||||||
|
"total": "जम्मा",
|
||||||
"rowTotal": "पंक्ति जम्मा",
|
"rowTotal": "पंक्ति जम्मा",
|
||||||
"selectAllTypes": "सबै प्रकार छान्नुहोस्",
|
"selectAllTypes": "सबै प्रकार छान्नुहोस्",
|
||||||
"setAllTypes": "सबै पंक्तिको प्रकार सेट गर्नुहोस्",
|
"setAllTypes": "सबै पंक्तिको प्रकार सेट गर्नुहोस्",
|
||||||
@@ -338,6 +339,12 @@
|
|||||||
"big": "ठू",
|
"big": "ठू",
|
||||||
"small": "सा"
|
"small": "सा"
|
||||||
},
|
},
|
||||||
|
"digitPosition": {
|
||||||
|
"0": "हजार",
|
||||||
|
"1": "सय",
|
||||||
|
"2": "दश",
|
||||||
|
"3": "एकाइ"
|
||||||
|
},
|
||||||
"filledPlayCount": "{{count}} प्ले भरियो",
|
"filledPlayCount": "{{count}} प्ले भरियो",
|
||||||
"tapToFill": "रकम लेख्न ट्याप गर्नुहोस्",
|
"tapToFill": "रकम लेख्न ट्याप गर्नुहोस्",
|
||||||
"rowActual": "वास्तविक",
|
"rowActual": "वास्तविक",
|
||||||
@@ -493,6 +500,10 @@
|
|||||||
"logsTitle": "वालेट लग",
|
"logsTitle": "वालेट लग",
|
||||||
"logsSubtitle": "ट्रान्सफर र बेट रेकर्ड",
|
"logsSubtitle": "ट्रान्सफर र बेट रेकर्ड",
|
||||||
"typeFilter": "प्रकार फिल्टर",
|
"typeFilter": "प्रकार फिल्टर",
|
||||||
|
"logType": "प्रकार",
|
||||||
|
"logTime": "समय",
|
||||||
|
"logRef": "सम्बन्धित नम्बर",
|
||||||
|
"logAmount": "रकम",
|
||||||
"transferInTitle": "फन्ड ट्रान्सफर इन",
|
"transferInTitle": "फन्ड ट्रान्सफर इन",
|
||||||
"transferOutTitle": "फन्ड ट्रान्सफर आउट",
|
"transferOutTitle": "फन्ड ट्रान्सफर आउट",
|
||||||
"transferInSubtitle": "लटरी वालेटमा रकम थप्नुहोस् ({{currency}})",
|
"transferInSubtitle": "लटरी वालेटमा रकम थप्नुहोस् ({{currency}})",
|
||||||
@@ -525,29 +536,33 @@
|
|||||||
"viewPendingReconcile": "मिलान बाँकी हेर्नुहोस् ({{count}})",
|
"viewPendingReconcile": "मिलान बाँकी हेर्नुहोस् ({{count}})",
|
||||||
"pendingStatus": "प्रक्रिया हुँदैछ",
|
"pendingStatus": "प्रक्रिया हुँदैछ",
|
||||||
"flowsTitle": "वालेट लग",
|
"flowsTitle": "वालेट लग",
|
||||||
"creditTitle": "क्रेडिट",
|
"creditTitle": "सीमा",
|
||||||
"creditSubtitle": "क्रेडिट प्रयोग र बिलिङ अवधि लेनदेन",
|
"creditSubtitle": "उपलब्ध सीमा र अवधि सेटलमेन्ट",
|
||||||
"creditAvailable": "उपलब्ध क्रेडिट",
|
"creditAvailable": "उपलब्ध सीमा",
|
||||||
|
"creditAvailableHint": "अहिले बेट गर्न उपलब्ध",
|
||||||
|
"creditDetails": "सीमा विवरण",
|
||||||
"creditSummary": "सीमा {{limit}} · प्रयोग {{used}}",
|
"creditSummary": "सीमा {{limit}} · प्रयोग {{used}}",
|
||||||
|
"creditLimit": "कुल सीमा",
|
||||||
|
"usedCredit": "प्रयोगमा",
|
||||||
"creditNoTransferHint": "क्रेडिट खाता एजेन्टले दिन्छ; मुख्य साइट ट्रान्सफर छैन। जित तुरुन्त भुक्तानी हुँदैन—नाफा/नोक्सान बिलिङ अवधिमा मिल्छ।",
|
"creditNoTransferHint": "क्रेडिट खाता एजेन्टले दिन्छ; मुख्य साइट ट्रान्सफर छैन। जित तुरुन्त भुक्तानी हुँदैन—नाफा/नोक्सान बिलिङ अवधिमा मिल्छ।",
|
||||||
"creditFlowsTitle": "क्रेडिट लेनदेन",
|
"creditFlowsTitle": "सीमा र सेटलमेन्ट रेकर्ड",
|
||||||
"creditLogsTitle": "क्रेडिट लेनदेन",
|
"creditLogsTitle": "सीमा र सेटलमेन्ट रेकर्ड",
|
||||||
"creditLogsSubtitle": "ओगट, मुक्ति र बिलिङ भुक्तानी",
|
"creditLogsSubtitle": "बेट, ड्र नतिजा र अवधि सेटलमेन्ट",
|
||||||
"playerChannel": {
|
"playerChannel": {
|
||||||
"credit": "क्रेडिट लाइन: बेट होल्ड, ड्र सेटलमेन्ट र अवधि भुक्तानी (वालेट भुक्तानी होइन)",
|
"credit": "क्रेडिट लाइन: बेट होल्ड, ड्र सेटलमेन्ट र अवधि भुक्तानी (वालेट भुक्तानी होइन)",
|
||||||
"wallet": "मुख्य साइट वालेट खेलाडी"
|
"wallet": "मुख्य साइट वालेट खेलाडी"
|
||||||
},
|
},
|
||||||
"creditAvailableAfter": "पछि उपलब्ध क्रेडिट",
|
"creditAvailableAfter": "परिवर्तनपछि उपलब्ध सीमा",
|
||||||
"creditReleasedAmount": "मुक्त क्रेडिट {{amount}}",
|
"creditReleasedAmount": "मुक्त क्रेडिट {{amount}}",
|
||||||
"creditPaymentRecordOnly": "बिलिङ भुक्तानी रेकर्ड मात्र; उपलब्ध क्रेडिटमा गणना हुँदैन",
|
"creditPaymentRecordOnly": "बिलिङ भुक्तानी रेकर्ड मात्र; उपलब्ध क्रेडिटमा गणना हुँदैन",
|
||||||
"emptyCreditLogs": "क्रेडिट लेनदेन छैन",
|
"emptyCreditLogs": "सीमा वा सेटलमेन्ट रेकर्ड छैन",
|
||||||
"creditFlow": {
|
"creditFlow": {
|
||||||
"all": "सबै",
|
"all": "सबै",
|
||||||
"bet": "बेट होल्ड",
|
"bet": "बेट",
|
||||||
"game_settlement": "ड्र सेटलमेन्ट",
|
"game_settlement": "ड्र नतिजा",
|
||||||
"rebate": "अवधि रिबेट",
|
"rebate": "अवधि रिबेट",
|
||||||
"credit_release": "मुक्ति",
|
"credit_release": "मुक्ति",
|
||||||
"bill_settlement": "अवधि भुक्तानी",
|
"bill_settlement": "अवधि सेटलमेन्ट",
|
||||||
"win_credit": "जित मुक्ति",
|
"win_credit": "जित मुक्ति",
|
||||||
"refund": "अवधि पुष्टि मुक्ति",
|
"refund": "अवधि पुष्टि मुक्ति",
|
||||||
"reversal": "फिर्ता मुक्ति"
|
"reversal": "फिर्ता मुक्ति"
|
||||||
@@ -612,8 +627,29 @@
|
|||||||
"timeout": "अनुरोध timeout भयो। कृपया पछि प्रयास गर्नुहोस्।",
|
"timeout": "अनुरोध timeout भयो। कृपया पछि प्रयास गर्नुहोस्।",
|
||||||
"fallback": "अनुरोध असफल। कृपया पछि प्रयास गर्नुहोस्।"
|
"fallback": "अनुरोध असफल। कृपया पछि प्रयास गर्नुहोस्।"
|
||||||
},
|
},
|
||||||
"settlementTitle": "मेरो बिलिङ अवधि बिलहरू",
|
"activity": {
|
||||||
"settlementHint": "केवल बाँकी बिलहरू देखाउँदै। क्रेडिट सीमाभन्दा बढी जित रकम यहाँ सेटल हुन्छ।",
|
"bet": "बेट",
|
||||||
|
"settledLoss": "जितेन",
|
||||||
|
"settledWin": "जित",
|
||||||
|
"result": "ड्र नतिजा",
|
||||||
|
"refunded": "फिर्ता भयो",
|
||||||
|
"periodPaid": "भुक्तानी भयो",
|
||||||
|
"periodReceived": "प्राप्त भयो",
|
||||||
|
"awaitingDraw": "ड्र पर्खँदै",
|
||||||
|
"processing": "प्रक्रियामा",
|
||||||
|
"reversed": "रिभर्स भयो",
|
||||||
|
"details": "विवरण हेर्नुहोस्",
|
||||||
|
"stake": "बेट रकम",
|
||||||
|
"win": "जित रकम",
|
||||||
|
"rebate": "रिबेट",
|
||||||
|
"ticketCount": "बेट संख्या",
|
||||||
|
"ticketCountValue": "{{count}} आइटम",
|
||||||
|
"viewOrder": "बेट हेर्नुहोस्"
|
||||||
|
},
|
||||||
|
"settlementTitle": "अवधि सेटलमेन्ट",
|
||||||
|
"settlementHint": "अहिले प्राप्त वा भुक्तानी गर्नुपर्ने रकम हेर्नुहोस्।",
|
||||||
|
"settlementClear": "अहिले सेटल गर्नुपर्ने केही छैन",
|
||||||
|
"settlementDetails": "अवधि विवरण हेर्नुहोस्",
|
||||||
"settlementPendingCount": "{{count}} बिल(हरू)",
|
"settlementPendingCount": "{{count}} बिल(हरू)",
|
||||||
"pendingReceivable": "प्राप्त हुन बाँकी",
|
"pendingReceivable": "प्राप्त हुन बाँकी",
|
||||||
"pendingPayable": "भुक्तानी हुन बाँकी",
|
"pendingPayable": "भुक्तानी हुन बाँकी",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"captcha": "验证码",
|
"captcha": "验证码",
|
||||||
"captchaPlaceholder": "请输入验证码",
|
"captchaPlaceholder": "请输入验证码",
|
||||||
"captchaRequired": "请先加载验证码",
|
"captchaRequired": "请先加载验证码",
|
||||||
|
"captchaCodeRequired": "请输入验证码",
|
||||||
"captchaLoadFailed": "验证码加载失败,请重试",
|
"captchaLoadFailed": "验证码加载失败,请重试",
|
||||||
"captchaLoading": "加载中…",
|
"captchaLoading": "加载中…",
|
||||||
"captchaRefresh": "刷新验证码",
|
"captchaRefresh": "刷新验证码",
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
"noTokenDetail": "请返回主站后重试。",
|
"noTokenDetail": "请返回主站后重试。",
|
||||||
"sessionExpired": "登录已过期",
|
"sessionExpired": "登录已过期",
|
||||||
"sessionExpiredDetail": "请返回主站重新进入彩票大厅。",
|
"sessionExpiredDetail": "请返回主站重新进入彩票大厅。",
|
||||||
|
"sessionReplaced": "该账号已在其他设备登录,请重新登录",
|
||||||
"authFailed": "授权失败",
|
"authFailed": "授权失败",
|
||||||
"unknown": "未知错误",
|
"unknown": "未知错误",
|
||||||
"network": "网络异常",
|
"network": "网络异常",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"orders": "我的注单",
|
"orders": "我的注单",
|
||||||
"rules": "规则",
|
"rules": "规则",
|
||||||
"wallet": "钱包",
|
"wallet": "钱包",
|
||||||
"credit": "信用"
|
"credit": "额度"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "待对账提醒",
|
"title": "待对账提醒",
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
"closesIn": "距封盘",
|
"closesIn": "距封盘",
|
||||||
"drawsIn": "距开奖",
|
"drawsIn": "距开奖",
|
||||||
"drawProcessing": "开奖处理中",
|
"drawProcessing": "开奖处理中",
|
||||||
"coolDown": "冷静期",
|
"coolDown": "距自动结算",
|
||||||
"loadFailedRefresh": "加载失败,请下拉刷新",
|
"loadFailedRefresh": "加载失败,请下拉刷新",
|
||||||
"issueNo": "期号",
|
"issueNo": "期号",
|
||||||
"hall": "下注大厅",
|
"hall": "下注大厅",
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
"closed": "待开奖",
|
"closed": "待开奖",
|
||||||
"drawing": "开奖中",
|
"drawing": "开奖中",
|
||||||
"review": "待审核",
|
"review": "待审核",
|
||||||
"cooldown": "冷静期",
|
"cooldown": "开奖结果确认中",
|
||||||
"settling": "结算中",
|
"settling": "结算中",
|
||||||
"settled": "已结算",
|
"settled": "已结算",
|
||||||
"cancelled": "已取消"
|
"cancelled": "已取消"
|
||||||
@@ -287,6 +287,7 @@
|
|||||||
"no": "序号",
|
"no": "序号",
|
||||||
"number": "号码",
|
"number": "号码",
|
||||||
"selectionType": "种类",
|
"selectionType": "种类",
|
||||||
|
"total": "合计",
|
||||||
"rowTotal": "本行合计",
|
"rowTotal": "本行合计",
|
||||||
"selectAllTypes": "全选种类",
|
"selectAllTypes": "全选种类",
|
||||||
"setAllTypes": "统一设置所有行的种类",
|
"setAllTypes": "统一设置所有行的种类",
|
||||||
@@ -337,6 +338,12 @@
|
|||||||
"big": "大",
|
"big": "大",
|
||||||
"small": "小"
|
"small": "小"
|
||||||
},
|
},
|
||||||
|
"digitPosition": {
|
||||||
|
"0": "千",
|
||||||
|
"1": "百",
|
||||||
|
"2": "十",
|
||||||
|
"3": "个"
|
||||||
|
},
|
||||||
"filledPlayCount": "已填写 {{count}} 个玩法",
|
"filledPlayCount": "已填写 {{count}} 个玩法",
|
||||||
"tapToFill": "点击填写玩法金额",
|
"tapToFill": "点击填写玩法金额",
|
||||||
"rowActual": "实扣",
|
"rowActual": "实扣",
|
||||||
@@ -483,11 +490,15 @@
|
|||||||
"wallet": {
|
"wallet": {
|
||||||
"title": "钱包",
|
"title": "钱包",
|
||||||
"subtitle": "余额与划转记录",
|
"subtitle": "余额与划转记录",
|
||||||
"creditTitle": "信用",
|
"creditTitle": "额度",
|
||||||
"creditSubtitle": "额度占用与账期流水",
|
"creditSubtitle": "可用额度与账期结算",
|
||||||
"balance": "钱包余额",
|
"balance": "钱包余额",
|
||||||
"creditAvailable": "可用信用",
|
"creditAvailable": "可用额度",
|
||||||
|
"creditAvailableHint": "当前可用于下注",
|
||||||
|
"creditDetails": "额度详情",
|
||||||
"creditSummary": "授信 {{limit}} · 已用 {{used}}",
|
"creditSummary": "授信 {{limit}} · 已用 {{used}}",
|
||||||
|
"creditLimit": "总额度",
|
||||||
|
"usedCredit": "使用中",
|
||||||
"creditNoTransferHint": "由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
|
"creditNoTransferHint": "由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
|
||||||
"available": "可用 {{amount}}",
|
"available": "可用 {{amount}}",
|
||||||
"transferIn": "转入",
|
"transferIn": "转入",
|
||||||
@@ -497,9 +508,13 @@
|
|||||||
"logs": "流水",
|
"logs": "流水",
|
||||||
"logsTitle": "钱包流水",
|
"logsTitle": "钱包流水",
|
||||||
"logsSubtitle": "划转与下注记录",
|
"logsSubtitle": "划转与下注记录",
|
||||||
"creditLogsTitle": "信用流水",
|
"creditLogsTitle": "额度与结算记录",
|
||||||
"creditLogsSubtitle": "占用、释额与账期收付",
|
"creditLogsSubtitle": "下注、开奖结果与账期结算",
|
||||||
"typeFilter": "类型筛选",
|
"typeFilter": "类型筛选",
|
||||||
|
"logType": "类型",
|
||||||
|
"logTime": "时间",
|
||||||
|
"logRef": "关联单号",
|
||||||
|
"logAmount": "金额",
|
||||||
"transferInTitle": "转入资金",
|
"transferInTitle": "转入资金",
|
||||||
"transferOutTitle": "转出资金",
|
"transferOutTitle": "转出资金",
|
||||||
"transferInSubtitle": "转入彩票钱包({{currency}})",
|
"transferInSubtitle": "转入彩票钱包({{currency}})",
|
||||||
@@ -532,17 +547,17 @@
|
|||||||
"viewPendingReconcile": "查看待对账详情({{count}})",
|
"viewPendingReconcile": "查看待对账详情({{count}})",
|
||||||
"pendingStatus": "处理中",
|
"pendingStatus": "处理中",
|
||||||
"flowsTitle": "资金流水",
|
"flowsTitle": "资金流水",
|
||||||
"creditFlowsTitle": "信用流水",
|
"creditFlowsTitle": "额度与结算记录",
|
||||||
"playerChannel": {
|
"playerChannel": {
|
||||||
"credit": "信用盘:展示下注冻结、开奖结算与账期收付(非钱包派彩)",
|
"credit": "信用盘:展示下注冻结、开奖结算与账期收付(非钱包派彩)",
|
||||||
"wallet": "主站钱包玩家(划转与钱包余额流水)"
|
"wallet": "主站钱包玩家(划转与钱包余额流水)"
|
||||||
},
|
},
|
||||||
"creditAvailableAfter": "变更后可用信用",
|
"creditAvailableAfter": "变更后可用额度",
|
||||||
"creditReleasedAmount": "释额 {{amount}}",
|
"creditReleasedAmount": "释额 {{amount}}",
|
||||||
"creditPaymentRecordOnly": "账期收付记账,不计入可用信用",
|
"creditPaymentRecordOnly": "账期收付记账,不计入可用信用",
|
||||||
"totalRecords": "共 {{total}} 条记录",
|
"totalRecords": "共 {{total}} 条记录",
|
||||||
"emptyLogs": "暂无流水",
|
"emptyLogs": "暂无流水",
|
||||||
"emptyCreditLogs": "暂无信用流水",
|
"emptyCreditLogs": "暂无额度与结算记录",
|
||||||
"noMoreLogs": "没有更多流水",
|
"noMoreLogs": "没有更多流水",
|
||||||
"balanceAfter": "变更后余额",
|
"balanceAfter": "变更后余额",
|
||||||
"wsBalanceUpdated": "余额 {{change}}({{reason}})",
|
"wsBalanceUpdated": "余额 {{change}}({{reason}})",
|
||||||
@@ -565,11 +580,11 @@
|
|||||||
},
|
},
|
||||||
"creditFlow": {
|
"creditFlow": {
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"bet": "下注冻结",
|
"bet": "下注",
|
||||||
"game_settlement": "开奖结算",
|
"game_settlement": "开奖结果",
|
||||||
"rebate": "账期回水",
|
"rebate": "账期回水",
|
||||||
"credit_release": "释额",
|
"credit_release": "释额",
|
||||||
"bill_settlement": "账期收付",
|
"bill_settlement": "账期结算",
|
||||||
"win_credit": "中奖释额",
|
"win_credit": "中奖释额",
|
||||||
"refund": "账期确认释额",
|
"refund": "账期确认释额",
|
||||||
"reversal": "退本释额"
|
"reversal": "退本释额"
|
||||||
@@ -612,8 +627,29 @@
|
|||||||
"1009": "主站未能完成本次划转,请稍后重试。",
|
"1009": "主站未能完成本次划转,请稍后重试。",
|
||||||
"1010": "请勿用同一幂等键发起不同金额的转账。"
|
"1010": "请勿用同一幂等键发起不同金额的转账。"
|
||||||
},
|
},
|
||||||
"settlementTitle": "我的账期账单",
|
"activity": {
|
||||||
"settlementHint": "只显示未结清账单,中奖超出授信的部分在这里结算。",
|
"bet": "下注",
|
||||||
|
"settledLoss": "未中奖",
|
||||||
|
"settledWin": "中奖",
|
||||||
|
"result": "开奖结果",
|
||||||
|
"refunded": "已退款",
|
||||||
|
"periodPaid": "已付款",
|
||||||
|
"periodReceived": "已收款",
|
||||||
|
"awaitingDraw": "待开奖",
|
||||||
|
"processing": "处理中",
|
||||||
|
"reversed": "已冲正",
|
||||||
|
"details": "查看明细",
|
||||||
|
"stake": "下注金额",
|
||||||
|
"win": "中奖金额",
|
||||||
|
"rebate": "回水",
|
||||||
|
"ticketCount": "注单数量",
|
||||||
|
"ticketCountValue": "{{count}}项",
|
||||||
|
"viewOrder": "查看注单"
|
||||||
|
},
|
||||||
|
"settlementTitle": "账期结算",
|
||||||
|
"settlementHint": "查看当前需要收取或支付的金额。",
|
||||||
|
"settlementClear": "当前无需结算",
|
||||||
|
"settlementDetails": "查看账期明细",
|
||||||
"settlementPendingCount": "{{count}}笔",
|
"settlementPendingCount": "{{count}}笔",
|
||||||
"pendingReceivable": "待收",
|
"pendingReceivable": "待收",
|
||||||
"pendingPayable": "待付",
|
"pendingPayable": "待付",
|
||||||
|
|||||||
@@ -20,4 +20,14 @@ export function parseJwtExp(token: string | null): number | null {
|
|||||||
const payload = parseJwtPayload(token);
|
const payload = parseJwtPayload(token);
|
||||||
const exp = payload?.exp;
|
const exp = payload?.exp;
|
||||||
return typeof exp === "number" ? exp : null;
|
return typeof exp === "number" ? exp : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回原生玩家 JWT 的单设备会话版本;非原生或解析失败返回 null。 */
|
||||||
|
export function parseJwtSessionVersion(token: string | null): number | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const payload = parseJwtPayload(token);
|
||||||
|
const sessionVersion = payload?.session_version;
|
||||||
|
return typeof sessionVersion === "number" && Number.isInteger(sessionVersion)
|
||||||
|
? sessionVersion
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const lotteryHttp = axios.create({
|
|||||||
|
|
||||||
/** 凭据类 401(非 token 失效),不应触发「会话过期」跳转 */
|
/** 凭据类 401(非 token 失效),不应触发「会话过期」跳转 */
|
||||||
const PLAYER_CREDENTIAL_BIZ_CODES = new Set([8006]);
|
const PLAYER_CREDENTIAL_BIZ_CODES = new Set([8006]);
|
||||||
|
const PLAYER_SESSION_REPLACED_BIZ_CODE = 8010;
|
||||||
|
|
||||||
function isPlayerAuthLoginRequest(config: AxiosRequestConfig | undefined): boolean {
|
function isPlayerAuthLoginRequest(config: AxiosRequestConfig | undefined): boolean {
|
||||||
if (!config || (config.method ?? "get").toLowerCase() !== "post") {
|
if (!config || (config.method ?? "get").toLowerCase() !== "post") {
|
||||||
@@ -73,16 +74,26 @@ lotteryHttp.interceptors.response.use(
|
|||||||
|
|
||||||
// 401: 已登录态 token 失效 → 清会话并回入口;登录凭据错误等由页面自行提示
|
// 401: 已登录态 token 失效 → 清会话并回入口;登录凭据错误等由页面自行提示
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
|
const body = error.response?.data;
|
||||||
|
const sessionReplaced =
|
||||||
|
isApiEnvelope(body) && body.code === PLAYER_SESSION_REPLACED_BIZ_CODE;
|
||||||
const redirectSessionExpired = shouldRedirectPlayerSessionExpired(error);
|
const redirectSessionExpired = shouldRedirectPlayerSessionExpired(error);
|
||||||
if (redirectSessionExpired || window.location.pathname === "/login") {
|
if (redirectSessionExpired || window.location.pathname === "/login") {
|
||||||
usePlayerSessionStore.getState().clearBearerToken();
|
usePlayerSessionStore.getState().clearBearerToken();
|
||||||
}
|
}
|
||||||
if (redirectSessionExpired) {
|
if (redirectSessionExpired) {
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
const alreadyExpired = window.location.search.includes("session=expired");
|
const sessionStatus = sessionReplaced ? "replaced" : "expired";
|
||||||
const expiredTarget = isInIframe() ? "/?session=expired" : "/login?session=expired";
|
const alreadyOnTarget = window.location.search.includes(
|
||||||
|
`session=${sessionStatus}`,
|
||||||
|
);
|
||||||
|
const expiredTarget = sessionReplaced
|
||||||
|
? "/login?session=replaced"
|
||||||
|
: isInIframe()
|
||||||
|
? "/?session=expired"
|
||||||
|
: "/login?session=expired";
|
||||||
const onExpiredPage =
|
const onExpiredPage =
|
||||||
(pathname === "/" || pathname === "/login") && alreadyExpired;
|
(pathname === "/" || pathname === "/login") && alreadyOnTarget;
|
||||||
if (!onExpiredPage) {
|
if (!onExpiredPage) {
|
||||||
window.location.replace(expiredTarget);
|
window.location.replace(expiredTarget);
|
||||||
}
|
}
|
||||||
@@ -185,4 +196,4 @@ export const lotteryRequest = {
|
|||||||
data?: unknown,
|
data?: unknown,
|
||||||
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
||||||
) => request<T>({ ...config, url, method: "PATCH", data }),
|
) => request<T>({ ...config, url, method: "PATCH", data }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ export type WalletLogItem = {
|
|||||||
balance_after: number | null;
|
balance_after: number | null;
|
||||||
/** 信用盘:false 表示账期收付/账期回水展示记录,不改变可用信用 */
|
/** 信用盘:false 表示账期收付/账期回水展示记录,不改变可用信用 */
|
||||||
affects_available_credit?: boolean;
|
affects_available_credit?: boolean;
|
||||||
|
/** 信用盘玩家视图:按业务事件聚合后的活动类型 */
|
||||||
|
activity_kind?: "bet" | "draw_result" | "period_settlement" | "refund";
|
||||||
|
activity_status?: "pending" | "completed" | "reversed";
|
||||||
|
order_no?: string | null;
|
||||||
|
draw_no?: string | null;
|
||||||
|
ticket_no?: string | null;
|
||||||
|
ticket_count?: number;
|
||||||
|
settlement_bill_id?: number | null;
|
||||||
|
stake_amount?: number;
|
||||||
|
win_amount?: number;
|
||||||
|
rebate_amount?: number;
|
||||||
|
net_amount?: number;
|
||||||
ref_id: string | null;
|
ref_id: string | null;
|
||||||
idempotent_key: string | null;
|
idempotent_key: string | null;
|
||||||
external_ref_no: string | null;
|
external_ref_no: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user