- 合并钱包、订单、开奖、通知等独立 screen 到页面层,减少重复壳组件 - 优化订单列表/详情、钱包划转与待对账展示、开奖核对与结果详情交互 - 改进入口 gate、移动端视口与下拉刷新在窄屏下的表现 - dev 绑定 0.0.0.0 并默认放行 192.168/10 网段,修复局域网 HMR WebSocket
This commit is contained in:
@@ -6,7 +6,7 @@ import { parseAllowedDevOrigins } from "./src/lib/next-dev-origins";
|
||||
const allowedDevOrigins = parseAllowedDevOrigins(process.env.ALLOWED_DEV_ORIGINS);
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
...(allowedDevOrigins.length > 0 ? { allowedDevOrigins } : {}),
|
||||
allowedDevOrigins,
|
||||
reactCompiler: true,
|
||||
|
||||
images: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3800",
|
||||
"dev": "next dev --port 3800 -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3800",
|
||||
"lint": "eslint"
|
||||
|
||||
@@ -9,6 +9,7 @@ export type GetTicketItemsParams = {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
draw_no?: string;
|
||||
order_no?: string;
|
||||
number?: string;
|
||||
status?: string[];
|
||||
start_date?: string;
|
||||
@@ -26,6 +27,7 @@ export function getTicketItems(
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
draw_no: params?.draw_no,
|
||||
order_no: params?.order_no,
|
||||
number: params?.number,
|
||||
status: params?.status,
|
||||
start_date: params?.start_date,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NotificationsScreen } from "@/features/player/notifications-screen";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
return <NotificationsScreen />;
|
||||
/** 待对账已内嵌在钱包页,旧路由锚点跳转 */
|
||||
export default function NotificationsRedirectPage() {
|
||||
redirect("/wallet?section=pending");
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { TicketOrderGroupScreen } from "@/features/orders/ticket-order-group-screen";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ groupKey: string }>;
|
||||
};
|
||||
|
||||
export default async function OrderGroupPage({ params }: PageProps) {
|
||||
const { groupKey } = await params;
|
||||
return <TicketOrderGroupScreen groupKey={groupKey} />;
|
||||
/** 旧「订单详情」中间层已移除,统一回到注单列表 */
|
||||
export default function OrderGroupRedirectPage() {
|
||||
redirect("/orders");
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { DrawResultDetailScreen } from "@/features/results/draw-result-detail-screen";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ drawNo: string }>;
|
||||
};
|
||||
|
||||
function DetailFallback(): ReactNode {
|
||||
return <div className="min-h-40" aria-hidden />;
|
||||
}
|
||||
|
||||
export default async function DrawResultByNoPage(props: PageProps) {
|
||||
const { drawNo: raw } = await props.params;
|
||||
const drawNo = decodeURIComponent(raw);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<DrawResultDetailScreen drawNo={drawNo} />
|
||||
<Suspense fallback={<DetailFallback />}>
|
||||
<DrawResultDetailScreen drawNo={drawNo} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CheckWinningScreen } from "@/features/results/check-winning-screen";
|
||||
import { CheckWinningRedirect } from "@/features/results/check-winning-redirect";
|
||||
|
||||
export default function CheckWinningPage() {
|
||||
return <CheckWinningScreen />;
|
||||
return <CheckWinningRedirect />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WalletLogsScreen } from "@/features/wallet/wallet-logs-screen";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function WalletLogsPage() {
|
||||
return <WalletLogsScreen />;
|
||||
/** 流水已内嵌在钱包页,旧路由锚点跳转 */
|
||||
export default function WalletLogsRedirectPage() {
|
||||
redirect("/wallet?section=logs");
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { WalletScreen } from "@/features/wallet/wallet-screen";
|
||||
|
||||
/** 界面文档 §4.9 彩票钱包:余额、转入/转出、流水 */
|
||||
export default function WalletPage() {
|
||||
return <WalletScreen />;
|
||||
function WalletFallback(): ReactNode {
|
||||
return <div className="min-h-[40vh] bg-white" aria-hidden />;
|
||||
}
|
||||
|
||||
/** 界面文档 §4.9 彩票钱包:余额、转入/转出、待对账、流水 */
|
||||
export default function WalletPage() {
|
||||
return (
|
||||
<Suspense fallback={<WalletFallback />}>
|
||||
<WalletScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TransferInScreen } from "@/features/wallet/transfer-in-screen";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function WalletTransferInPage() {
|
||||
return <TransferInScreen />;
|
||||
/** 深链统一进钱包弹窗,避免独立页与弹窗两套入口 */
|
||||
export default function WalletTransferInRedirectPage() {
|
||||
redirect("/wallet?action=transfer-in");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TransferOutScreen } from "@/features/wallet/transfer-out-screen";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function WalletTransferOutPage() {
|
||||
return <TransferOutScreen />;
|
||||
/** 深链统一进钱包弹窗,避免独立页与弹窗两套入口 */
|
||||
export default function WalletTransferOutRedirectPage() {
|
||||
redirect("/wallet?action=transfer-out");
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { PlayerLoginScreen } from "@/features/player/player-login-screen";
|
||||
|
||||
function LoginFallback(): ReactNode {
|
||||
return <div className="min-h-dvh bg-white" aria-hidden />;
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col items-center justify-center bg-white"
|
||||
aria-hidden
|
||||
>
|
||||
<Loader2 className="size-8 animate-spin text-red-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlayerLoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginFallback />}>
|
||||
<PlayerLoginScreen />
|
||||
</Suspense>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<Suspense fallback={<LoginFallback />}>
|
||||
<PlayerLoginScreen />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { EntryGate } from "@/features/player/entry-gate";
|
||||
|
||||
function EntryFallback(): ReactNode {
|
||||
return <div className="min-h-dvh bg-white" aria-hidden />;
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col items-center justify-center bg-white"
|
||||
aria-hidden
|
||||
>
|
||||
<Loader2 className="size-8 animate-spin text-red-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EntryPage() {
|
||||
return (
|
||||
<Suspense fallback={<EntryFallback />}>
|
||||
<EntryGate />
|
||||
</Suspense>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<Suspense fallback={<EntryFallback />}>
|
||||
<EntryGate />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -142,6 +142,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 下注表格横向滚动:右侧渐变提示可继续滑动 */
|
||||
.player-table-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.player-table-scroll-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-table-scroll-wrap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 1.25rem;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to left, rgba(255, 255, 255, 0.95), transparent);
|
||||
}
|
||||
|
||||
/* 玩家端 Toast:顶部居中、紧凑尺寸(位置见 components/ui/sonner.tsx) */
|
||||
[data-sonner-toaster] {
|
||||
--width: min(280px, calc(100vw - 24px));
|
||||
|
||||
@@ -30,6 +30,7 @@ export const viewport = {
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -5,7 +5,6 @@ import { FileQuestion, Home } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PlayerMobileViewport } from "@/components/layout/player-mobile-viewport";
|
||||
import { playerViewportColumnClass } from "@/lib/player-viewport";
|
||||
import { cn } from "@/lib/utils";
|
||||
import "@/i18n";
|
||||
|
||||
@@ -16,8 +15,7 @@ export default function NotFoundPage(): React.ReactElement {
|
||||
<PlayerMobileViewport>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-dvh flex-col items-center justify-center bg-white px-4 py-8 text-slate-900",
|
||||
playerViewportColumnClass,
|
||||
"flex min-h-0 flex-1 flex-col items-center justify-center bg-white px-4 py-8 text-slate-900",
|
||||
)}
|
||||
>
|
||||
<div className="w-full rounded-2xl border border-[#e4eaf4] bg-white p-8 shadow-[0_10px_28px_rgba(15,23,42,0.08)]">
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useNetworkStatus } from "@/hooks/use-network-status";
|
||||
import { OfflineBanner } from "@/components/offline-banner";
|
||||
import { ServerError } from "@/components/server-error";
|
||||
|
||||
/**
|
||||
@@ -17,13 +16,7 @@ export function ErrorProvider({ children }: { children: ReactNode }): ReactNode
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 全局离线状态横幅 - 显示在页面顶部 */}
|
||||
<OfflineBanner />
|
||||
|
||||
{/* 服务器错误全屏覆盖 */}
|
||||
<ServerError />
|
||||
|
||||
{/* 子内容 */}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,14 +28,17 @@ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white text-foreground overflow-hidden">
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-white text-foreground">
|
||||
<PullToRefreshIndicator
|
||||
pullDistance={pullDistance}
|
||||
isRefreshing={isRefreshing}
|
||||
threshold={70}
|
||||
/>
|
||||
<NetworkStatusBanner />
|
||||
<main id="player-scroll-container" className="flex w-full flex-1 flex-col pb-1 overflow-y-auto overscroll-y-contain">
|
||||
<main
|
||||
id="player-scroll-container"
|
||||
className="flex w-full min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain pb-3"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<div className="shrink-0 w-full relative z-50">
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { playerViewportColumnClass } from "@/lib/player-viewport";
|
||||
import { OfflineBanner } from "@/components/offline-banner";
|
||||
import {
|
||||
playerSafeAreaTopClass,
|
||||
playerViewportColumnClass,
|
||||
} from "@/lib/player-viewport";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PlayerMobileViewportProps = {
|
||||
@@ -20,10 +24,12 @@ export function PlayerMobileViewport({
|
||||
<div
|
||||
className={cn(
|
||||
playerViewportColumnClass,
|
||||
"relative h-full bg-white shadow-none sm:shadow-[0_0_48px_rgba(15,23,42,0.1)] flex flex-col",
|
||||
playerSafeAreaTopClass,
|
||||
"relative flex h-full min-h-0 flex-col bg-white shadow-none sm:shadow-[0_0_48px_rgba(15,23,42,0.1)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<OfflineBanner />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,10 @@ export function PullToRefreshIndicator({
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed left-1/2 z-[60] flex w-full max-w-[480px] -translate-x-1/2 items-center justify-center overflow-hidden transition-[height] duration-100 ease-out"
|
||||
style={{ height: isRefreshing ? 40 : pullDistance }}
|
||||
style={{
|
||||
top: "env(safe-area-inset-top, 0px)",
|
||||
height: isRefreshing ? 40 : pullDistance,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -297,15 +297,6 @@ export function HallBetPreviewDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{periodRebate ? (
|
||||
<p className="text-xs leading-relaxed text-slate-500">
|
||||
{t("hall.preview.periodRebateHint", {
|
||||
defaultValue:
|
||||
"信用盘回水在账期结算入账,下注时按全额占用可用信用,此处为账期回水预估。",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{data.warnings.length > 0 ? (
|
||||
<WarningsBlock warnings={data.warnings} />
|
||||
) : (
|
||||
|
||||
@@ -588,13 +588,18 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
return allPlayColumns.filter((col) => allowedCodes.has(col.play.play_code));
|
||||
}, [activeCategory, allPlayColumns, d4PlayGroup]);
|
||||
|
||||
const compactTable = playColumns.length > 6;
|
||||
const amountColClass = compactTable
|
||||
? "w-[3.75rem] min-w-[3.75rem] max-w-[3.75rem]"
|
||||
: "w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem]";
|
||||
|
||||
const tableMinWidthPx = useMemo(() => {
|
||||
const indexCol = 40;
|
||||
const numberCol = activeCategory === "D4" ? 112 : activeCategory === "D3" ? 88 : 72;
|
||||
const amountCol = 72;
|
||||
const amountCol = compactTable ? 60 : 72;
|
||||
const deleteCol = 36;
|
||||
return indexCol + numberCol + playColumns.length * amountCol + deleteCol;
|
||||
}, [activeCategory, playColumns.length]);
|
||||
}, [activeCategory, compactTable, playColumns.length]);
|
||||
|
||||
const showWideTableHint = activeCategory === "D4" && allPlayColumns.length > 8 && playColumns.length > 8;
|
||||
|
||||
@@ -1382,9 +1387,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-opacity",
|
||||
tableDisabled && "opacity-55",
|
||||
showWideTableHint && "player-table-scroll-wrap",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<div className="player-table-scroll overflow-x-auto overscroll-x-contain">
|
||||
<table
|
||||
className="w-full border-collapse text-[11px]"
|
||||
style={{ minWidth: tableMinWidthPx }}
|
||||
@@ -1412,7 +1418,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
{playColumns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className="w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem] px-0.5 py-2 text-center font-bold"
|
||||
className={cn(amountColClass, "px-0.5 py-2 text-center font-bold")}
|
||||
>
|
||||
<span className="block whitespace-nowrap text-[11px] leading-tight">
|
||||
{playColumnHeaderLabel(
|
||||
@@ -1512,7 +1518,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
onClick={() => setActiveRowId(row.id)}
|
||||
onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
|
||||
className={cn(
|
||||
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center text-xs font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
|
||||
"h-9 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center text-base font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
|
||||
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
|
||||
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
|
||||
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",
|
||||
|
||||
@@ -15,8 +15,6 @@ export type TicketItemGroup = {
|
||||
jackpot_win_amount: number;
|
||||
};
|
||||
|
||||
export const ORDER_GROUP_STORAGE_PREFIX = "lottery:order-group:v1:";
|
||||
|
||||
/** 分组键:有 order_no 则按订单;否则 draw_no + placed_at + currency + status 兜底 */
|
||||
export function getTicketItemGroupKey(row: TicketItemListRow): string {
|
||||
const orderNo = (row.order_no ?? "").trim();
|
||||
@@ -135,56 +133,6 @@ export function groupTicketItems(items: TicketItemListRow[]): TicketItemGroup[]
|
||||
});
|
||||
}
|
||||
|
||||
export function persistOrderGroup(group: TicketItemGroup): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
ORDER_GROUP_STORAGE_PREFIX + group.key,
|
||||
JSON.stringify(group),
|
||||
);
|
||||
} catch {
|
||||
/* quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPersistedOrderGroup(groupKey: string): TicketItemGroup | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(ORDER_GROUP_STORAGE_PREFIX + groupKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as TicketItemGroup;
|
||||
if (!parsed?.key || !Array.isArray(parsed.items)) return null;
|
||||
return { ...parsed, items: sortTicketGroupItems(parsed.items) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function orderGroupPath(groupKey: string, ticketNos?: string[]): string {
|
||||
const base = `/orders/group/${encodeURIComponent(groupKey)}`;
|
||||
if (!ticketNos?.length) return base;
|
||||
return `${base}?tickets=${encodeURIComponent(ticketNos.join(","))}`;
|
||||
}
|
||||
|
||||
export function orderGroupHref(group: TicketItemGroup): string {
|
||||
if (group.items.length === 1) {
|
||||
return `/orders/${encodeURIComponent(group.items[0].ticket_no)}`;
|
||||
}
|
||||
return orderGroupPath(
|
||||
group.key,
|
||||
group.items.map((i) => i.ticket_no),
|
||||
);
|
||||
}
|
||||
|
||||
/** 注项详情;来自订单组时带上 fromGroup,便于返回订单详情 */
|
||||
export function ticketDetailHref(
|
||||
ticketNo: string,
|
||||
fromGroup?: TicketItemGroup | null,
|
||||
): string {
|
||||
const base = `/orders/${encodeURIComponent(ticketNo)}`;
|
||||
const key = (fromGroup?.key ?? "").trim();
|
||||
if (!key) return base;
|
||||
const params = new URLSearchParams({ fromGroup: key });
|
||||
if (fromGroup && fromGroup.items.length > 1) {
|
||||
params.set("tickets", fromGroup.items.map((i) => i.ticket_no).join(","));
|
||||
}
|
||||
return `${base}?${params.toString()}`;
|
||||
export function ticketDetailHref(ticketNo: string): string {
|
||||
return `/orders/${encodeURIComponent(ticketNo)}`;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getTicketItemDetail } from "@/api/ticket-items";
|
||||
import { getTicketItemDetail, getTicketItems } from "@/api/ticket-items";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { orderGroupPath } from "@/features/orders/group-ticket-items";
|
||||
import { ticketDetailHref } from "@/features/orders/group-ticket-items";
|
||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
@@ -28,7 +28,7 @@ import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
|
||||
import type { TicketItemDetailPayload, TicketItemListRow } from "@/types/api/ticket-items";
|
||||
|
||||
type OddsSnapRow = { prize_scope?: string; odds_value?: number };
|
||||
|
||||
@@ -78,35 +78,16 @@ type TicketItemDetailWithExtras = TicketItemDetailPayload & {
|
||||
|
||||
/** 界面文档 §4.8 注单详情 */
|
||||
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation("player");
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
useCurrencyCatalog();
|
||||
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
|
||||
const [siblingItems, setSiblingItems] = useState<TicketItemListRow[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fromGroupKey = useMemo(
|
||||
() => (searchParams.get("fromGroup") ?? "").trim(),
|
||||
[searchParams],
|
||||
);
|
||||
const groupTickets = useMemo(
|
||||
() => (searchParams.get("tickets") ?? "").trim(),
|
||||
[searchParams],
|
||||
);
|
||||
const backNav = useMemo(() => {
|
||||
if (!fromGroupKey) {
|
||||
return { href: "/orders", label: t("orders.title") };
|
||||
}
|
||||
const ticketNos = groupTickets
|
||||
? groupTickets.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
return {
|
||||
href: orderGroupPath(fromGroupKey, ticketNos),
|
||||
label: t("orders.groupDetail"),
|
||||
};
|
||||
}, [fromGroupKey, groupTickets, t]);
|
||||
const backHref = "/orders";
|
||||
const backLabel = t("orders.title");
|
||||
|
||||
const load = useCallback(async (options?: { silent?: boolean }) => {
|
||||
if (!options?.silent) {
|
||||
@@ -134,6 +115,33 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const orderNo = (data?.order_no ?? "").trim();
|
||||
if (!orderNo) {
|
||||
setSiblingItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getTicketItems({ order_no: orderNo, per_page: 50 });
|
||||
if (cancelled) return;
|
||||
setSiblingItems(
|
||||
res.items.filter((row) => row.ticket_no !== ticketNo),
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSiblingItems([]);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [data?.order_no, ticketNo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !TRANSIENT_TICKET_STATUSES.has(data.status)) {
|
||||
return;
|
||||
@@ -153,8 +161,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
return (
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
backHref={backNav.href}
|
||||
backLabel={backNav.label}
|
||||
backHref={backHref}
|
||||
backLabel={backLabel}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
@@ -168,8 +176,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
return (
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
backHref={backNav.href}
|
||||
backLabel={backNav.label}
|
||||
backHref={backHref}
|
||||
backLabel={backLabel}
|
||||
>
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||||
<p>{error ?? t("orders.noData")}</p>
|
||||
@@ -235,10 +243,55 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
return (
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
backHref={backNav.href}
|
||||
backLabel={backNav.label}
|
||||
backHref={backHref}
|
||||
backLabel={backLabel}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{siblingItems.length > 0 ? (
|
||||
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<p className="text-sm font-black text-[#0b3f96]">{t("orders.sameOrderItems")}</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{siblingItems.map((row) => {
|
||||
const lineSt = ticketStatusDisplay(
|
||||
row.status,
|
||||
row.win_amount,
|
||||
row.jackpot_win_amount,
|
||||
t,
|
||||
creditMode,
|
||||
);
|
||||
const lineCur = row.currency_code ?? activeCurrency;
|
||||
return (
|
||||
<Link
|
||||
key={row.ticket_no}
|
||||
href={ticketDetailHref(row.ticket_no)}
|
||||
className="flex items-center gap-3 rounded-xl border border-[#e5edf8] bg-[#fbfdff] px-3 py-2.5 transition-colors hover:border-[#b9ccf6] hover:bg-white"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-black text-[#0b3f96]">
|
||||
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{t("orders.deduction")}{" "}
|
||||
<span className="font-bold tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<StatusDot
|
||||
label={lineSt.label}
|
||||
dotClass={lineSt.dotClass}
|
||||
ring={lineSt.ring}
|
||||
/>
|
||||
<ChevronRight className="size-4 text-[#7890b8]" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card className="ring-0 border border-[#e8eef7] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
|
||||
<CardHeader className="space-y-2 border-b border-[#edf2f9] pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -445,12 +498,12 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
href={backNav.href}
|
||||
href={backHref}
|
||||
className={cn(
|
||||
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl border border-[#dce7f7] bg-white px-4 text-sm font-semibold text-[#07459f] transition-colors hover:bg-[#f1f6ff]",
|
||||
)}
|
||||
>
|
||||
{fromGroupKey ? t("orders.backToGroup") : t("orders.backToOrders")}
|
||||
{t("orders.backToOrders")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { OrderMetaLine } from "@/features/orders/order-meta-line";
|
||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||
import {
|
||||
loadPersistedOrderGroup,
|
||||
ticketDetailHref,
|
||||
type TicketItemGroup,
|
||||
} from "@/features/orders/group-ticket-items";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
|
||||
type TicketOrderGroupScreenProps = {
|
||||
groupKey: string;
|
||||
};
|
||||
|
||||
export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
useCurrencyCatalog();
|
||||
|
||||
const decodedKey = decodeURIComponent(groupKey);
|
||||
|
||||
const group = useMemo(
|
||||
(): TicketItemGroup | null => loadPersistedOrderGroup(decodedKey),
|
||||
[decodedKey],
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<PlayerPanel
|
||||
title={t("orders.groupDetail")}
|
||||
backHref="/orders"
|
||||
backLabel={t("orders.title")}
|
||||
>
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center">
|
||||
<p className="text-sm font-bold text-slate-700">{t("orders.groupNotFound")}</p>
|
||||
<Link
|
||||
href="/orders"
|
||||
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#07459f] px-4 text-sm font-bold text-white"
|
||||
>
|
||||
{t("orders.backToOrders")}
|
||||
</Link>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const cur = group.currency_code ?? activeCurrency;
|
||||
const st = ticketStatusDisplay(
|
||||
group.status,
|
||||
group.win_amount,
|
||||
group.jackpot_win_amount,
|
||||
t,
|
||||
creditMode,
|
||||
);
|
||||
const totalWin = group.win_amount + group.jackpot_win_amount;
|
||||
return (
|
||||
<PlayerPanel
|
||||
title={t("orders.groupDetail")}
|
||||
backHref="/orders"
|
||||
backLabel={t("orders.title")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="truncate font-mono text-lg font-black text-[#0b3f96]">
|
||||
{group.draw_no ?? "—"}
|
||||
</p>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<OrderMetaLine orderNo={group.order_no} placedAt={group.placed_at} t={t} />
|
||||
<p className="mt-2 text-[11px] font-bold uppercase tracking-wide text-[#7890b8]">
|
||||
{t("orders.itemCount", { count: group.items.length })}
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
||||
<p className="mt-1 text-sm font-black text-slate-900">
|
||||
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
||||
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{group.status === "partial_failed" ? (
|
||||
<p className="mt-2 text-xs font-bold text-amber-700">
|
||||
{t("orders.partialFailedHint")}
|
||||
</p>
|
||||
) : null}
|
||||
{totalWin > 0 && group.status === "settled_win" ? (
|
||||
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="px-0.5 text-sm font-black text-[#0b3f96]">{t("orders.betItems")}</p>
|
||||
<div className="space-y-2">
|
||||
{group.items.map((row, index) => {
|
||||
const lineCur = row.currency_code ?? cur;
|
||||
const lineSt = ticketStatusDisplay(
|
||||
row.status,
|
||||
row.win_amount,
|
||||
row.jackpot_win_amount,
|
||||
t,
|
||||
creditMode,
|
||||
);
|
||||
const lineWin = row.win_amount + row.jackpot_win_amount;
|
||||
return (
|
||||
<Link
|
||||
key={row.ticket_no}
|
||||
href={ticketDetailHref(row.ticket_no, group)}
|
||||
aria-label={t("orders.viewBetLine")}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border border-[#e5edf8] bg-white px-3 py-3",
|
||||
"shadow-[0_6px_18px_rgba(15,23,42,0.04)] transition-colors hover:border-[#b9ccf6]",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#eaf2ff] text-xs font-black text-[#0b56b7]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-black text-[#0b3f96]">
|
||||
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate font-mono text-[11px] text-slate-500">
|
||||
{t("orders.ticketNo", { ticketNo: row.ticket_no })}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-600">
|
||||
<span>
|
||||
{t("orders.stake")}{" "}
|
||||
<span className="font-bold tabular-nums text-slate-900">
|
||||
{formatMinorAsCurrency(row.total_bet_amount, lineCur)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{t("orders.deduction")}{" "}
|
||||
<span className="font-bold tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{row.status === "failed" || row.status === "refunded" ? (
|
||||
<p className="mt-1 text-xs font-bold text-red-600">
|
||||
{t("orders.lineFailedTitle")}
|
||||
</p>
|
||||
) : null}
|
||||
{lineWin > 0 && row.status === "settled_win" ? (
|
||||
<p className="mt-1 text-xs font-bold text-emerald-600">
|
||||
{t("orders.win", { amount: formatMinorAsCurrency(lineWin, lineCur) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<StatusDot label={lineSt.label} dotClass={lineSt.dotClass} ring={lineSt.ring} />
|
||||
<ChevronRight className="size-4 text-[#7890b8]" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CalendarRange, ChevronDown, Search } from "lucide-react";
|
||||
import { CalendarRange, ChevronDown, ChevronRight, Search, SlidersHorizontal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getDrawCurrent } from "@/api/draw";
|
||||
@@ -14,15 +14,11 @@ import { Input } from "@/components/ui/input";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
groupTicketItems,
|
||||
orderGroupHref,
|
||||
persistOrderGroup,
|
||||
} from "@/features/orders/group-ticket-items";
|
||||
import { groupTicketItems, ticketDetailHref } from "@/features/orders/group-ticket-items";
|
||||
import { OrderMetaLine } from "@/features/orders/order-meta-line";
|
||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
@@ -52,16 +48,19 @@ const STATUS_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
export function TicketOrdersListScreen() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation("player");
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
useCurrencyCatalog();
|
||||
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
|
||||
const numberFilter = useMemo(() => (searchParams.get("number") ?? "").trim(), [searchParams]);
|
||||
const statusFilter = useMemo(
|
||||
() => searchParams.getAll("status").map((s) => s.trim()).filter(Boolean),
|
||||
[searchParams],
|
||||
);
|
||||
const hasUrlFilters = drawNoFilter !== "" || numberFilter !== "" || statusFilter.length > 0;
|
||||
|
||||
const [items, setItems] = useState<TicketItemListRow[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -74,7 +73,11 @@ export function TicketOrdersListScreen() {
|
||||
base: drawNoFilter,
|
||||
draft: drawNoFilter,
|
||||
}));
|
||||
const [queryNumber, setQueryNumber] = useState("");
|
||||
const [queryNumberDraft, setQueryNumberDraft] = useState("");
|
||||
const queryNumber = numberFilter || queryNumberDraft;
|
||||
const [filtersOpen, setFiltersOpen] = useState(hasUrlFilters);
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(false);
|
||||
const filtersExpanded = hasUrlFilters ? !filtersCollapsed : filtersOpen;
|
||||
const [queryStatuses, setQueryStatuses] = useState<string[]>(statusFilter);
|
||||
const [fromDate, setFromDate] = useState("");
|
||||
const [toDate, setToDate] = useState("");
|
||||
@@ -83,7 +86,6 @@ export function TicketOrdersListScreen() {
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
||||
const [scheduleTimezone, setScheduleTimezone] = useState(LOTTERY_SCHEDULE_TIMEZONE);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useIsMobile();
|
||||
const initialLoadDone = useRef(false);
|
||||
|
||||
const queryDrawNoInput =
|
||||
@@ -110,7 +112,6 @@ export function TicketOrdersListScreen() {
|
||||
if (fromDate && toDate) return `${formatCompactDate(fromDate)} ~ ${formatCompactDate(toDate)}`;
|
||||
return `${fromDate ? formatCompactDate(fromDate) : "..." } ~ ${toDate ? formatCompactDate(toDate) : "..."}`;
|
||||
}, [formatCompactDate, fromDate, t, toDate]);
|
||||
const visiblePages = useMemo(() => buildPageWindow(page, lastPage), [lastPage, page]);
|
||||
const orderGroups = useMemo(() => groupTicketItems(items), [items]);
|
||||
|
||||
const fetchPage = useCallback(
|
||||
@@ -214,6 +215,24 @@ export function TicketOrdersListScreen() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-9 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]",
|
||||
filtersExpanded && "border-[#b9ccf6] bg-[#f1f6ff] text-[#0b56b7]",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (hasUrlFilters) {
|
||||
setFiltersCollapsed((value) => !value);
|
||||
return;
|
||||
}
|
||||
setFiltersOpen((value) => !value);
|
||||
}}
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
{filtersExpanded ? t("orders.hideFilters") : t("orders.showFilters")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/hall"
|
||||
className="inline-flex h-9 shrink-0 items-center rounded-full bg-[#e5002c] px-5 text-sm font-black text-white shadow-[0_8px_18px_rgba(229,0,44,0.22)]"
|
||||
@@ -226,13 +245,18 @@ export function TicketOrdersListScreen() {
|
||||
variant="outline"
|
||||
className="h-9 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
onClick={() => {
|
||||
setQueryDrawNoState({ base: drawNoFilter, draft: drawNoFilter });
|
||||
setQueryNumber("");
|
||||
setQueryDrawNoState({ base: "", draft: "" });
|
||||
setQueryNumberDraft("");
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
setQueryStatuses(statusFilter);
|
||||
setQueryStatuses([]);
|
||||
setFiltersOpen(false);
|
||||
setFiltersCollapsed(false);
|
||||
setRangeOpen(false);
|
||||
setStatusOpen(false);
|
||||
if (hasUrlFilters) {
|
||||
router.replace("/orders");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
@@ -241,6 +265,7 @@ export function TicketOrdersListScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtersExpanded ? (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="flex h-9 min-w-0 items-center rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
|
||||
<Input
|
||||
@@ -250,7 +275,7 @@ export function TicketOrdersListScreen() {
|
||||
}
|
||||
placeholder={t("orders.drawNo")}
|
||||
aria-label={t("orders.drawNo")}
|
||||
className="h-7 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
|
||||
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -258,10 +283,10 @@ export function TicketOrdersListScreen() {
|
||||
<Search className="size-3.5 shrink-0 text-slate-400" />
|
||||
<Input
|
||||
value={queryNumber}
|
||||
onChange={(e) => setQueryNumber(e.target.value)}
|
||||
onChange={(e) => setQueryNumberDraft(e.target.value)}
|
||||
placeholder={t("orders.number")}
|
||||
aria-label={t("orders.number")}
|
||||
className="h-7 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
|
||||
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -286,7 +311,7 @@ export function TicketOrdersListScreen() {
|
||||
mode="range"
|
||||
month={calendarMonth}
|
||||
onMonthChange={setCalendarMonth}
|
||||
numberOfMonths={isMobile ? 1 : 2}
|
||||
numberOfMonths={1}
|
||||
selected={selectedRange}
|
||||
onSelect={(range) => {
|
||||
if (!range?.from && !range?.to) {
|
||||
@@ -388,6 +413,7 @@ export function TicketOrdersListScreen() {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -432,113 +458,109 @@ export function TicketOrdersListScreen() {
|
||||
);
|
||||
const totalWin = group.win_amount + group.jackpot_win_amount;
|
||||
return (
|
||||
<Link
|
||||
<div
|
||||
key={group.key}
|
||||
href={orderGroupHref(group)}
|
||||
onClick={() => persistOrderGroup(group)}
|
||||
className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]"
|
||||
className="rounded-xl border border-[#e5edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="min-w-0 truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||
{group.draw_no ?? "—"}
|
||||
</p>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<OrderMetaLine
|
||||
orderNo={group.order_no}
|
||||
placedAt={group.placed_at}
|
||||
t={t}
|
||||
/>
|
||||
<div className="mt-2.5 space-y-1">
|
||||
{group.items.map((row) => (
|
||||
<p
|
||||
key={row.ticket_no}
|
||||
className="text-sm font-semibold text-[#32518d]"
|
||||
>
|
||||
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
||||
<p className="mt-1 text-sm font-black text-slate-900">
|
||||
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
||||
<div className="p-3 pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="min-w-0 truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||
{group.draw_no ?? "—"}
|
||||
</p>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
||||
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
||||
</p>
|
||||
<OrderMetaLine
|
||||
orderNo={group.order_no}
|
||||
placedAt={group.placed_at}
|
||||
t={t}
|
||||
/>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
||||
<p className="mt-1 text-sm font-black text-slate-900">
|
||||
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
||||
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{group.status === "partial_failed" ? (
|
||||
<p className="mt-2 text-xs font-bold text-amber-700">
|
||||
{t("orders.partialFailedHint")}
|
||||
</p>
|
||||
) : null}
|
||||
{totalWin > 0 && group.status === "settled_win" ? (
|
||||
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{group.status === "partial_failed" ? (
|
||||
<p className="mt-2 text-xs font-bold text-amber-700">
|
||||
{t("orders.partialFailedHint")}
|
||||
|
||||
<div className="space-y-2 border-t border-[#edf2f8] px-3 py-3">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wide text-[#7890b8]">
|
||||
{t("orders.betItems")}
|
||||
</p>
|
||||
) : null}
|
||||
{totalWin > 0 && group.status === "settled_win" ? (
|
||||
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||
</p>
|
||||
) : null}
|
||||
</Link>
|
||||
{group.items.map((row, index) => {
|
||||
const lineSt = ticketStatusDisplay(
|
||||
row.status,
|
||||
row.win_amount,
|
||||
row.jackpot_win_amount,
|
||||
t,
|
||||
creditMode,
|
||||
);
|
||||
const lineCur = row.currency_code ?? cur;
|
||||
return (
|
||||
<Link
|
||||
key={row.ticket_no}
|
||||
href={ticketDetailHref(row.ticket_no)}
|
||||
aria-label={t("orders.viewBetLine")}
|
||||
className="flex items-center gap-3 rounded-xl border border-[#e5edf8] bg-[#fbfdff] px-3 py-2.5 transition-colors hover:border-[#b9ccf6] hover:bg-white"
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#eaf2ff] text-xs font-black text-[#0b56b7]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-black text-[#0b3f96]">
|
||||
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{t("orders.deduction")}{" "}
|
||||
<span className="font-bold tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<StatusDot
|
||||
label={lineSt.label}
|
||||
dotClass={lineSt.dotClass}
|
||||
ring={lineSt.ring}
|
||||
/>
|
||||
<ChevronRight className="size-4 text-[#7890b8]" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isMobile ? <div ref={loadMoreRef} className="min-h-1" /> : null}
|
||||
{isMobile && page < lastPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
disabled={loadingMore}
|
||||
onClick={() => void fetchPage(page + 1, true)}
|
||||
>
|
||||
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
|
||||
</Button>
|
||||
) : !isMobile && lastPage > 1 ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 rounded-xl border border-[#e6edf8] bg-white px-3 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full border-[#dce7f7] bg-white text-[#32518d]"
|
||||
disabled={loading || page <= 1}
|
||||
onClick={() => void fetchPage(Math.max(1, page - 1), false)}
|
||||
>
|
||||
{t("actions.previous")}
|
||||
</Button>
|
||||
{visiblePages.map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
type="button"
|
||||
variant={p === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"min-w-8 rounded-full",
|
||||
p === page
|
||||
? "bg-[#07459f] text-white hover:bg-[#063b88]"
|
||||
: "border-[#dce7f7] bg-white text-[#32518d]",
|
||||
)}
|
||||
disabled={loading}
|
||||
onClick={() => void fetchPage(p, false)}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full border-[#dce7f7] bg-white text-[#32518d]"
|
||||
disabled={loading || page >= lastPage}
|
||||
onClick={() => void fetchPage(Math.min(lastPage, page + 1), false)}
|
||||
>
|
||||
{t("actions.next")}
|
||||
</Button>
|
||||
</div>
|
||||
<div ref={loadMoreRef} className="min-h-1" />
|
||||
{page < lastPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
disabled={loadingMore}
|
||||
onClick={() => void fetchPage(page + 1, true)}
|
||||
>
|
||||
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
|
||||
</Button>
|
||||
) : lastPage > 1 ? (
|
||||
<p className="py-2 text-center text-xs text-slate-400">
|
||||
{t("orders.noMore")}
|
||||
@@ -551,11 +573,4 @@ export function TicketOrdersListScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function buildPageWindow(current: number, last: number): number[] {
|
||||
if (last <= 5) {
|
||||
return Array.from({ length: last }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
const start = Math.max(1, Math.min(current - 2, last - 4));
|
||||
return Array.from({ length: 5 }, (_, index) => start + index);
|
||||
}
|
||||
|
||||
@@ -116,6 +116,14 @@ export function EntryGate() {
|
||||
|
||||
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
|
||||
usePlayerSessionStore();
|
||||
/** 仅主站 iframe 内复用已有 token;直连站点只认 URL `?token=`(SSO) */
|
||||
const isReturningSession =
|
||||
typeof window !== "undefined" &&
|
||||
isInIframe() &&
|
||||
!sessionExpired &&
|
||||
!tokenFromUrl &&
|
||||
(bearerToken ?? "").trim() !== "";
|
||||
const [resumeSilent, setResumeSilent] = useState(isReturningSession);
|
||||
const waitingForEmbeddedToken =
|
||||
!sessionExpired &&
|
||||
typeof window !== "undefined" &&
|
||||
@@ -128,13 +136,11 @@ export function EntryGate() {
|
||||
|
||||
if (!isInIframe()) {
|
||||
if (sessionExpired) return false;
|
||||
|
||||
const hasToken = tokenFromUrl !== "" || (bearerToken ?? "").trim() !== "";
|
||||
if (!hasToken) return false;
|
||||
return tokenFromUrl !== "";
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [bearerToken, sessionExpired, tokenFromUrl]);
|
||||
}, [sessionExpired, tokenFromUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gateReady) return;
|
||||
@@ -143,13 +149,10 @@ export function EntryGate() {
|
||||
|
||||
if (sessionExpired) {
|
||||
router.replace("/login?session=expired");
|
||||
} else {
|
||||
const hasToken = tokenFromUrl !== "" || (bearerToken ?? "").trim() !== "";
|
||||
if (!hasToken) {
|
||||
router.replace("/login");
|
||||
}
|
||||
} else if (!tokenFromUrl) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [gateReady, router, sessionExpired, tokenFromUrl, bearerToken]);
|
||||
}, [gateReady, router, sessionExpired, tokenFromUrl]);
|
||||
|
||||
const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading");
|
||||
const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() =>
|
||||
@@ -164,7 +167,15 @@ export function EntryGate() {
|
||||
);
|
||||
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
|
||||
|
||||
const effectiveToken = tokenFromUrl || bearerToken;
|
||||
const entryToken = useMemo(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return tokenFromUrl || bearerToken;
|
||||
}
|
||||
if (!isInIframe()) {
|
||||
return tokenFromUrl;
|
||||
}
|
||||
return tokenFromUrl || bearerToken;
|
||||
}, [bearerToken, tokenFromUrl]);
|
||||
/** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下 */
|
||||
const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle");
|
||||
|
||||
@@ -183,7 +194,7 @@ export function EntryGate() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!effectiveToken) {
|
||||
if (!entryToken) {
|
||||
// 主站 iframe:token 由 MAIN_INIT_TOKEN 稍后到达,勿先闪「授权失败」
|
||||
if (typeof window !== "undefined" && isInIframe() && !tokenFromUrl) {
|
||||
return;
|
||||
@@ -199,6 +210,7 @@ export function EntryGate() {
|
||||
}
|
||||
|
||||
entryLifecycleRef.current = "running";
|
||||
setResumeSilent(false);
|
||||
setPhase("loading");
|
||||
setFailureDetails([]);
|
||||
|
||||
@@ -298,7 +310,7 @@ export function EntryGate() {
|
||||
},
|
||||
]);
|
||||
}, [
|
||||
effectiveToken,
|
||||
entryToken,
|
||||
tokenFromUrl,
|
||||
setBearerToken,
|
||||
setProfile,
|
||||
@@ -323,26 +335,75 @@ export function EntryGate() {
|
||||
stripSearchParamFromBrowserUrl("session");
|
||||
}, [sessionExpired, clearBearerToken]);
|
||||
|
||||
const trimmedEffectiveToken = (effectiveToken ?? "").trim();
|
||||
const trimmedEntryToken = (entryToken ?? "").trim();
|
||||
const doEntryRef = useRef(doEntry);
|
||||
useEffect(() => {
|
||||
doEntryRef.current = doEntry;
|
||||
}, [doEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionExpired || waitingForEmbeddedToken) return;
|
||||
if (sessionExpired || waitingForEmbeddedToken || isReturningSession) return;
|
||||
if (entryLifecycleRef.current !== "idle") return;
|
||||
if (!trimmedEffectiveToken) return;
|
||||
if (!trimmedEntryToken) return;
|
||||
|
||||
const tmr = window.setTimeout(() => {
|
||||
void doEntryRef.current();
|
||||
}, 300);
|
||||
return () => window.clearTimeout(tmr);
|
||||
}, [sessionExpired, trimmedEffectiveToken, waitingForEmbeddedToken]);
|
||||
}, [isReturningSession, sessionExpired, trimmedEntryToken, waitingForEmbeddedToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReturningSession) return;
|
||||
if (entryLifecycleRef.current === "running" || entryLifecycleRef.current === "done") {
|
||||
return;
|
||||
}
|
||||
|
||||
entryLifecycleRef.current = "running";
|
||||
let cancelled = false;
|
||||
|
||||
const resumeToHall = async () => {
|
||||
try {
|
||||
if (!usePlayerSessionStore.getState().profile) {
|
||||
const [me] = await Promise.all([getPlayerMe(), sleep(300)]);
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const currencies = await getPublicCurrencies();
|
||||
setCurrencies(currencies.items);
|
||||
} catch {
|
||||
/* 不阻断回大厅 */
|
||||
}
|
||||
setProfile(me);
|
||||
await Promise.all([getPlayerPing(), sleep(200)]);
|
||||
if (cancelled) return;
|
||||
}
|
||||
entryLifecycleRef.current = "done";
|
||||
router.replace("/hall");
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (err instanceof LotteryApiBizError) {
|
||||
entryLifecycleRef.current = "done";
|
||||
clearBearerToken();
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
entryLifecycleRef.current = "idle";
|
||||
setResumeSilent(false);
|
||||
void doEntryRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
void resumeToHall();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (entryLifecycleRef.current === "running") {
|
||||
entryLifecycleRef.current = "idle";
|
||||
}
|
||||
};
|
||||
}, [clearBearerToken, isReturningSession, router, setCurrencies, setProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionExpired) return;
|
||||
if (tokenFromUrl || effectiveToken) return;
|
||||
if (tokenFromUrl || bearerToken) return;
|
||||
if (typeof window === "undefined" || !isInIframe()) return;
|
||||
|
||||
const tmr = window.setTimeout(() => {
|
||||
@@ -354,14 +415,22 @@ export function EntryGate() {
|
||||
}, IFRAME_TOKEN_WAIT_MS);
|
||||
|
||||
return () => window.clearTimeout(tmr);
|
||||
}, [sessionExpired, effectiveToken, tokenFromUrl]);
|
||||
}, [bearerToken, sessionExpired, tokenFromUrl]);
|
||||
|
||||
if (!gateReady) {
|
||||
return null;
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center bg-white">
|
||||
<Loader2 className="size-8 animate-spin text-red-600" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resumeSilent && phase === "loading") {
|
||||
return <EntryBusyScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col bg-white">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-white">
|
||||
<div className={cn("relative h-[45vh] min-h-[200px]", phase === "success" ? "bg-white" : "bg-red-600")}>
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<Image
|
||||
@@ -472,7 +541,8 @@ export function EntryGate() {
|
||||
{t("failure.detailsTitle")}
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch]">
|
||||
<table className="w-full min-w-[20rem] text-sm">
|
||||
<thead className="bg-red-100/50 text-xs">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-700">
|
||||
@@ -502,6 +572,7 @@ export function EntryGate() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -586,6 +657,17 @@ export function EntryGate() {
|
||||
);
|
||||
}
|
||||
|
||||
function EntryBusyScreen() {
|
||||
const { t } = useTranslation("entry");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 bg-white px-6">
|
||||
<Loader2 className="size-10 animate-spin text-red-600" aria-hidden />
|
||||
<p className="text-sm font-medium text-slate-600">{t("loading.title")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryStatusBadge({ status }: { status: EntryStepStatus }) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { BellRing, CheckCheck } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import {
|
||||
pendingReconcileDescriptionKey,
|
||||
pendingReconcileTitleKey,
|
||||
} from "@/lib/pending-reconcile-notification";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function NotificationsScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
const { pending, unreadPending, unreadCount, loading, markAsRead, markAllAsRead } =
|
||||
usePendingWalletReconcile();
|
||||
const unreadSet = new Set(unreadPending.map((item) => item.transfer_no));
|
||||
|
||||
return (
|
||||
<PlayerPanel title={t("notifications.title")} backHref="/hall">
|
||||
<div className="space-y-3">
|
||||
{creditMode ? (
|
||||
<div className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
|
||||
{t("notifications.creditEmptyHint", {
|
||||
defaultValue: "信用盘无主站划转,暂无待对账通知。",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between rounded-xl border border-[#dce7f7] bg-[#f8fbff] px-3 py-2.5">
|
||||
<p className="text-sm font-semibold text-[#0b3f96]">
|
||||
{t("notifications.unreadCount", { count: unreadCount })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-xs font-bold text-[#0b56b7] hover:bg-[#ebf2ff]"
|
||||
onClick={markAllAsRead}
|
||||
disabled={pending.length === 0 || unreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="mr-1 size-3.5" aria-hidden />
|
||||
{t("notifications.markAllRead")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && pending.length === 0 ? (
|
||||
<div className="rounded-xl border border-[#dce7f7] bg-white px-4 py-8 text-center text-sm text-slate-500">
|
||||
{t("actions.loading")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && pending.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-white px-4 py-10 text-center">
|
||||
<BellRing className="mx-auto size-5 text-slate-400" aria-hidden />
|
||||
<p className="mt-2 text-sm text-slate-500">{t("notifications.empty")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{pending.map((item) => {
|
||||
const cardRead = !unreadSet.has(item.transfer_no);
|
||||
return (
|
||||
<li
|
||||
key={item.transfer_no}
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-3 transition-colors",
|
||||
cardRead
|
||||
? "border-[#e4eaf4] bg-white"
|
||||
: "border-amber-200 bg-amber-50/80",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold text-amber-900">
|
||||
{t(pendingReconcileTitleKey(item.type))}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{formatPlayerInstant(item.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-bold text-amber-800">
|
||||
{t("notifications.pendingBadge")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-semibold",
|
||||
cardRead ? "text-slate-400" : "text-amber-700",
|
||||
)}
|
||||
>
|
||||
{cardRead ? t("notifications.read") : t("notifications.unread")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs leading-relaxed text-amber-950/85">
|
||||
{t(pendingReconcileDescriptionKey(item.type))}
|
||||
</p>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-700">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
{t("notifications.amountLabel")}{" "}
|
||||
</span>
|
||||
{formatMinorAsCurrency(item.amount, item.currency_code)}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-[#dce7f7] px-3 text-xs font-semibold text-[#0b56b7]"
|
||||
onClick={() => markAsRead(item.transfer_no)}
|
||||
>
|
||||
{t("notifications.markRead")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/wallet/logs"
|
||||
className="inline-flex h-8 items-center rounded-full bg-[#07459f] px-3 text-xs font-semibold text-white hover:bg-[#063b88]"
|
||||
onClick={() => markAsRead(item.transfer_no)}
|
||||
>
|
||||
{t("notifications.viewLogs")}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col bg-white">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-white">
|
||||
<div className="relative h-[45vh] min-h-[200px] shrink-0 overflow-hidden bg-[#f8fafc]">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<Image src="/entry/image1.png" alt="" fill sizes="100vw" className="object-cover object-center" priority />
|
||||
|
||||
29
src/features/results/check-winning-redirect.tsx
Normal file
29
src/features/results/check-winning-redirect.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getDrawResults } from "@/api/draw";
|
||||
|
||||
/** 旧 `/results/check` 路由:跳转到最新期详情并展开查奖面板 */
|
||||
export function CheckWinningRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getDrawResults({ page: 1, size: 1 });
|
||||
const drawNo = res.items[0]?.draw_no;
|
||||
if (drawNo) {
|
||||
router.replace(`/results/${encodeURIComponent(drawNo)}?check=1`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* 回落到列表 */
|
||||
}
|
||||
router.replace("/results");
|
||||
})();
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,310 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { BriefcaseBusiness, CheckCircle2, Clock3, RefreshCw, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getDrawResults } from "@/api/draw";
|
||||
import { getTicketDrawMyMatch, getTicketItems } from "@/api/ticket-items";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items";
|
||||
|
||||
type WinningCheckResult = {
|
||||
draw: DrawResultListItem;
|
||||
match: TicketDrawMyMatchPayload;
|
||||
tickets: TicketItemListRow[];
|
||||
};
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-panel";
|
||||
|
||||
/** 保留组件供内嵌使用;独立路由已 redirect 至期号详情 */
|
||||
export function CheckWinningScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
useCurrencyCatalog();
|
||||
const [ticketNo, setTicketNo] = useState("");
|
||||
const [latestDraw, setLatestDraw] = useState<DrawResultListItem | null>(null);
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
const [result, setResult] = useState<WinningCheckResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drawNo, setDrawNo] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getDrawResults({ page: 1, size: 1 });
|
||||
setLatestDraw(res.items[0] ?? null);
|
||||
} catch {
|
||||
setLatestDraw(null);
|
||||
}
|
||||
})();
|
||||
});
|
||||
void getDrawResults({ page: 1, size: 1 })
|
||||
.then((res) => setDrawNo(res.items[0]?.draw_no ?? null))
|
||||
.catch(() => setDrawNo(null));
|
||||
}, []);
|
||||
|
||||
const normalizedTicketNo = ticketNo.trim();
|
||||
|
||||
const runCheck = useCallback(async () => {
|
||||
if (!latestDraw || normalizedTicketNo === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [match, tickets] = await Promise.all([
|
||||
getTicketDrawMyMatch(latestDraw.draw_no),
|
||||
getTicketItems({
|
||||
draw_no: latestDraw.draw_no,
|
||||
number: normalizedTicketNo,
|
||||
per_page: 10,
|
||||
page: 1,
|
||||
}),
|
||||
]);
|
||||
const next = {
|
||||
draw: latestDraw,
|
||||
match,
|
||||
tickets: tickets.items,
|
||||
};
|
||||
setResult(next);
|
||||
setRecent((current) => [normalizedTicketNo, ...current.filter((x) => x !== normalizedTicketNo)].slice(0, 5));
|
||||
} catch {
|
||||
setError(t("results.check.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [latestDraw, normalizedTicketNo, t]);
|
||||
|
||||
return (
|
||||
<PlayerPanel title={t("results.check.title")} backHref="/results" backLabel={t("results.title")}>
|
||||
<div className="space-y-3">
|
||||
<section className="overflow-hidden rounded-2xl border border-red-100 bg-white shadow-[0_12px_32px_rgba(15,23,42,0.06)]">
|
||||
<div className="bg-gradient-to-b from-red-50 to-white px-5 pb-5 pt-8 text-center">
|
||||
<div className="mx-auto flex size-24 items-center justify-center rounded-full bg-white text-[#e5002c] shadow-[0_18px_40px_rgba(229,0,44,0.14)]">
|
||||
<BriefcaseBusiness className="size-12" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-lg font-black text-slate-950">
|
||||
{t("results.check.enterTicket")}
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-slate-500">
|
||||
{t("results.check.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 px-3 pb-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-black text-slate-700">
|
||||
{t("results.check.ticketNumber")}
|
||||
</span>
|
||||
<Input
|
||||
value={ticketNo}
|
||||
placeholder={t("results.check.placeholder")}
|
||||
onChange={(e) => setTicketNo(e.target.value)}
|
||||
className="h-12 rounded-xl border-[#dce7f7] bg-white font-mono text-base font-bold"
|
||||
/>
|
||||
</label>
|
||||
{latestDraw ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("results.check.latestDraw", { drawNo: latestDraw.draw_no })}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p className="text-sm font-semibold text-[#e5002c]">{error}</p> : null}
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!latestDraw || normalizedTicketNo === "" || loading}
|
||||
onClick={() => void runCheck()}
|
||||
className="h-12 w-full rounded-xl bg-[#e5002c] text-base font-black text-white hover:bg-[#d10028]"
|
||||
>
|
||||
{loading ? t("results.check.loading") : t("results.check.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[#dfe8f6] bg-white p-4 shadow-[0_10px_26px_rgba(15,23,42,0.05)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-black text-slate-950">
|
||||
{t("results.check.recent")}
|
||||
</h3>
|
||||
{recent.length > 0 ? (
|
||||
<button type="button" className="text-sm font-bold text-[#0b56b7]" onClick={() => setRecent([])}>
|
||||
{t("actions.clear")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 divide-y divide-[#edf2f9]">
|
||||
{recent.length === 0 ? (
|
||||
<p className="py-4 text-sm text-slate-500">
|
||||
{t("results.check.noRecent")}
|
||||
</p>
|
||||
) : (
|
||||
recent.map((row) => (
|
||||
<button
|
||||
key={row}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between py-3 text-left"
|
||||
onClick={() => setTicketNo(row)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-mono text-sm font-black text-slate-800">
|
||||
<Clock3 className="size-4 text-slate-400" />
|
||||
{row}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{latestDraw?.business_date ?? "—"}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<WinningResultDialog
|
||||
open={result !== null}
|
||||
data={result}
|
||||
query={normalizedTicketNo}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResult(null);
|
||||
}}
|
||||
onCheckAnother={() => {
|
||||
setResult(null);
|
||||
setTicketNo("");
|
||||
}}
|
||||
/>
|
||||
{drawNo ? (
|
||||
<DrawWinningCheckPanel drawNo={drawNo} collapsible={false} />
|
||||
) : (
|
||||
<Skeleton className="h-56 rounded-2xl" />
|
||||
)}
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function WinningResultDialog({
|
||||
open,
|
||||
data,
|
||||
query,
|
||||
onOpenChange,
|
||||
onCheckAnother,
|
||||
}: {
|
||||
open: boolean;
|
||||
data: WinningCheckResult | null;
|
||||
query: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCheckAnother: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const totalWin = (data?.match.total_win_minor ?? 0) + (data?.match.total_jackpot_win_minor ?? 0);
|
||||
const isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0;
|
||||
const firstTicket = useMemo(() => data?.tickets[0] ?? null, [data]);
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const currency = firstTicket?.currency_code ?? activeCurrency;
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-h-[calc(100dvh-24px)] flex-col gap-0 overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.28)] sm:max-w-md"
|
||||
>
|
||||
<div className="relative shrink-0 px-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-3 top-3 z-10 inline-flex size-9 items-center justify-center rounded-full text-slate-500 hover:bg-slate-100"
|
||||
aria-label={t("actions.close")}
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</button>
|
||||
<DialogHeader className="items-center text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full border-4 border-emerald-100 bg-white text-emerald-600">
|
||||
<CheckCircle2 className="size-11" />
|
||||
</div>
|
||||
<DialogTitle className="mt-3 text-xl font-black text-slate-950">
|
||||
{isWon
|
||||
? t("results.check.winTitle")
|
||||
: t("results.check.noWinTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-slate-500">
|
||||
{t("results.check.ticketNumber")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
|
||||
<div className="mx-auto mt-3 w-fit rounded-xl bg-emerald-50 px-8 py-2 font-mono text-lg font-black text-[#0a8f3e]">
|
||||
{query}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 overflow-hidden rounded-xl border border-emerald-100 bg-emerald-50 text-center">
|
||||
<div className="border-r border-emerald-100 px-3 py-4">
|
||||
<p className="text-xs font-medium text-slate-500">
|
||||
{t("results.check.match")}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-black text-[#0a8f3e]">
|
||||
{firstTicket ? playLabel(firstTicket.play_code, t) : isWon ? t("orders.hit") : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 py-4">
|
||||
<p className="text-xs font-medium text-slate-500">
|
||||
{t("results.check.amount")}
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-lg font-black text-[#0a8f3e]">
|
||||
{formatMinorAsCurrency(totalWin, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-xl border border-[#e8eef7] bg-white p-4 text-sm">
|
||||
<p className="font-black text-slate-950">
|
||||
{t("results.check.drawInfo")}
|
||||
</p>
|
||||
<div className="mt-2.5 grid grid-cols-2 gap-3 text-slate-500">
|
||||
<div>
|
||||
<p className="text-xs">{t("results.check.issueNo")}</p>
|
||||
<p className="mt-1 font-mono font-black text-slate-900">{data.draw.draw_no}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs">{t("results.businessDate")}</p>
|
||||
<p className="mt-1 font-semibold text-slate-900">{data.draw.business_date}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs">{t("results.drawTime", { time: "" }).replace(":", "").trim()}</p>
|
||||
<p className="mt-1 font-semibold text-slate-900">
|
||||
{formatPlayerInstant(data.draw.draw_time_iso ?? data.draw.draw_time ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
nativeButton={false}
|
||||
className="h-12 rounded-xl bg-[#07459f] text-base font-black text-white hover:bg-[#063b88]"
|
||||
render={<Link href={`/orders?draw_no=${encodeURIComponent(data.draw.draw_no)}&number=${encodeURIComponent(query)}`} />}
|
||||
>
|
||||
{t("results.check.viewBetDetails")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 rounded-xl border-[#ff3650] text-base font-black text-[#e5002c] hover:bg-[#fff5f6]"
|
||||
onClick={onCheckAnother}
|
||||
>
|
||||
<RefreshCw className="size-5" />
|
||||
{t("results.check.checkAnother")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getDrawResultByNo } from "@/api/draw";
|
||||
import { getTicketDrawMyMatch } from "@/api/ticket-items";
|
||||
import Link from "next/link";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-panel";
|
||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
@@ -37,6 +39,8 @@ type DrawResultDetailScreenProps = {
|
||||
/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
|
||||
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const searchParams = useSearchParams();
|
||||
const checkOpen = searchParams.get("check") === "1";
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
useCurrencyCatalog();
|
||||
@@ -268,20 +272,14 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] px-3 py-3">
|
||||
<p className="text-xs leading-relaxed text-slate-500">
|
||||
{t("results.hitHint")}
|
||||
</p>
|
||||
<Link
|
||||
href="/results/check"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "default", size: "sm" }),
|
||||
"mt-3 h-10 w-full rounded-xl bg-[#e5002c] text-white hover:bg-[#d10028]",
|
||||
)}
|
||||
>
|
||||
{t("results.viewMyWinning")}
|
||||
</Link>
|
||||
</div>
|
||||
<DrawWinningCheckPanel
|
||||
key={checkOpen ? `${data.draw_no}:check` : data.draw_no}
|
||||
drawNo={data.draw_no}
|
||||
businessDate={data.business_date}
|
||||
drawTimeIso={data.draw_time_iso}
|
||||
drawTime={data.draw_time}
|
||||
defaultOpen={checkOpen}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
@@ -259,14 +259,17 @@ export function DrawResultsListScreen() {
|
||||
{t("results.openDetail", { defaultValue: "查看详情" })}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<TwentyThreeResultsGrid numbers={featured.results} />
|
||||
<Link
|
||||
href="/results/check"
|
||||
className="mt-4 inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#e5002c] px-4 text-sm font-bold text-white transition-colors hover:bg-[#d10028]"
|
||||
>
|
||||
{t("results.viewMyWinning")}
|
||||
</Link>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||
<div key={tier} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
||||
<p className="text-[11px] font-bold text-[#7890b8]">
|
||||
{t(resultsPrizeLabelKey(tier))}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||||
{featured.results[tier]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
354
src/features/results/draw-winning-check-panel.tsx
Normal file
354
src/features/results/draw-winning-check-panel.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { BriefcaseBusiness, CheckCircle2, ChevronDown, Clock3, RefreshCw, XIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getTicketDrawMyMatch, getTicketItems } from "@/api/ticket-items";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items";
|
||||
|
||||
type WinningCheckResult = {
|
||||
draw: DrawResultListItem;
|
||||
match: TicketDrawMyMatchPayload;
|
||||
tickets: TicketItemListRow[];
|
||||
};
|
||||
|
||||
export type DrawWinningCheckPanelProps = {
|
||||
drawNo: string;
|
||||
businessDate?: string | null;
|
||||
drawTimeIso?: string | null;
|
||||
drawTime?: string | null;
|
||||
defaultOpen?: boolean;
|
||||
collapsible?: boolean;
|
||||
};
|
||||
|
||||
export function DrawWinningCheckPanel({
|
||||
drawNo,
|
||||
businessDate,
|
||||
drawTimeIso,
|
||||
drawTime,
|
||||
defaultOpen = false,
|
||||
collapsible = true,
|
||||
}: DrawWinningCheckPanelProps) {
|
||||
const { t } = useTranslation("player");
|
||||
useCurrencyCatalog();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [ticketNo, setTicketNo] = useState("");
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
const [result, setResult] = useState<WinningCheckResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedTicketNo = ticketNo.trim();
|
||||
|
||||
const runCheck = useCallback(async () => {
|
||||
if (normalizedTicketNo === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [match, tickets] = await Promise.all([
|
||||
getTicketDrawMyMatch(drawNo),
|
||||
getTicketItems({
|
||||
draw_no: drawNo,
|
||||
number: normalizedTicketNo,
|
||||
per_page: 10,
|
||||
page: 1,
|
||||
}),
|
||||
]);
|
||||
const next = {
|
||||
draw: {
|
||||
draw_id: "",
|
||||
draw_no: drawNo,
|
||||
business_date: businessDate ?? "",
|
||||
draw_time: drawTime ?? null,
|
||||
draw_time_iso: drawTimeIso ?? null,
|
||||
result_version: 0,
|
||||
result_source: null,
|
||||
results: { "1st": "", "2nd": "", "3rd": "", starter: [], consolation: [] },
|
||||
result_items: [],
|
||||
} satisfies DrawResultListItem,
|
||||
match,
|
||||
tickets: tickets.items,
|
||||
};
|
||||
setResult(next);
|
||||
setRecent((current) =>
|
||||
[normalizedTicketNo, ...current.filter((x) => x !== normalizedTicketNo)].slice(0, 5),
|
||||
);
|
||||
} catch {
|
||||
setError(t("results.check.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [businessDate, drawNo, drawTime, drawTimeIso, normalizedTicketNo, t]);
|
||||
|
||||
const panelBody = (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-black text-slate-700">
|
||||
{t("results.check.ticketNumber")}
|
||||
</span>
|
||||
<Input
|
||||
value={ticketNo}
|
||||
placeholder={t("results.check.placeholder")}
|
||||
onChange={(e) => setTicketNo(e.target.value)}
|
||||
className="h-12 rounded-xl border-[#dce7f7] bg-white font-mono text-base font-bold"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("results.check.forDraw", { drawNo })}
|
||||
</p>
|
||||
{error ? <p className="text-sm font-semibold text-[#e5002c]">{error}</p> : null}
|
||||
<Button
|
||||
type="button"
|
||||
disabled={normalizedTicketNo === "" || loading}
|
||||
onClick={() => void runCheck()}
|
||||
className="h-12 w-full rounded-xl bg-[#e5002c] text-base font-black text-white hover:bg-[#d10028]"
|
||||
>
|
||||
{loading ? t("results.check.loading") : t("results.check.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[#dfe8f6] bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-black text-slate-950">{t("results.check.recent")}</h3>
|
||||
{recent.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-bold text-[#0b56b7]"
|
||||
onClick={() => setRecent([])}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 divide-y divide-[#edf2f9]">
|
||||
{recent.length === 0 ? (
|
||||
<p className="py-4 text-sm text-slate-500">{t("results.check.noRecent")}</p>
|
||||
) : (
|
||||
recent.map((row) => (
|
||||
<button
|
||||
key={row}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between py-3 text-left"
|
||||
onClick={() => setTicketNo(row)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-mono text-sm font-black text-slate-800">
|
||||
<Clock3 className="size-4 text-slate-400" />
|
||||
{row}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{businessDate ?? "—"}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="overflow-hidden rounded-2xl border border-red-100 bg-white shadow-[0_12px_32px_rgba(15,23,42,0.06)]">
|
||||
{collapsible ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 bg-gradient-to-b from-red-50 to-white px-4 py-4 text-left"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-white text-[#e5002c] shadow-[0_12px_28px_rgba(229,0,44,0.12)]">
|
||||
<BriefcaseBusiness className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-black text-slate-950">
|
||||
{t("results.check.title")}
|
||||
</h2>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">
|
||||
{t("results.check.forDraw", { drawNo })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-5 shrink-0 text-[#7890b8] transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{open ? <div className="border-t border-red-100 px-3 pb-3 pt-2">{panelBody}</div> : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-gradient-to-b from-red-50 to-white px-5 pb-5 pt-8 text-center">
|
||||
<div className="mx-auto flex size-24 items-center justify-center rounded-full bg-white text-[#e5002c] shadow-[0_18px_40px_rgba(229,0,44,0.14)]">
|
||||
<BriefcaseBusiness className="size-12" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-lg font-black text-slate-950">
|
||||
{t("results.check.enterTicket")}
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-slate-500">
|
||||
{t("results.check.forDraw", { drawNo })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pb-3">{panelBody}</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<WinningResultDialog
|
||||
open={result !== null}
|
||||
data={result}
|
||||
query={normalizedTicketNo}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setResult(null);
|
||||
}}
|
||||
onCheckAnother={() => {
|
||||
setResult(null);
|
||||
setTicketNo("");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WinningResultDialog({
|
||||
open,
|
||||
data,
|
||||
query,
|
||||
onOpenChange,
|
||||
onCheckAnother,
|
||||
}: {
|
||||
open: boolean;
|
||||
data: WinningCheckResult | null;
|
||||
query: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCheckAnother: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const totalWin = (data?.match.total_win_minor ?? 0) + (data?.match.total_jackpot_win_minor ?? 0);
|
||||
const isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0;
|
||||
const firstTicket = data?.tickets[0] ?? null;
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const currency = firstTicket?.currency_code ?? activeCurrency;
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-h-[calc(100dvh-24px)] flex-col gap-0 overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.28)] sm:max-w-md"
|
||||
>
|
||||
<div className="relative shrink-0 px-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-3 top-3 z-10 inline-flex size-9 items-center justify-center rounded-full text-slate-500 hover:bg-slate-100"
|
||||
aria-label={t("actions.close")}
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</button>
|
||||
<DialogHeader className="items-center text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full border-4 border-emerald-100 bg-white text-emerald-600">
|
||||
<CheckCircle2 className="size-11" />
|
||||
</div>
|
||||
<DialogTitle className="mt-3 text-xl font-black text-slate-950">
|
||||
{isWon ? t("results.check.winTitle") : t("results.check.noWinTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-slate-500">
|
||||
{t("results.check.ticketNumber")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
|
||||
<div className="mx-auto mt-3 w-fit rounded-xl bg-emerald-50 px-8 py-2 font-mono text-lg font-black text-[#0a8f3e]">
|
||||
{query}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 overflow-hidden rounded-xl border border-emerald-100 bg-emerald-50 text-center">
|
||||
<div className="border-r border-emerald-100 px-3 py-4">
|
||||
<p className="text-xs font-medium text-slate-500">{t("results.check.match")}</p>
|
||||
<p className="mt-2 text-lg font-black text-[#0a8f3e]">
|
||||
{firstTicket ? playLabel(firstTicket.play_code, t) : isWon ? t("orders.hit") : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 py-4">
|
||||
<p className="text-xs font-medium text-slate-500">{t("results.check.amount")}</p>
|
||||
<p className="mt-2 font-mono text-lg font-black text-[#0a8f3e]">
|
||||
{formatMinorAsCurrency(totalWin, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-xl border border-[#e8eef7] bg-white p-4 text-sm">
|
||||
<p className="font-black text-slate-950">{t("results.check.drawInfo")}</p>
|
||||
<div className="mt-2.5 grid grid-cols-2 gap-3 text-slate-500">
|
||||
<div>
|
||||
<p className="text-xs">{t("results.check.issueNo")}</p>
|
||||
<p className="mt-1 font-mono font-black text-slate-900">{data.draw.draw_no}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs">{t("results.businessDate")}</p>
|
||||
<p className="mt-1 font-semibold text-slate-900">{data.draw.business_date}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs">{t("results.drawTime", { time: "" }).replace(":", "").trim()}</p>
|
||||
<p className="mt-1 font-semibold text-slate-900">
|
||||
{formatPlayerInstant(data.draw.draw_time_iso ?? data.draw.draw_time ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
nativeButton={false}
|
||||
className="h-12 rounded-xl bg-[#07459f] text-base font-black text-white hover:bg-[#063b88]"
|
||||
render={
|
||||
<Link
|
||||
href={`/orders?draw_no=${encodeURIComponent(data.draw.draw_no)}&number=${encodeURIComponent(query)}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("results.check.viewBetDetails")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 rounded-xl border-[#ff3650] text-base font-black text-[#e5002c] hover:bg-[#fff5f6]"
|
||||
onClick={onCheckAnother}
|
||||
>
|
||||
<RefreshCw className="size-5" />
|
||||
{t("results.check.checkAnother")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
|
||||
import { WalletTransferCreditGuard } from "@/features/wallet/wallet-transfer-credit-guard";
|
||||
import { WalletTransferLoadingPanel } from "@/features/wallet/wallet-transfer-loading-panel";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||
|
||||
const BALANCE_KEY = (currency: string) => ["wallet/balance", currency];
|
||||
|
||||
/** 独立路由 `/wallet/transfer-in` */
|
||||
export function TransferInScreen() {
|
||||
const router = useRouter();
|
||||
const { activeCurrency: currency } = useActivePlayerCurrency();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const { data: balance, isLoading: loading } = useApiQuery(
|
||||
BALANCE_KEY(currency),
|
||||
() => getWalletBalance({ currency }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
||||
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||
}, [mutate, currency]);
|
||||
|
||||
const onSuccess = useCallback(async () => {
|
||||
await mutate(BALANCE_KEY(currency));
|
||||
router.push("/wallet");
|
||||
}, [mutate, currency, router]);
|
||||
|
||||
if (loading && !balance) {
|
||||
return <WalletTransferLoadingPanel titleKey="wallet.transferInTitle" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WalletTransferCreditGuard balance={balance} loading={loading}>
|
||||
<TransferInPage
|
||||
currency={currency}
|
||||
lotteryMinor={Number(balance?.balance ?? 0)}
|
||||
mainMinor={
|
||||
balance?.main_balance === null || balance?.main_balance === undefined
|
||||
? null
|
||||
: Number(balance.main_balance)
|
||||
}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</WalletTransferCreditGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
|
||||
import { WalletTransferCreditGuard } from "@/features/wallet/wallet-transfer-credit-guard";
|
||||
import { WalletTransferLoadingPanel } from "@/features/wallet/wallet-transfer-loading-panel";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||
|
||||
const BALANCE_KEY = (currency: string) => ["wallet/balance", currency];
|
||||
|
||||
/** 独立路由 `/wallet/transfer-out` */
|
||||
export function TransferOutScreen() {
|
||||
const router = useRouter();
|
||||
const { activeCurrency: currency } = useActivePlayerCurrency();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const { data: balance, isLoading: loading } = useApiQuery(
|
||||
BALANCE_KEY(currency),
|
||||
() => getWalletBalance({ currency }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
||||
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||
}, [mutate, currency]);
|
||||
|
||||
const onSuccess = useCallback(async () => {
|
||||
await mutate(BALANCE_KEY(currency));
|
||||
router.push("/wallet");
|
||||
}, [mutate, currency, router]);
|
||||
|
||||
if (loading && !balance) {
|
||||
return <WalletTransferLoadingPanel titleKey="wallet.transferOutTitle" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WalletTransferCreditGuard balance={balance} loading={loading}>
|
||||
<TransferOutPage
|
||||
currency={currency}
|
||||
availableMinor={Number(balance?.available_balance ?? 0)}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</WalletTransferCreditGuard>
|
||||
);
|
||||
}
|
||||
@@ -241,24 +241,14 @@ export function LogRow({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between rounded-xl bg-[#f8fbff] px-3 py-2 text-xs">
|
||||
{creditMode && item.affects_available_credit === false ? (
|
||||
<span className="font-medium text-slate-500">
|
||||
{t("wallet.creditPaymentRecordOnly", {
|
||||
defaultValue: "账期收付记账,不计入可用信用",
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-slate-500">
|
||||
{creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")}
|
||||
</span>
|
||||
<span className="font-mono font-black tabular-nums text-[#32518d]">
|
||||
{item.balance_after != null
|
||||
? formatMinorAsCurrency(item.balance_after, ccy)
|
||||
: "—"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="font-semibold text-slate-500">
|
||||
{creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")}
|
||||
</span>
|
||||
<span className="font-mono font-black tabular-nums text-[#32518d]">
|
||||
{item.balance_after != null
|
||||
? formatMinorAsCurrency(item.balance_after, ccy)
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getWalletBalance, getWalletLogs } from "@/api/wallet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||
import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
|
||||
|
||||
const WALLET_LOGS_PAGE_SIZE = 10;
|
||||
|
||||
export function WalletLogsScreen() {
|
||||
const { activeCurrency: currency } = useActivePlayerCurrency();
|
||||
const { t } = useTranslation("player");
|
||||
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const [creditMode, setCreditMode] = useState(() => isCreditFundingPlayer(profile));
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const fetchPassRef = useRef(true);
|
||||
|
||||
const load = useCallback(async (targetPage = 1, append = false) => {
|
||||
setError(null);
|
||||
if (append) {
|
||||
setLoadingMore(true);
|
||||
} else if (fetchPassRef.current) {
|
||||
setLoading(true);
|
||||
fetchPassRef.current = false;
|
||||
} else {
|
||||
setLogsLoading(true);
|
||||
}
|
||||
try {
|
||||
const balance = await getWalletBalance({ currency });
|
||||
setCreditMode(isCreditFundingPlayer(balance));
|
||||
const nextLogs = await getWalletLogs({
|
||||
page: targetPage,
|
||||
size: WALLET_LOGS_PAGE_SIZE,
|
||||
type: filter || undefined,
|
||||
currency,
|
||||
});
|
||||
setLogs((current) =>
|
||||
append && current
|
||||
? { ...nextLogs, items: [...current.items, ...nextLogs.items] }
|
||||
: nextLogs,
|
||||
);
|
||||
dispatchWalletLogsRefresh(nextLogs.pending_reconcile ?? []);
|
||||
} catch (e) {
|
||||
setError(formatWalletClientError(e, t));
|
||||
if (!append) {
|
||||
setLogs(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLogsLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [currency, filter, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load(1, false);
|
||||
});
|
||||
}, [currency, load]);
|
||||
|
||||
useEffect(() => {
|
||||
const onCurrencyChange = () => void load(1, false);
|
||||
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||
}, [load]);
|
||||
|
||||
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!logs || !hasMore || loadingMore) return;
|
||||
void load(logs.page + 1, true);
|
||||
}, [hasMore, load, loadingMore, logs]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = loadMoreRef.current;
|
||||
if (!target || loading || logsLoading || loadingMore || !hasMore) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "160px" },
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadMore, loading, loadingMore, logsLoading]);
|
||||
|
||||
return (
|
||||
<PlayerPanel
|
||||
title={
|
||||
creditMode
|
||||
? t("wallet.creditLogsTitle", { defaultValue: "信用流水" })
|
||||
: t("wallet.logsTitle")
|
||||
}
|
||||
backHref="/wallet"
|
||||
backLabel={
|
||||
creditMode
|
||||
? t("wallet.creditTitle", { defaultValue: "信用" })
|
||||
: t("wallet.title")
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<WalletLogsBlock
|
||||
logs={logs}
|
||||
logsLoading={loading || logsLoading}
|
||||
loadingMore={loadingMore}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={loadMore}
|
||||
loadMoreRef={loadMoreRef}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
currency={currency}
|
||||
creditMode={creditMode}
|
||||
/>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
||||
|
||||
/** 钱包盘:顶栏铃铛隐藏后,在钱包页展示待对账提醒入口 */
|
||||
export function WalletPendingReconcileBanner() {
|
||||
const { t } = useTranslation("player");
|
||||
const { pending, unreadCount, hasPending } = usePendingWalletReconcile();
|
||||
|
||||
if (!hasPending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-950">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-amber-900">{t("wallet.pendingTitle")}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-amber-950/85">
|
||||
{t("wallet.pendingDescription")}
|
||||
</p>
|
||||
{unreadCount > 0 ? (
|
||||
<p className="mt-1.5 text-xs font-semibold text-amber-800">
|
||||
{t("notifications.unreadCount", { count: unreadCount })}
|
||||
</p>
|
||||
) : null}
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="mt-2 inline-flex text-xs font-bold text-[#0b56b7] underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("wallet.viewPendingReconcile", {
|
||||
defaultValue: "查看待对账详情({{count}})",
|
||||
count: pending.length,
|
||||
})}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
src/features/wallet/wallet-pending-reconcile-section.tsx
Normal file
153
src/features/wallet/wallet-pending-reconcile-section.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { BellRing, CheckCheck } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import {
|
||||
pendingReconcileDescriptionKey,
|
||||
pendingReconcileTitleKey,
|
||||
} from "@/lib/pending-reconcile-notification";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WalletPendingReconcileSectionProps = {
|
||||
onViewLogs?: () => void;
|
||||
};
|
||||
|
||||
/** 钱包页内嵌:待对账提醒(原独立 /notifications 页) */
|
||||
export function WalletPendingReconcileSection({
|
||||
onViewLogs,
|
||||
}: WalletPendingReconcileSectionProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
const { pending, unreadPending, unreadCount, loading, markAsRead, markAllAsRead } =
|
||||
usePendingWalletReconcile();
|
||||
const unreadSet = new Set(unreadPending.map((item) => item.transfer_no));
|
||||
|
||||
if (creditMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="wallet-pending" className="scroll-mt-3 space-y-3">
|
||||
<div className="flex items-center justify-between rounded-xl border border-[#dce7f7] bg-[#f8fbff] px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing className="size-4 text-[#0b56b7]" aria-hidden />
|
||||
<p className="text-sm font-semibold text-[#0b3f96]">
|
||||
{t("notifications.title")}
|
||||
{unreadCount > 0 ? (
|
||||
<span className="ml-1.5 text-xs font-bold text-amber-700">
|
||||
({t("notifications.unreadCount", { count: unreadCount })})
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-xs font-bold text-[#0b56b7] hover:bg-[#ebf2ff]"
|
||||
onClick={markAllAsRead}
|
||||
disabled={pending.length === 0 || unreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="mr-1 size-3.5" aria-hidden />
|
||||
{t("notifications.markAllRead")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && pending.length === 0 ? (
|
||||
<div className="rounded-xl border border-[#dce7f7] bg-white px-4 py-6 text-center text-sm text-slate-500">
|
||||
{t("actions.loading")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && pending.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-white px-4 py-8 text-center">
|
||||
<p className="text-sm text-slate-500">{t("notifications.empty")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{pending.map((item) => {
|
||||
const cardRead = !unreadSet.has(item.transfer_no);
|
||||
return (
|
||||
<li
|
||||
key={item.transfer_no}
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-3 transition-colors",
|
||||
cardRead
|
||||
? "border-[#e4eaf4] bg-white"
|
||||
: "border-amber-200 bg-amber-50/80",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold text-amber-900">
|
||||
{t(pendingReconcileTitleKey(item.type))}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{formatPlayerInstant(item.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-bold text-amber-800">
|
||||
{t("notifications.pendingBadge")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-semibold",
|
||||
cardRead ? "text-slate-400" : "text-amber-700",
|
||||
)}
|
||||
>
|
||||
{cardRead ? t("notifications.read") : t("notifications.unread")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs leading-relaxed text-amber-950/85">
|
||||
{t(pendingReconcileDescriptionKey(item.type))}
|
||||
</p>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-700">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
{t("notifications.amountLabel")}{" "}
|
||||
</span>
|
||||
{formatMinorAsCurrency(item.amount, item.currency_code)}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-[#dce7f7] px-3 text-xs font-semibold text-[#0b56b7]"
|
||||
onClick={() => markAsRead(item.transfer_no)}
|
||||
>
|
||||
{t("notifications.markRead")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 rounded-full bg-[#07459f] px-3 text-xs font-semibold text-white hover:bg-[#063b88]"
|
||||
onClick={() => {
|
||||
markAsRead(item.transfer_no);
|
||||
onViewLogs?.();
|
||||
}}
|
||||
>
|
||||
{t("notifications.viewLogs")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Wallet } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
TransferInDialog,
|
||||
TransferOutDialog,
|
||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||
import { WalletPendingReconcileBanner } from "@/features/wallet/wallet-pending-reconcile-banner";
|
||||
import { WalletPendingReconcileSection } from "@/features/wallet/wallet-pending-reconcile-section";
|
||||
import { PlayerMoneyDisplay } from "@/components/player-money-display";
|
||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||
import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile";
|
||||
@@ -28,9 +29,18 @@ import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-l
|
||||
|
||||
const WALLET_LOGS_PAGE_SIZE = 10;
|
||||
|
||||
function scrollToWalletSection(id: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
export function WalletScreen() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { activeCurrency: currency } = useActivePlayerCurrency();
|
||||
const { t } = useTranslation("player");
|
||||
const [transferInOpen, setTransferInOpen] = useState(false);
|
||||
const [transferOutOpen, setTransferOutOpen] = useState(false);
|
||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
@@ -39,8 +49,47 @@ export function WalletScreen() {
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const filterInitializedRef = useRef(false);
|
||||
const prevFilterRef = useRef("");
|
||||
const actionDeepLinkHandledRef = useRef(false);
|
||||
const sectionDeepLinkHandledRef = useRef(false);
|
||||
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const isCreditPlayer = isCreditFundingPlayer(balance) || isCreditFundingPlayer(profile);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionDeepLinkHandledRef.current || loading) return;
|
||||
const action = searchParams.get("action");
|
||||
if (action !== "transfer-in" && action !== "transfer-out") return;
|
||||
|
||||
actionDeepLinkHandledRef.current = true;
|
||||
if (!isCreditPlayer) {
|
||||
if (action === "transfer-in") {
|
||||
setTransferInOpen(true);
|
||||
} else {
|
||||
setTransferOutOpen(true);
|
||||
}
|
||||
}
|
||||
router.replace("/wallet", { scroll: false });
|
||||
}, [isCreditPlayer, loading, router, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionDeepLinkHandledRef.current || loading) return;
|
||||
const section = searchParams.get("section");
|
||||
if (section !== "logs" && section !== "pending") return;
|
||||
|
||||
sectionDeepLinkHandledRef.current = true;
|
||||
const targetId =
|
||||
section === "logs" || (section === "pending" && isCreditPlayer)
|
||||
? "wallet-logs"
|
||||
: "wallet-pending";
|
||||
const timer = window.setTimeout(() => {
|
||||
scrollToWalletSection(targetId);
|
||||
router.replace("/wallet", { scroll: false });
|
||||
}, 120);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isCreditPlayer, loading, router, searchParams]);
|
||||
|
||||
const loadLogs = useCallback(async (targetPage = 1, append = false) => {
|
||||
const nextLogs = await getWalletLogs({
|
||||
@@ -164,8 +213,6 @@ export function WalletScreen() {
|
||||
|
||||
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
|
||||
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const isCreditPlayer = isCreditFundingPlayer(balance) || isCreditFundingPlayer(profile);
|
||||
const displayMinor = isCreditPlayer
|
||||
? Number(balance?.available_balance ?? 0)
|
||||
: Number(balance?.balance ?? 0);
|
||||
@@ -261,17 +308,8 @@ export function WalletScreen() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isCreditPlayer ? (
|
||||
<p className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
|
||||
{t("wallet.creditNoTransferHint", {
|
||||
defaultValue:
|
||||
"由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<WalletPendingReconcileBanner />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{!isCreditPlayer ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TransferInDialog
|
||||
idPrefix="wallet-"
|
||||
currency={currency}
|
||||
@@ -285,6 +323,8 @@ export function WalletScreen() {
|
||||
triggerVariant="hall"
|
||||
triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })}
|
||||
triggerClassName="h-14 rounded-2xl text-base font-black"
|
||||
open={transferInOpen}
|
||||
onOpenChange={setTransferInOpen}
|
||||
/>
|
||||
<TransferOutDialog
|
||||
idPrefix="wallet-"
|
||||
@@ -294,11 +334,17 @@ export function WalletScreen() {
|
||||
triggerVariant="hall"
|
||||
triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })}
|
||||
triggerClassName="h-14 rounded-2xl text-base font-black"
|
||||
open={transferOutOpen}
|
||||
onOpenChange={setTransferOutOpen}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<WalletPendingReconcileSection
|
||||
onViewLogs={() => scrollToWalletSection("wallet-logs")}
|
||||
/>
|
||||
|
||||
<div id="wallet-logs" className="scroll-mt-3">
|
||||
<WalletLogsBlock
|
||||
creditMode={isCreditPlayer}
|
||||
logs={logs}
|
||||
@@ -311,6 +357,7 @@ export function WalletScreen() {
|
||||
onFilterChange={setFilter}
|
||||
currency={currency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
|
||||
@@ -21,7 +18,6 @@ export function WalletTransferCreditGuard({
|
||||
children,
|
||||
}: WalletTransferCreditGuardProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !balance) {
|
||||
@@ -37,20 +33,7 @@ export function WalletTransferCreditGuard({
|
||||
}
|
||||
|
||||
if (isCreditFundingPlayer(balance)) {
|
||||
return (
|
||||
<PlayerPanel
|
||||
title={t("wallet.creditTitle", { defaultValue: "信用" })}
|
||||
backHref="/wallet"
|
||||
backLabel={t("wallet.creditTitle", { defaultValue: "信用" })}
|
||||
>
|
||||
<p className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
|
||||
{t("wallet.creditNoTransferHint", {
|
||||
defaultValue:
|
||||
"由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
|
||||
})}
|
||||
</p>
|
||||
</PlayerPanel>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -23,6 +23,8 @@ type BaseProps = {
|
||||
onSuccess: () => Promise<void>;
|
||||
/** 避免同页多实例 input id 冲突 */
|
||||
idPrefix?: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function TransferInDialog({
|
||||
@@ -34,6 +36,8 @@ export function TransferInDialog({
|
||||
triggerClassName,
|
||||
triggerVariant = "wallet",
|
||||
triggerLabel,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: BaseProps & {
|
||||
lotteryMinor: number;
|
||||
mainMinor?: number | null;
|
||||
@@ -41,7 +45,19 @@ export function TransferInDialog({
|
||||
triggerVariant?: "wallet" | "hall";
|
||||
triggerLabel?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen;
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
if (isControlled) {
|
||||
controlledOnOpenChange?.(next);
|
||||
} else {
|
||||
setUncontrolledOpen(next);
|
||||
}
|
||||
},
|
||||
[controlledOnOpenChange, isControlled],
|
||||
);
|
||||
const { t } = useTranslation("player");
|
||||
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferIn");
|
||||
|
||||
@@ -94,13 +110,27 @@ export function TransferOutDialog({
|
||||
triggerClassName,
|
||||
triggerVariant = "wallet",
|
||||
triggerLabel,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: BaseProps & {
|
||||
availableMinor: number;
|
||||
triggerClassName?: string;
|
||||
triggerVariant?: "wallet" | "hall";
|
||||
triggerLabel?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen;
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
if (isControlled) {
|
||||
controlledOnOpenChange?.(next);
|
||||
} else {
|
||||
setUncontrolledOpen(next);
|
||||
}
|
||||
},
|
||||
[controlledOnOpenChange, isControlled],
|
||||
);
|
||||
const { t } = useTranslation("player");
|
||||
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferOut");
|
||||
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useIsMobile(breakpoint = 768): boolean {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
|
||||
const update = () => setIsMobile(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobile;
|
||||
/**
|
||||
* 玩家端 H5 内容列最大 480px,交互一律按紧凑布局处理。
|
||||
* 宽屏浏览器中虽 viewport 更宽,画布仍按手机宽度展示,因此恒为 `true`。
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -506,6 +506,8 @@
|
||||
"lineFailedTitle": "This line did not succeed",
|
||||
"status": "Status",
|
||||
"statusFilter": "Status filter",
|
||||
"showFilters": "Filters",
|
||||
"hideFilters": "Hide",
|
||||
"noMore": "No more tickets",
|
||||
"submitBet": "Submit Bet",
|
||||
"stake": "Stake",
|
||||
@@ -520,6 +522,7 @@
|
||||
"groupDetail": "Order detail",
|
||||
"groupNotFound": "Could not load this order. Please open it again from My Bets.",
|
||||
"betItems": "Bet lines",
|
||||
"sameOrderItems": "Other lines in this order",
|
||||
"itemCount": "{{count}} bet line(s)",
|
||||
"viewBetLine": "View bet line detail",
|
||||
"drawNo": "Issue",
|
||||
@@ -607,6 +610,7 @@
|
||||
"ticketNumber": "Ticket No. / Number",
|
||||
"placeholder": "Enter ticket number or number",
|
||||
"latestDraw": "Latest issue {{drawNo}}",
|
||||
"forDraw": "Check your bets and winnings for issue {{drawNo}}.",
|
||||
"loading": "Checking...",
|
||||
"submit": "Check now",
|
||||
"recent": "Recent checks",
|
||||
|
||||
@@ -505,6 +505,8 @@
|
||||
"lineFailedTitle": "यो लाइन सफल भएन",
|
||||
"status": "स्थिति",
|
||||
"statusFilter": "स्थिति फिल्टर",
|
||||
"showFilters": "फिल्टर",
|
||||
"hideFilters": "लुकाउनुहोस्",
|
||||
"noMore": "थप टिकट छैन",
|
||||
"submitBet": "बेट पेश गर्नुहोस्",
|
||||
"stake": "बेट",
|
||||
@@ -519,6 +521,7 @@
|
||||
"groupDetail": "अर्डर विवरण",
|
||||
"groupNotFound": "यो अर्डर लोड हुन सकेन। कृपया मेरा बेटबाट फेरि खोल्नुहोस्।",
|
||||
"betItems": "बेट लाइन विवरण",
|
||||
"sameOrderItems": "यही अर्डरका अन्य बेट लाइनहरू",
|
||||
"itemCount": "जम्मा {{count}} बेट लाइन",
|
||||
"viewBetLine": "बेट लाइन विवरण हेर्नुहोस्",
|
||||
"drawNo": "इश्यू",
|
||||
@@ -606,6 +609,7 @@
|
||||
"ticketNumber": "टिकट नं. / नम्बर",
|
||||
"placeholder": "टिकट नम्बर वा नम्बर लेख्नुहोस्",
|
||||
"latestDraw": "पछिल्लो इश्यू {{drawNo}}",
|
||||
"forDraw": "इश्यू {{drawNo}} का लागि तपाईंको बेट र जित जाँच गर्नुहोस्।",
|
||||
"loading": "जाँच हुँदैछ...",
|
||||
"submit": "अहिले जाँच गर्नुहोस्",
|
||||
"recent": "हालका जाँच",
|
||||
|
||||
@@ -505,6 +505,8 @@
|
||||
"lineFailedTitle": "本注项未成功",
|
||||
"status": "状态",
|
||||
"statusFilter": "状态筛选",
|
||||
"showFilters": "筛选",
|
||||
"hideFilters": "收起",
|
||||
"noMore": "没有更多注单",
|
||||
"submitBet": "提交下注",
|
||||
"stake": "下注",
|
||||
@@ -519,6 +521,7 @@
|
||||
"groupDetail": "订单详情",
|
||||
"groupNotFound": "无法加载该订单,请从注单列表重新进入。",
|
||||
"betItems": "注项明细",
|
||||
"sameOrderItems": "同订单其他注项",
|
||||
"itemCount": "共 {{count}} 条注项",
|
||||
"viewBetLine": "查看注项详情",
|
||||
"drawNo": "期号",
|
||||
@@ -606,6 +609,7 @@
|
||||
"ticketNumber": "票号 / 号码",
|
||||
"placeholder": "请输入票号或号码",
|
||||
"latestDraw": "最新期号 {{drawNo}}",
|
||||
"forDraw": "按本期 {{drawNo}} 查询你的注单和中奖情况。",
|
||||
"loading": "查询中...",
|
||||
"submit": "立即查询",
|
||||
"recent": "最近查询",
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/** 常见局域网段,供手机/其他设备通过 IP 访问 dev 时 HMR WebSocket 放行 */
|
||||
const DEFAULT_LAN_DEV_ORIGINS = ["192.168.*.*", "10.*.*.*"];
|
||||
|
||||
/** 解析 `ALLOWED_DEV_ORIGINS`(逗号分隔),供 next.config `allowedDevOrigins` 使用 */
|
||||
export function parseAllowedDevOrigins(envValue: string | undefined): string[] {
|
||||
if (envValue === undefined || envValue.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
const fromEnv =
|
||||
envValue === undefined || envValue.trim() === ""
|
||||
? []
|
||||
: envValue
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin !== "");
|
||||
|
||||
return envValue
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin !== "");
|
||||
return [...DEFAULT_LAN_DEV_ORIGINS, ...fromEnv];
|
||||
}
|
||||
|
||||
@@ -7,3 +7,6 @@ export const playerViewportColumnClass = "mx-auto w-full max-w-[480px]" as const
|
||||
/** 贴底/贴顶固定栏:与内容列同宽并居中(桌面不铺满整屏) */
|
||||
export const playerViewportFixedBarClass =
|
||||
"fixed left-1/2 z-50 w-full max-w-[480px] -translate-x-1/2" as const;
|
||||
|
||||
/** 刘海屏 / 全屏 WebView 顶部安全区 */
|
||||
export const playerSafeAreaTopClass = "pt-[env(safe-area-inset-top,0px)]" as const;
|
||||
|
||||
Reference in New Issue
Block a user