feat: 精简玩家端页面结构并支持局域网开发热更新
Some checks failed
lotteryfront CI / build (push) Has been cancelled

- 合并钱包、订单、开奖、通知等独立 screen 到页面层,减少重复壳组件
- 优化订单列表/详情、钱包划转与待对账展示、开奖核对与结果详情交互
- 改进入口 gate、移动端视口与下拉刷新在窄屏下的表现
- dev 绑定 0.0.0.0 并默认放行 192.168/10 网段,修复局域网 HMR WebSocket
This commit is contained in:
2026-06-26 14:10:47 +08:00
parent dd1155978a
commit e5622c58cb
51 changed files with 1184 additions and 1335 deletions

View File

@@ -6,7 +6,7 @@ import { parseAllowedDevOrigins } from "./src/lib/next-dev-origins";
const allowedDevOrigins = parseAllowedDevOrigins(process.env.ALLOWED_DEV_ORIGINS); const allowedDevOrigins = parseAllowedDevOrigins(process.env.ALLOWED_DEV_ORIGINS);
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
...(allowedDevOrigins.length > 0 ? { allowedDevOrigins } : {}), allowedDevOrigins,
reactCompiler: true, reactCompiler: true,
images: { images: {

View File

@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --port 3800", "dev": "next dev --port 3800 -H 0.0.0.0",
"build": "next build", "build": "next build",
"start": "next start --port 3800", "start": "next start --port 3800",
"lint": "eslint" "lint": "eslint"

View File

@@ -9,6 +9,7 @@ export type GetTicketItemsParams = {
page?: number; page?: number;
per_page?: number; per_page?: number;
draw_no?: string; draw_no?: string;
order_no?: string;
number?: string; number?: string;
status?: string[]; status?: string[];
start_date?: string; start_date?: string;
@@ -26,6 +27,7 @@ export function getTicketItems(
page: params?.page, page: params?.page,
per_page: params?.per_page, per_page: params?.per_page,
draw_no: params?.draw_no, draw_no: params?.draw_no,
order_no: params?.order_no,
number: params?.number, number: params?.number,
status: params?.status, status: params?.status,
start_date: params?.start_date, start_date: params?.start_date,

View File

@@ -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");
} }

View File

@@ -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 function OrderGroupRedirectPage() {
}; redirect("/orders");
export default async function OrderGroupPage({ params }: PageProps) {
const { groupKey } = await params;
return <TicketOrderGroupScreen groupKey={groupKey} />;
} }

View File

@@ -1,16 +1,25 @@
import type { ReactNode } from "react";
import { Suspense } from "react";
import { DrawResultDetailScreen } from "@/features/results/draw-result-detail-screen"; import { DrawResultDetailScreen } from "@/features/results/draw-result-detail-screen";
type PageProps = { type PageProps = {
params: Promise<{ drawNo: string }>; params: Promise<{ drawNo: string }>;
}; };
function DetailFallback(): ReactNode {
return <div className="min-h-40" aria-hidden />;
}
export default async function DrawResultByNoPage(props: PageProps) { export default async function DrawResultByNoPage(props: PageProps) {
const { drawNo: raw } = await props.params; const { drawNo: raw } = await props.params;
const drawNo = decodeURIComponent(raw); const drawNo = decodeURIComponent(raw);
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Suspense fallback={<DetailFallback />}>
<DrawResultDetailScreen drawNo={drawNo} /> <DrawResultDetailScreen drawNo={drawNo} />
</Suspense>
</div> </div>
); );
} }

View File

@@ -1,5 +1,5 @@
import { CheckWinningScreen } from "@/features/results/check-winning-screen"; import { CheckWinningRedirect } from "@/features/results/check-winning-redirect";
export default function CheckWinningPage() { export default function CheckWinningPage() {
return <CheckWinningScreen />; return <CheckWinningRedirect />;
} }

View File

@@ -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");
} }

View File

@@ -1,6 +1,17 @@
import type { ReactNode } from "react";
import { Suspense } from "react";
import { WalletScreen } from "@/features/wallet/wallet-screen"; import { WalletScreen } from "@/features/wallet/wallet-screen";
/** 界面文档 §4.9 彩票钱包:余额、转入/转出、流水 */ function WalletFallback(): ReactNode {
export default function WalletPage() { return <div className="min-h-[40vh] bg-white" aria-hidden />;
return <WalletScreen />; }
/** 界面文档 §4.9 彩票钱包:余额、转入/转出、待对账、流水 */
export default function WalletPage() {
return (
<Suspense fallback={<WalletFallback />}>
<WalletScreen />
</Suspense>
);
} }

View File

@@ -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");
} }

View File

@@ -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");
} }

View File

@@ -1,16 +1,26 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Suspense } from "react"; import { Suspense } from "react";
import { Loader2 } from "lucide-react";
import { PlayerLoginScreen } from "@/features/player/player-login-screen"; import { PlayerLoginScreen } from "@/features/player/player-login-screen";
function LoginFallback(): ReactNode { 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() { export default function PlayerLoginPage() {
return ( return (
<div className="flex min-h-0 flex-1 flex-col">
<Suspense fallback={<LoginFallback />}> <Suspense fallback={<LoginFallback />}>
<PlayerLoginScreen /> <PlayerLoginScreen />
</Suspense> </Suspense>
</div>
); );
} }

View File

@@ -1,16 +1,26 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Suspense } from "react"; import { Suspense } from "react";
import { Loader2 } from "lucide-react";
import { EntryGate } from "@/features/player/entry-gate"; import { EntryGate } from "@/features/player/entry-gate";
function EntryFallback(): ReactNode { 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() { export default function EntryPage() {
return ( return (
<div className="flex min-h-0 flex-1 flex-col">
<Suspense fallback={<EntryFallback />}> <Suspense fallback={<EntryFallback />}>
<EntryGate /> <EntryGate />
</Suspense> </Suspense>
</div>
); );
} }

View File

@@ -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 */ /* 玩家端 Toast顶部居中、紧凑尺寸位置见 components/ui/sonner.tsx */
[data-sonner-toaster] { [data-sonner-toaster] {
--width: min(280px, calc(100vw - 24px)); --width: min(280px, calc(100vw - 24px));

View File

@@ -30,6 +30,7 @@ export const viewport = {
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 1,
userScalable: false, userScalable: false,
viewportFit: "cover",
}; };
export default function RootLayout({ export default function RootLayout({

View File

@@ -5,7 +5,6 @@ import { FileQuestion, Home } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PlayerMobileViewport } from "@/components/layout/player-mobile-viewport"; import { PlayerMobileViewport } from "@/components/layout/player-mobile-viewport";
import { playerViewportColumnClass } from "@/lib/player-viewport";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import "@/i18n"; import "@/i18n";
@@ -16,8 +15,7 @@ export default function NotFoundPage(): React.ReactElement {
<PlayerMobileViewport> <PlayerMobileViewport>
<div <div
className={cn( className={cn(
"flex min-h-dvh flex-col items-center justify-center bg-white px-4 py-8 text-slate-900", "flex min-h-0 flex-1 flex-col items-center justify-center bg-white px-4 py-8 text-slate-900",
playerViewportColumnClass,
)} )}
> >
<div className="w-full rounded-2xl border border-[#e4eaf4] bg-white p-8 shadow-[0_10px_28px_rgba(15,23,42,0.08)]"> <div className="w-full rounded-2xl border border-[#e4eaf4] bg-white p-8 shadow-[0_10px_28px_rgba(15,23,42,0.08)]">

View File

@@ -3,7 +3,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useNetworkStatus } from "@/hooks/use-network-status"; import { useNetworkStatus } from "@/hooks/use-network-status";
import { OfflineBanner } from "@/components/offline-banner";
import { ServerError } from "@/components/server-error"; import { ServerError } from "@/components/server-error";
/** /**
@@ -17,13 +16,7 @@ export function ErrorProvider({ children }: { children: ReactNode }): ReactNode
return ( return (
<> <>
{/* 全局离线状态横幅 - 显示在页面顶部 */}
<OfflineBanner />
{/* 服务器错误全屏覆盖 */}
<ServerError /> <ServerError />
{/* 子内容 */}
{children} {children}
</> </>
); );

View File

@@ -28,14 +28,17 @@ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
}); });
return ( 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 <PullToRefreshIndicator
pullDistance={pullDistance} pullDistance={pullDistance}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}
threshold={70} threshold={70}
/> />
<NetworkStatusBanner /> <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} {children}
</main> </main>
<div className="shrink-0 w-full relative z-50"> <div className="shrink-0 w-full relative z-50">

View File

@@ -1,6 +1,10 @@
import type { ReactNode } from "react"; 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"; import { cn } from "@/lib/utils";
type PlayerMobileViewportProps = { type PlayerMobileViewportProps = {
@@ -20,10 +24,12 @@ export function PlayerMobileViewport({
<div <div
className={cn( className={cn(
playerViewportColumnClass, 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, className,
)} )}
> >
<OfflineBanner />
{children} {children}
</div> </div>
</div> </div>

View File

@@ -26,7 +26,10 @@ export function PullToRefreshIndicator({
return ( return (
<div <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" 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 <div
className={cn( className={cn(

View File

@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -297,15 +297,6 @@ export function HallBetPreviewDialog({
</div> </div>
) : null} ) : null}
{periodRebate ? (
<p className="text-xs leading-relaxed text-slate-500">
{t("hall.preview.periodRebateHint", {
defaultValue:
"信用盘回水在账期结算入账,下注时按全额占用可用信用,此处为账期回水预估。",
})}
</p>
) : null}
{data.warnings.length > 0 ? ( {data.warnings.length > 0 ? (
<WarningsBlock warnings={data.warnings} /> <WarningsBlock warnings={data.warnings} />
) : ( ) : (

View File

@@ -588,13 +588,18 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
return allPlayColumns.filter((col) => allowedCodes.has(col.play.play_code)); return allPlayColumns.filter((col) => allowedCodes.has(col.play.play_code));
}, [activeCategory, allPlayColumns, d4PlayGroup]); }, [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 tableMinWidthPx = useMemo(() => {
const indexCol = 40; const indexCol = 40;
const numberCol = activeCategory === "D4" ? 112 : activeCategory === "D3" ? 88 : 72; const numberCol = activeCategory === "D4" ? 112 : activeCategory === "D3" ? 88 : 72;
const amountCol = 72; const amountCol = compactTable ? 60 : 72;
const deleteCol = 36; const deleteCol = 36;
return indexCol + numberCol + playColumns.length * amountCol + deleteCol; return indexCol + numberCol + playColumns.length * amountCol + deleteCol;
}, [activeCategory, playColumns.length]); }, [activeCategory, compactTable, playColumns.length]);
const showWideTableHint = activeCategory === "D4" && allPlayColumns.length > 8 && playColumns.length > 8; const showWideTableHint = activeCategory === "D4" && allPlayColumns.length > 8 && playColumns.length > 8;
@@ -1382,9 +1387,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
className={cn( className={cn(
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-opacity", "overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-opacity",
tableDisabled && "opacity-55", 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 <table
className="w-full border-collapse text-[11px]" className="w-full border-collapse text-[11px]"
style={{ minWidth: tableMinWidthPx }} style={{ minWidth: tableMinWidthPx }}
@@ -1412,7 +1418,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
{playColumns.map((column) => ( {playColumns.map((column) => (
<th <th
key={column.key} 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"> <span className="block whitespace-nowrap text-[11px] leading-tight">
{playColumnHeaderLabel( {playColumnHeaderLabel(
@@ -1512,7 +1518,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
onClick={() => setActiveRowId(row.id)} onClick={() => setActiveRowId(row.id)}
onChange={(event) => updateAmount(row.id, column.key, event.target.value)} onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
className={cn( 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]", hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800", status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400", status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",

View File

@@ -15,8 +15,6 @@ export type TicketItemGroup = {
jackpot_win_amount: number; jackpot_win_amount: number;
}; };
export const ORDER_GROUP_STORAGE_PREFIX = "lottery:order-group:v1:";
/** 分组键:有 order_no 则按订单;否则 draw_no + placed_at + currency + status 兜底 */ /** 分组键:有 order_no 则按订单;否则 draw_no + placed_at + currency + status 兜底 */
export function getTicketItemGroupKey(row: TicketItemListRow): string { export function getTicketItemGroupKey(row: TicketItemListRow): string {
const orderNo = (row.order_no ?? "").trim(); const orderNo = (row.order_no ?? "").trim();
@@ -135,56 +133,6 @@ export function groupTicketItems(items: TicketItemListRow[]): TicketItemGroup[]
}); });
} }
export function persistOrderGroup(group: TicketItemGroup): void { export function ticketDetailHref(ticketNo: string): string {
try { return `/orders/${encodeURIComponent(ticketNo)}`;
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()}`;
} }

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next"; 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 { Button } from "@/components/ui/button";
import { PlayerPanel } from "@/components/layout/player-panel"; import { PlayerPanel } from "@/components/layout/player-panel";
import { import {
@@ -18,7 +18,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid"; import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { 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 { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
import { formatPlayerInstant } from "@/lib/player-datetime"; import { formatPlayerInstant } from "@/lib/player-datetime";
import { formatMinorAsCurrency } from "@/lib/money"; 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 { usePlayerSessionStore } from "@/stores/player-session-store";
import { playLabel } from "@/lib/play-labels"; import { playLabel } from "@/lib/play-labels";
import { cn } from "@/lib/utils"; 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 }; type OddsSnapRow = { prize_scope?: string; odds_value?: number };
@@ -78,35 +78,16 @@ type TicketItemDetailWithExtras = TicketItemDetailPayload & {
/** 界面文档 §4.8 注单详情 */ /** 界面文档 §4.8 注单详情 */
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
const searchParams = useSearchParams();
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const { activeCurrency } = useActivePlayerCurrency(); const { activeCurrency } = useActivePlayerCurrency();
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile)); const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
useCurrencyCatalog(); useCurrencyCatalog();
const [data, setData] = useState<TicketItemDetailPayload | null>(null); const [data, setData] = useState<TicketItemDetailPayload | null>(null);
const [siblingItems, setSiblingItems] = useState<TicketItemListRow[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const backHref = "/orders";
const fromGroupKey = useMemo( const backLabel = t("orders.title");
() => (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 load = useCallback(async (options?: { silent?: boolean }) => { const load = useCallback(async (options?: { silent?: boolean }) => {
if (!options?.silent) { if (!options?.silent) {
@@ -134,6 +115,33 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
}); });
}, [load]); }, [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(() => { useEffect(() => {
if (!data || !TRANSIENT_TICKET_STATUSES.has(data.status)) { if (!data || !TRANSIENT_TICKET_STATUSES.has(data.status)) {
return; return;
@@ -153,8 +161,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
return ( return (
<PlayerPanel <PlayerPanel
title={t("orders.betDetail")} title={t("orders.betDetail")}
backHref={backNav.href} backHref={backHref}
backLabel={backNav.label} backLabel={backLabel}
> >
<div className="space-y-3"> <div className="space-y-3">
<Skeleton className="h-12 rounded-xl" /> <Skeleton className="h-12 rounded-xl" />
@@ -168,8 +176,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
return ( return (
<PlayerPanel <PlayerPanel
title={t("orders.betDetail")} title={t("orders.betDetail")}
backHref={backNav.href} backHref={backHref}
backLabel={backNav.label} backLabel={backLabel}
> >
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700"> <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> <p>{error ?? t("orders.noData")}</p>
@@ -235,10 +243,55 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
return ( return (
<PlayerPanel <PlayerPanel
title={t("orders.betDetail")} title={t("orders.betDetail")}
backHref={backNav.href} backHref={backHref}
backLabel={backNav.label} backLabel={backLabel}
> >
<div className="flex flex-col gap-3"> <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)]"> <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"> <CardHeader className="space-y-2 border-b border-[#edf2f9] pb-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
@@ -445,12 +498,12 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
</Link> </Link>
) : null} ) : null}
<Link <Link
href={backNav.href} href={backHref}
className={cn( 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]", "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> </Link>
</div> </div>
</div> </div>

View File

@@ -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>
);
}

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import Link from "next/link"; 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 { 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 { useTranslation } from "react-i18next";
import { getDrawCurrent } from "@/api/draw"; import { getDrawCurrent } from "@/api/draw";
@@ -14,15 +14,11 @@ import { Input } from "@/components/ui/input";
import { PlayerPanel } from "@/components/layout/player-panel"; import { PlayerPanel } from "@/components/layout/player-panel";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import { groupTicketItems, ticketDetailHref } from "@/features/orders/group-ticket-items";
groupTicketItems,
orderGroupHref,
persistOrderGroup,
} from "@/features/orders/group-ticket-items";
import { OrderMetaLine } from "@/features/orders/order-meta-line"; import { OrderMetaLine } from "@/features/orders/order-meta-line";
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status"; import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { useIsMobile } from "@/hooks/use-mobile";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import { formatMinorAsCurrency } from "@/lib/money"; import { formatMinorAsCurrency } from "@/lib/money";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
@@ -52,16 +48,19 @@ const STATUS_OPTIONS = [
] as const; ] as const;
export function TicketOrdersListScreen() { export function TicketOrdersListScreen() {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const { activeCurrency } = useActivePlayerCurrency(); const { activeCurrency } = useActivePlayerCurrency();
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile)); const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
useCurrencyCatalog(); useCurrencyCatalog();
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]); const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
const numberFilter = useMemo(() => (searchParams.get("number") ?? "").trim(), [searchParams]);
const statusFilter = useMemo( const statusFilter = useMemo(
() => searchParams.getAll("status").map((s) => s.trim()).filter(Boolean), () => searchParams.getAll("status").map((s) => s.trim()).filter(Boolean),
[searchParams], [searchParams],
); );
const hasUrlFilters = drawNoFilter !== "" || numberFilter !== "" || statusFilter.length > 0;
const [items, setItems] = useState<TicketItemListRow[]>([]); const [items, setItems] = useState<TicketItemListRow[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -74,7 +73,11 @@ export function TicketOrdersListScreen() {
base: drawNoFilter, base: drawNoFilter,
draft: 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 [queryStatuses, setQueryStatuses] = useState<string[]>(statusFilter);
const [fromDate, setFromDate] = useState(""); const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState(""); const [toDate, setToDate] = useState("");
@@ -83,7 +86,6 @@ export function TicketOrdersListScreen() {
const [calendarMonth, setCalendarMonth] = useState(() => new Date()); const [calendarMonth, setCalendarMonth] = useState(() => new Date());
const [scheduleTimezone, setScheduleTimezone] = useState(LOTTERY_SCHEDULE_TIMEZONE); const [scheduleTimezone, setScheduleTimezone] = useState(LOTTERY_SCHEDULE_TIMEZONE);
const loadMoreRef = useRef<HTMLDivElement | null>(null); const loadMoreRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobile();
const initialLoadDone = useRef(false); const initialLoadDone = useRef(false);
const queryDrawNoInput = const queryDrawNoInput =
@@ -110,7 +112,6 @@ export function TicketOrdersListScreen() {
if (fromDate && toDate) return `${formatCompactDate(fromDate)} ~ ${formatCompactDate(toDate)}`; if (fromDate && toDate) return `${formatCompactDate(fromDate)} ~ ${formatCompactDate(toDate)}`;
return `${fromDate ? formatCompactDate(fromDate) : "..." } ~ ${toDate ? formatCompactDate(toDate) : "..."}`; return `${fromDate ? formatCompactDate(fromDate) : "..." } ~ ${toDate ? formatCompactDate(toDate) : "..."}`;
}, [formatCompactDate, fromDate, t, toDate]); }, [formatCompactDate, fromDate, t, toDate]);
const visiblePages = useMemo(() => buildPageWindow(page, lastPage), [lastPage, page]);
const orderGroups = useMemo(() => groupTicketItems(items), [items]); const orderGroups = useMemo(() => groupTicketItems(items), [items]);
const fetchPage = useCallback( const fetchPage = useCallback(
@@ -214,6 +215,24 @@ export function TicketOrdersListScreen() {
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <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 <Link
href="/hall" 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)]" 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" variant="outline"
className="h-9 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]" className="h-9 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
onClick={() => { onClick={() => {
setQueryDrawNoState({ base: drawNoFilter, draft: drawNoFilter }); setQueryDrawNoState({ base: "", draft: "" });
setQueryNumber(""); setQueryNumberDraft("");
setFromDate(""); setFromDate("");
setToDate(""); setToDate("");
setQueryStatuses(statusFilter); setQueryStatuses([]);
setFiltersOpen(false);
setFiltersCollapsed(false);
setRangeOpen(false); setRangeOpen(false);
setStatusOpen(false); setStatusOpen(false);
if (hasUrlFilters) {
router.replace("/orders");
}
}} }}
> >
{t("actions.clear")} {t("actions.clear")}
@@ -241,6 +265,7 @@ export function TicketOrdersListScreen() {
</div> </div>
</div> </div>
{filtersExpanded ? (
<div className="mt-3 grid grid-cols-2 gap-2"> <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"> <div className="flex h-9 min-w-0 items-center rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
<Input <Input
@@ -250,7 +275,7 @@ export function TicketOrdersListScreen() {
} }
placeholder={t("orders.drawNo")} placeholder={t("orders.drawNo")}
aria-label={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> </div>
@@ -258,10 +283,10 @@ export function TicketOrdersListScreen() {
<Search className="size-3.5 shrink-0 text-slate-400" /> <Search className="size-3.5 shrink-0 text-slate-400" />
<Input <Input
value={queryNumber} value={queryNumber}
onChange={(e) => setQueryNumber(e.target.value)} onChange={(e) => setQueryNumberDraft(e.target.value)}
placeholder={t("orders.number")} placeholder={t("orders.number")}
aria-label={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> </div>
@@ -286,7 +311,7 @@ export function TicketOrdersListScreen() {
mode="range" mode="range"
month={calendarMonth} month={calendarMonth}
onMonthChange={setCalendarMonth} onMonthChange={setCalendarMonth}
numberOfMonths={isMobile ? 1 : 2} numberOfMonths={1}
selected={selectedRange} selected={selectedRange}
onSelect={(range) => { onSelect={(range) => {
if (!range?.from && !range?.to) { if (!range?.from && !range?.to) {
@@ -388,6 +413,7 @@ export function TicketOrdersListScreen() {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
) : null}
</div> </div>
{loading ? ( {loading ? (
@@ -432,12 +458,11 @@ export function TicketOrdersListScreen() {
); );
const totalWin = group.win_amount + group.jackpot_win_amount; const totalWin = group.win_amount + group.jackpot_win_amount;
return ( return (
<Link <div
key={group.key} key={group.key}
href={orderGroupHref(group)} className="rounded-xl border border-[#e5edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)]"
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]"
> >
<div className="p-3 pb-2">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<p className="min-w-0 truncate font-mono text-sm font-black text-[#0b3f96]"> <p className="min-w-0 truncate font-mono text-sm font-black text-[#0b3f96]">
{group.draw_no ?? "—"} {group.draw_no ?? "—"}
@@ -449,16 +474,6 @@ export function TicketOrdersListScreen() {
placedAt={group.placed_at} placedAt={group.placed_at}
t={t} 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="mt-3 grid grid-cols-2 gap-2">
<div className="rounded-lg bg-[#f8fbff] px-3 py-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="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
@@ -483,12 +498,60 @@ export function TicketOrdersListScreen() {
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })} {t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
</p> </p>
) : null} ) : null}
</div>
<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>
{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> </Link>
); );
})} })}
</div> </div>
{isMobile ? <div ref={loadMoreRef} className="min-h-1" /> : null} </div>
{isMobile && page < lastPage ? ( );
})}
</div>
<div ref={loadMoreRef} className="min-h-1" />
{page < lastPage ? (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -498,47 +561,6 @@ export function TicketOrdersListScreen() {
> >
{loadingMore ? t("actions.loading") : t("actions.loadMore")} {loadingMore ? t("actions.loading") : t("actions.loadMore")}
</Button> </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>
) : lastPage > 1 ? ( ) : lastPage > 1 ? (
<p className="py-2 text-center text-xs text-slate-400"> <p className="py-2 text-center text-xs text-slate-400">
{t("orders.noMore")} {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);
}

View File

@@ -116,6 +116,14 @@ export function EntryGate() {
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } = const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
usePlayerSessionStore(); usePlayerSessionStore();
/** 仅主站 iframe 内复用已有 token直连站点只认 URL `?token=`SSO */
const isReturningSession =
typeof window !== "undefined" &&
isInIframe() &&
!sessionExpired &&
!tokenFromUrl &&
(bearerToken ?? "").trim() !== "";
const [resumeSilent, setResumeSilent] = useState(isReturningSession);
const waitingForEmbeddedToken = const waitingForEmbeddedToken =
!sessionExpired && !sessionExpired &&
typeof window !== "undefined" && typeof window !== "undefined" &&
@@ -128,13 +136,11 @@ export function EntryGate() {
if (!isInIframe()) { if (!isInIframe()) {
if (sessionExpired) return false; if (sessionExpired) return false;
return tokenFromUrl !== "";
const hasToken = tokenFromUrl !== "" || (bearerToken ?? "").trim() !== "";
if (!hasToken) return false;
} }
return true; return true;
}, [bearerToken, sessionExpired, tokenFromUrl]); }, [sessionExpired, tokenFromUrl]);
useEffect(() => { useEffect(() => {
if (gateReady) return; if (gateReady) return;
@@ -143,13 +149,10 @@ export function EntryGate() {
if (sessionExpired) { if (sessionExpired) {
router.replace("/login?session=expired"); router.replace("/login?session=expired");
} else { } else if (!tokenFromUrl) {
const hasToken = tokenFromUrl !== "" || (bearerToken ?? "").trim() !== "";
if (!hasToken) {
router.replace("/login"); router.replace("/login");
} }
} }, [gateReady, router, sessionExpired, tokenFromUrl]);
}, [gateReady, router, sessionExpired, tokenFromUrl, bearerToken]);
const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading"); const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading");
const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() => const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() =>
@@ -164,7 +167,15 @@ export function EntryGate() {
); );
const [steps, setSteps] = useState<EntryStep[]>(initialSteps()); 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 剥离后重复触发进场,避免成功/失败页闪一下 */ /** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下 */
const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle"); const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle");
@@ -183,7 +194,7 @@ export function EntryGate() {
return; return;
} }
if (!effectiveToken) { if (!entryToken) {
// 主站 iframetoken 由 MAIN_INIT_TOKEN 稍后到达,勿先闪「授权失败」 // 主站 iframetoken 由 MAIN_INIT_TOKEN 稍后到达,勿先闪「授权失败」
if (typeof window !== "undefined" && isInIframe() && !tokenFromUrl) { if (typeof window !== "undefined" && isInIframe() && !tokenFromUrl) {
return; return;
@@ -199,6 +210,7 @@ export function EntryGate() {
} }
entryLifecycleRef.current = "running"; entryLifecycleRef.current = "running";
setResumeSilent(false);
setPhase("loading"); setPhase("loading");
setFailureDetails([]); setFailureDetails([]);
@@ -298,7 +310,7 @@ export function EntryGate() {
}, },
]); ]);
}, [ }, [
effectiveToken, entryToken,
tokenFromUrl, tokenFromUrl,
setBearerToken, setBearerToken,
setProfile, setProfile,
@@ -323,26 +335,75 @@ export function EntryGate() {
stripSearchParamFromBrowserUrl("session"); stripSearchParamFromBrowserUrl("session");
}, [sessionExpired, clearBearerToken]); }, [sessionExpired, clearBearerToken]);
const trimmedEffectiveToken = (effectiveToken ?? "").trim(); const trimmedEntryToken = (entryToken ?? "").trim();
const doEntryRef = useRef(doEntry); const doEntryRef = useRef(doEntry);
useEffect(() => { useEffect(() => {
doEntryRef.current = doEntry; doEntryRef.current = doEntry;
}, [doEntry]); }, [doEntry]);
useEffect(() => { useEffect(() => {
if (sessionExpired || waitingForEmbeddedToken) return; if (sessionExpired || waitingForEmbeddedToken || isReturningSession) return;
if (entryLifecycleRef.current !== "idle") return; if (entryLifecycleRef.current !== "idle") return;
if (!trimmedEffectiveToken) return; if (!trimmedEntryToken) return;
const tmr = window.setTimeout(() => { const tmr = window.setTimeout(() => {
void doEntryRef.current(); void doEntryRef.current();
}, 300); }, 300);
return () => window.clearTimeout(tmr); 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(() => { useEffect(() => {
if (sessionExpired) return; if (sessionExpired) return;
if (tokenFromUrl || effectiveToken) return; if (tokenFromUrl || bearerToken) return;
if (typeof window === "undefined" || !isInIframe()) return; if (typeof window === "undefined" || !isInIframe()) return;
const tmr = window.setTimeout(() => { const tmr = window.setTimeout(() => {
@@ -354,14 +415,22 @@ export function EntryGate() {
}, IFRAME_TOKEN_WAIT_MS); }, IFRAME_TOKEN_WAIT_MS);
return () => window.clearTimeout(tmr); return () => window.clearTimeout(tmr);
}, [sessionExpired, effectiveToken, tokenFromUrl]); }, [bearerToken, sessionExpired, tokenFromUrl]);
if (!gateReady) { 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 ( 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={cn("relative h-[45vh] min-h-[200px]", phase === "success" ? "bg-white" : "bg-red-600")}>
<div className="pointer-events-none absolute inset-0 overflow-hidden"> <div className="pointer-events-none absolute inset-0 overflow-hidden">
<Image <Image
@@ -472,7 +541,8 @@ export function EntryGate() {
{t("failure.detailsTitle")} {t("failure.detailsTitle")}
</span> </span>
</div> </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"> <thead className="bg-red-100/50 text-xs">
<tr> <tr>
<th className="px-3 py-2 text-left font-medium text-red-700"> <th className="px-3 py-2 text-left font-medium text-red-700">
@@ -503,6 +573,7 @@ export function EntryGate() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
) : null} ) : null}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -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 }) { function EntryStatusBadge({ status }: { status: EntryStepStatus }) {
const { t } = useTranslation("common"); const { t } = useTranslation("common");

View File

@@ -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>
);
}

View File

@@ -150,7 +150,7 @@ export function PlayerLoginScreen(): React.ReactElement {
} }
return ( 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="relative h-[45vh] min-h-[200px] shrink-0 overflow-hidden bg-[#f8fafc]">
<div className="pointer-events-none absolute inset-0"> <div className="pointer-events-none absolute inset-0">
<Image src="/entry/image1.png" alt="" fill sizes="100vw" className="object-cover object-center" priority /> <Image src="/entry/image1.png" alt="" fill sizes="100vw" className="object-cover object-center" priority />

View 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;
}

View File

@@ -1,310 +1,31 @@
"use client"; "use client";
import Link from "next/link"; import { useEffect, useState } from "react";
import { BriefcaseBusiness, CheckCircle2, Clock3, RefreshCw, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getDrawResults } from "@/api/draw"; 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 { PlayerPanel } from "@/components/layout/player-panel";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { Skeleton } from "@/components/ui/skeleton";
import { formatMinorAsCurrency } from "@/lib/money"; import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-panel";
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[];
};
/** 保留组件供内嵌使用;独立路由已 redirect 至期号详情 */
export function CheckWinningScreen() { export function CheckWinningScreen() {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
useCurrencyCatalog(); const [drawNo, setDrawNo] = useState<string | null>(null);
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);
useEffect(() => { useEffect(() => {
queueMicrotask(() => { void getDrawResults({ page: 1, size: 1 })
void (async () => { .then((res) => setDrawNo(res.items[0]?.draw_no ?? null))
try { .catch(() => setDrawNo(null));
const res = await getDrawResults({ page: 1, size: 1 });
setLatestDraw(res.items[0] ?? null);
} catch {
setLatestDraw(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 ( return (
<PlayerPanel title={t("results.check.title")} backHref="/results" backLabel={t("results.title")}> <PlayerPanel title={t("results.check.title")} backHref="/results" backLabel={t("results.title")}>
<div className="space-y-3"> {drawNo ? (
<section className="overflow-hidden rounded-2xl border border-red-100 bg-white shadow-[0_12px_32px_rgba(15,23,42,0.06)]"> <DrawWinningCheckPanel drawNo={drawNo} collapsible={false} />
<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) => ( <Skeleton className="h-56 rounded-2xl" />
<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("");
}}
/>
</PlayerPanel> </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>
);
}

View File

@@ -1,11 +1,12 @@
"use client"; "use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getDrawResultByNo } from "@/api/draw"; import { getDrawResultByNo } from "@/api/draw";
import { getTicketDrawMyMatch } from "@/api/ticket-items"; import { getTicketDrawMyMatch } from "@/api/ticket-items";
import Link from "next/link";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { PlayerPanel } from "@/components/layout/player-panel"; import { PlayerPanel } from "@/components/layout/player-panel";
import { import {
@@ -16,6 +17,7 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-panel";
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip"; import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid"; import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
@@ -37,6 +39,8 @@ type DrawResultDetailScreenProps = {
/** §4.6 开奖结果详情23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */ /** §4.6 开奖结果详情23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) { export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const searchParams = useSearchParams();
const checkOpen = searchParams.get("check") === "1";
const { activeCurrency } = useActivePlayerCurrency(); const { activeCurrency } = useActivePlayerCurrency();
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile)); const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
useCurrencyCatalog(); useCurrencyCatalog();
@@ -268,20 +272,14 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
</div> </div>
) : null} ) : null}
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] px-3 py-3"> <DrawWinningCheckPanel
<p className="text-xs leading-relaxed text-slate-500"> key={checkOpen ? `${data.draw_no}:check` : data.draw_no}
{t("results.hitHint")} drawNo={data.draw_no}
</p> businessDate={data.business_date}
<Link drawTimeIso={data.draw_time_iso}
href="/results/check" drawTime={data.draw_time}
className={cn( defaultOpen={checkOpen}
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>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -19,7 +19,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { PlayerPanel } from "@/components/layout/player-panel"; import { PlayerPanel } from "@/components/layout/player-panel";
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip"; import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { formatPlayerInstant } from "@/lib/player-datetime"; import { formatPlayerInstant } from "@/lib/player-datetime";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
@@ -259,14 +259,17 @@ export function DrawResultsListScreen() {
{t("results.openDetail", { defaultValue: "查看详情" })} {t("results.openDetail", { defaultValue: "查看详情" })}
</Link> </Link>
</div> </div>
<div className="pt-4"> <div className="mt-3 grid grid-cols-3 gap-2 text-center">
<TwentyThreeResultsGrid numbers={featured.results} /> {RESULTS_TOP_PRIZE_KEYS.map((tier) => (
<Link <div key={tier} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
href="/results/check" <p className="text-[11px] font-bold text-[#7890b8]">
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(resultsPrizeLabelKey(tier))}
> </p>
{t("results.viewMyWinning")} <p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
</Link> {featured.results[tier]}
</p>
</div>
))}
</div> </div>
</div> </div>
) : null} ) : null}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -241,14 +241,6 @@ export function LogRow({
</div> </div>
<div className="mt-3 flex items-center justify-between rounded-xl bg-[#f8fbff] px-3 py-2 text-xs"> <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"> <span className="font-semibold text-slate-500">
{creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")} {creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")}
</span> </span>
@@ -257,8 +249,6 @@ export function LogRow({
? formatMinorAsCurrency(item.balance_after, ccy) ? formatMinorAsCurrency(item.balance_after, ccy)
: "—"} : "—"}
</span> </span>
</>
)}
</div> </div>
</li> </li>
); );

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -2,6 +2,7 @@
import { Wallet } from "lucide-react"; import { Wallet } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -13,7 +14,7 @@ import {
TransferInDialog, TransferInDialog,
TransferOutDialog, TransferOutDialog,
} from "@/features/wallet/wallet-transfer-dialogs"; } 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 { PlayerMoneyDisplay } from "@/components/player-money-display";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block"; import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile"; 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; 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() { export function WalletScreen() {
const router = useRouter();
const searchParams = useSearchParams();
const { activeCurrency: currency } = useActivePlayerCurrency(); const { activeCurrency: currency } = useActivePlayerCurrency();
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const [transferInOpen, setTransferInOpen] = useState(false);
const [transferOutOpen, setTransferOutOpen] = useState(false);
const [balance, setBalance] = useState<WalletBalanceData | null>(null); const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [logs, setLogs] = useState<WalletLogsData | null>(null); const [logs, setLogs] = useState<WalletLogsData | null>(null);
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
@@ -39,8 +49,47 @@ export function WalletScreen() {
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null); const loadMoreRef = useRef<HTMLDivElement | null>(null);
const filterInitializedRef = useRef(false); const filterInitializedRef = useRef(false);
const prevFilterRef = useRef(""); 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 loadLogs = useCallback(async (targetPage = 1, append = false) => {
const nextLogs = await getWalletLogs({ const nextLogs = await getWalletLogs({
@@ -164,8 +213,6 @@ export function WalletScreen() {
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false; const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
const profile = usePlayerSessionStore((s) => s.profile);
const isCreditPlayer = isCreditFundingPlayer(balance) || isCreditFundingPlayer(profile);
const displayMinor = isCreditPlayer const displayMinor = isCreditPlayer
? Number(balance?.available_balance ?? 0) ? Number(balance?.available_balance ?? 0)
: Number(balance?.balance ?? 0); : Number(balance?.balance ?? 0);
@@ -261,16 +308,7 @@ export function WalletScreen() {
</div> </div>
</section> </section>
{isCreditPlayer ? ( {!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"> <div className="grid grid-cols-2 gap-3">
<TransferInDialog <TransferInDialog
idPrefix="wallet-" idPrefix="wallet-"
@@ -285,6 +323,8 @@ export function WalletScreen() {
triggerVariant="hall" triggerVariant="hall"
triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })} triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })}
triggerClassName="h-14 rounded-2xl text-base font-black" triggerClassName="h-14 rounded-2xl text-base font-black"
open={transferInOpen}
onOpenChange={setTransferInOpen}
/> />
<TransferOutDialog <TransferOutDialog
idPrefix="wallet-" idPrefix="wallet-"
@@ -294,11 +334,17 @@ export function WalletScreen() {
triggerVariant="hall" triggerVariant="hall"
triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })} triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })}
triggerClassName="h-14 rounded-2xl text-base font-black" triggerClassName="h-14 rounded-2xl text-base font-black"
open={transferOutOpen}
onOpenChange={setTransferOutOpen}
/> />
</div> </div>
</> ) : null}
)}
<WalletPendingReconcileSection
onViewLogs={() => scrollToWalletSection("wallet-logs")}
/>
<div id="wallet-logs" className="scroll-mt-3">
<WalletLogsBlock <WalletLogsBlock
creditMode={isCreditPlayer} creditMode={isCreditPlayer}
logs={logs} logs={logs}
@@ -312,6 +358,7 @@ export function WalletScreen() {
currency={currency} currency={currency}
/> />
</div> </div>
</div>
</PlayerPanel> </PlayerPanel>
); );
} }

View File

@@ -2,9 +2,6 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, type ReactNode } from "react"; 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 { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import type { WalletBalanceData } from "@/types/api/wallet-balance"; import type { WalletBalanceData } from "@/types/api/wallet-balance";
@@ -21,7 +18,6 @@ export function WalletTransferCreditGuard({
children, children,
}: WalletTransferCreditGuardProps) { }: WalletTransferCreditGuardProps) {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation("player");
useEffect(() => { useEffect(() => {
if (loading || !balance) { if (loading || !balance) {
@@ -37,20 +33,7 @@ export function WalletTransferCreditGuard({
} }
if (isCreditFundingPlayer(balance)) { if (isCreditFundingPlayer(balance)) {
return ( return null;
<PlayerPanel
title={t("wallet.creditTitle", { defaultValue: "信用" })}
backHref="/wallet"
backLabel={t("wallet.creditTitle", { defaultValue: "信用" })}
>
<p className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
{t("wallet.creditNoTransferHint", {
defaultValue:
"由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
})}
</p>
</PlayerPanel>
);
} }
return <>{children}</>; return <>{children}</>;

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { ArrowDownLeft, ArrowUpRight } from "lucide-react"; import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
import { useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -23,6 +23,8 @@ type BaseProps = {
onSuccess: () => Promise<void>; onSuccess: () => Promise<void>;
/** 避免同页多实例 input id 冲突 */ /** 避免同页多实例 input id 冲突 */
idPrefix?: string; idPrefix?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}; };
export function TransferInDialog({ export function TransferInDialog({
@@ -34,6 +36,8 @@ export function TransferInDialog({
triggerClassName, triggerClassName,
triggerVariant = "wallet", triggerVariant = "wallet",
triggerLabel, triggerLabel,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: BaseProps & { }: BaseProps & {
lotteryMinor: number; lotteryMinor: number;
mainMinor?: number | null; mainMinor?: number | null;
@@ -41,7 +45,19 @@ export function TransferInDialog({
triggerVariant?: "wallet" | "hall"; triggerVariant?: "wallet" | "hall";
triggerLabel?: string; 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 { t } = useTranslation("player");
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferIn"); const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferIn");
@@ -94,13 +110,27 @@ export function TransferOutDialog({
triggerClassName, triggerClassName,
triggerVariant = "wallet", triggerVariant = "wallet",
triggerLabel, triggerLabel,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: BaseProps & { }: BaseProps & {
availableMinor: number; availableMinor: number;
triggerClassName?: string; triggerClassName?: string;
triggerVariant?: "wallet" | "hall"; triggerVariant?: "wallet" | "hall";
triggerLabel?: string; 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 { t } = useTranslation("player");
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferOut"); const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferOut");

View File

@@ -1,17 +1,9 @@
"use client"; "use client";
import { useEffect, useState } from "react"; /**
* 玩家端 H5 内容列最大 480px交互一律按紧凑布局处理。
export function useIsMobile(breakpoint = 768): boolean { * 宽屏浏览器中虽 viewport 更宽,画布仍按手机宽度展示,因此恒为 `true`。
const [isMobile, setIsMobile] = useState(false); */
export function useIsMobile(): boolean {
useEffect(() => { return true;
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;
} }

View File

@@ -506,6 +506,8 @@
"lineFailedTitle": "This line did not succeed", "lineFailedTitle": "This line did not succeed",
"status": "Status", "status": "Status",
"statusFilter": "Status filter", "statusFilter": "Status filter",
"showFilters": "Filters",
"hideFilters": "Hide",
"noMore": "No more tickets", "noMore": "No more tickets",
"submitBet": "Submit Bet", "submitBet": "Submit Bet",
"stake": "Stake", "stake": "Stake",
@@ -520,6 +522,7 @@
"groupDetail": "Order detail", "groupDetail": "Order detail",
"groupNotFound": "Could not load this order. Please open it again from My Bets.", "groupNotFound": "Could not load this order. Please open it again from My Bets.",
"betItems": "Bet lines", "betItems": "Bet lines",
"sameOrderItems": "Other lines in this order",
"itemCount": "{{count}} bet line(s)", "itemCount": "{{count}} bet line(s)",
"viewBetLine": "View bet line detail", "viewBetLine": "View bet line detail",
"drawNo": "Issue", "drawNo": "Issue",
@@ -607,6 +610,7 @@
"ticketNumber": "Ticket No. / Number", "ticketNumber": "Ticket No. / Number",
"placeholder": "Enter ticket number or number", "placeholder": "Enter ticket number or number",
"latestDraw": "Latest issue {{drawNo}}", "latestDraw": "Latest issue {{drawNo}}",
"forDraw": "Check your bets and winnings for issue {{drawNo}}.",
"loading": "Checking...", "loading": "Checking...",
"submit": "Check now", "submit": "Check now",
"recent": "Recent checks", "recent": "Recent checks",

View File

@@ -505,6 +505,8 @@
"lineFailedTitle": "यो लाइन सफल भएन", "lineFailedTitle": "यो लाइन सफल भएन",
"status": "स्थिति", "status": "स्थिति",
"statusFilter": "स्थिति फिल्टर", "statusFilter": "स्थिति फिल्टर",
"showFilters": "फिल्टर",
"hideFilters": "लुकाउनुहोस्",
"noMore": "थप टिकट छैन", "noMore": "थप टिकट छैन",
"submitBet": "बेट पेश गर्नुहोस्", "submitBet": "बेट पेश गर्नुहोस्",
"stake": "बेट", "stake": "बेट",
@@ -519,6 +521,7 @@
"groupDetail": "अर्डर विवरण", "groupDetail": "अर्डर विवरण",
"groupNotFound": "यो अर्डर लोड हुन सकेन। कृपया मेरा बेटबाट फेरि खोल्नुहोस्।", "groupNotFound": "यो अर्डर लोड हुन सकेन। कृपया मेरा बेटबाट फेरि खोल्नुहोस्।",
"betItems": "बेट लाइन विवरण", "betItems": "बेट लाइन विवरण",
"sameOrderItems": "यही अर्डरका अन्य बेट लाइनहरू",
"itemCount": "जम्मा {{count}} बेट लाइन", "itemCount": "जम्मा {{count}} बेट लाइन",
"viewBetLine": "बेट लाइन विवरण हेर्नुहोस्", "viewBetLine": "बेट लाइन विवरण हेर्नुहोस्",
"drawNo": "इश्यू", "drawNo": "इश्यू",
@@ -606,6 +609,7 @@
"ticketNumber": "टिकट नं. / नम्बर", "ticketNumber": "टिकट नं. / नम्बर",
"placeholder": "टिकट नम्बर वा नम्बर लेख्नुहोस्", "placeholder": "टिकट नम्बर वा नम्बर लेख्नुहोस्",
"latestDraw": "पछिल्लो इश्यू {{drawNo}}", "latestDraw": "पछिल्लो इश्यू {{drawNo}}",
"forDraw": "इश्यू {{drawNo}} का लागि तपाईंको बेट र जित जाँच गर्नुहोस्।",
"loading": "जाँच हुँदैछ...", "loading": "जाँच हुँदैछ...",
"submit": "अहिले जाँच गर्नुहोस्", "submit": "अहिले जाँच गर्नुहोस्",
"recent": "हालका जाँच", "recent": "हालका जाँच",

View File

@@ -505,6 +505,8 @@
"lineFailedTitle": "本注项未成功", "lineFailedTitle": "本注项未成功",
"status": "状态", "status": "状态",
"statusFilter": "状态筛选", "statusFilter": "状态筛选",
"showFilters": "筛选",
"hideFilters": "收起",
"noMore": "没有更多注单", "noMore": "没有更多注单",
"submitBet": "提交下注", "submitBet": "提交下注",
"stake": "下注", "stake": "下注",
@@ -519,6 +521,7 @@
"groupDetail": "订单详情", "groupDetail": "订单详情",
"groupNotFound": "无法加载该订单,请从注单列表重新进入。", "groupNotFound": "无法加载该订单,请从注单列表重新进入。",
"betItems": "注项明细", "betItems": "注项明细",
"sameOrderItems": "同订单其他注项",
"itemCount": "共 {{count}} 条注项", "itemCount": "共 {{count}} 条注项",
"viewBetLine": "查看注项详情", "viewBetLine": "查看注项详情",
"drawNo": "期号", "drawNo": "期号",
@@ -606,6 +609,7 @@
"ticketNumber": "票号 / 号码", "ticketNumber": "票号 / 号码",
"placeholder": "请输入票号或号码", "placeholder": "请输入票号或号码",
"latestDraw": "最新期号 {{drawNo}}", "latestDraw": "最新期号 {{drawNo}}",
"forDraw": "按本期 {{drawNo}} 查询你的注单和中奖情况。",
"loading": "查询中...", "loading": "查询中...",
"submit": "立即查询", "submit": "立即查询",
"recent": "最近查询", "recent": "最近查询",

View File

@@ -1,11 +1,15 @@
/** 常见局域网段,供手机/其他设备通过 IP 访问 dev 时 HMR WebSocket 放行 */
const DEFAULT_LAN_DEV_ORIGINS = ["192.168.*.*", "10.*.*.*"];
/** 解析 `ALLOWED_DEV_ORIGINS`(逗号分隔),供 next.config `allowedDevOrigins` 使用 */ /** 解析 `ALLOWED_DEV_ORIGINS`(逗号分隔),供 next.config `allowedDevOrigins` 使用 */
export function parseAllowedDevOrigins(envValue: string | undefined): string[] { export function parseAllowedDevOrigins(envValue: string | undefined): string[] {
if (envValue === undefined || envValue.trim() === "") { const fromEnv =
return []; envValue === undefined || envValue.trim() === ""
} ? []
: envValue
return envValue
.split(",") .split(",")
.map((origin) => origin.trim()) .map((origin) => origin.trim())
.filter((origin) => origin !== ""); .filter((origin) => origin !== "");
return [...DEFAULT_LAN_DEV_ORIGINS, ...fromEnv];
} }

View File

@@ -7,3 +7,6 @@ export const playerViewportColumnClass = "mx-auto w-full max-w-[480px]" as const
/** 贴底/贴顶固定栏:与内容列同宽并居中(桌面不铺满整屏) */ /** 贴底/贴顶固定栏:与内容列同宽并居中(桌面不铺满整屏) */
export const playerViewportFixedBarClass = export const playerViewportFixedBarClass =
"fixed left-1/2 z-50 w-full max-w-[480px] -translate-x-1/2" as const; "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;