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 nextConfig: NextConfig = {
...(allowedDevOrigins.length > 0 ? { allowedDevOrigins } : {}),
allowedDevOrigins,
reactCompiler: true,
images: {

View File

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

View File

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

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 async function OrderGroupPage({ params }: PageProps) {
const { groupKey } = await params;
return <TicketOrderGroupScreen groupKey={groupKey} />;
/** 旧「订单详情」中间层已移除,统一回到注单列表 */
export default function OrderGroupRedirectPage() {
redirect("/orders");
}

View File

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

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() {
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";
/** 界面文档 §4.9 彩票钱包:余额、转入/转出、流水 */
export default function WalletPage() {
return <WalletScreen />;
function WalletFallback(): ReactNode {
return <div className="min-h-[40vh] bg-white" aria-hidden />;
}
/** 界面文档 §4.9 彩票钱包:余额、转入/转出、待对账、流水 */
export default function WalletPage() {
return (
<Suspense fallback={<WalletFallback />}>
<WalletScreen />
</Suspense>
);
}

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 { Suspense } from "react";
import { Loader2 } from "lucide-react";
import { PlayerLoginScreen } from "@/features/player/player-login-screen";
function LoginFallback(): ReactNode {
return <div className="min-h-dvh bg-white" aria-hidden />;
return (
<div
className="flex min-h-0 flex-1 flex-col items-center justify-center bg-white"
aria-hidden
>
<Loader2 className="size-8 animate-spin text-red-600" />
</div>
);
}
export default function PlayerLoginPage() {
return (
<Suspense fallback={<LoginFallback />}>
<PlayerLoginScreen />
</Suspense>
<div className="flex min-h-0 flex-1 flex-col">
<Suspense fallback={<LoginFallback />}>
<PlayerLoginScreen />
</Suspense>
</div>
);
}

View File

@@ -1,16 +1,26 @@
import type { ReactNode } from "react";
import { Suspense } from "react";
import { Loader2 } from "lucide-react";
import { EntryGate } from "@/features/player/entry-gate";
function EntryFallback(): ReactNode {
return <div className="min-h-dvh bg-white" aria-hidden />;
return (
<div
className="flex min-h-0 flex-1 flex-col items-center justify-center bg-white"
aria-hidden
>
<Loader2 className="size-8 animate-spin text-red-600" />
</div>
);
}
export default function EntryPage() {
return (
<Suspense fallback={<EntryFallback />}>
<EntryGate />
</Suspense>
<div className="flex min-h-0 flex-1 flex-col">
<Suspense fallback={<EntryFallback />}>
<EntryGate />
</Suspense>
</div>
);
}

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 */
[data-sonner-toaster] {
--width: min(280px, calc(100vw - 24px));

View File

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

View File

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

View File

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

View File

@@ -28,14 +28,17 @@ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
});
return (
<div className="flex h-full flex-col bg-white text-foreground overflow-hidden">
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-white text-foreground">
<PullToRefreshIndicator
pullDistance={pullDistance}
isRefreshing={isRefreshing}
threshold={70}
/>
<NetworkStatusBanner />
<main id="player-scroll-container" className="flex w-full flex-1 flex-col pb-1 overflow-y-auto overscroll-y-contain">
<main
id="player-scroll-container"
className="flex w-full min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain pb-3"
>
{children}
</main>
<div className="shrink-0 w-full relative z-50">

View File

@@ -1,6 +1,10 @@
import type { ReactNode } from "react";
import { playerViewportColumnClass } from "@/lib/player-viewport";
import { OfflineBanner } from "@/components/offline-banner";
import {
playerSafeAreaTopClass,
playerViewportColumnClass,
} from "@/lib/player-viewport";
import { cn } from "@/lib/utils";
type PlayerMobileViewportProps = {
@@ -20,10 +24,12 @@ export function PlayerMobileViewport({
<div
className={cn(
playerViewportColumnClass,
"relative h-full bg-white shadow-none sm:shadow-[0_0_48px_rgba(15,23,42,0.1)] flex flex-col",
playerSafeAreaTopClass,
"relative flex h-full min-h-0 flex-col bg-white shadow-none sm:shadow-[0_0_48px_rgba(15,23,42,0.1)]",
className,
)}
>
<OfflineBanner />
{children}
</div>
</div>

View File

@@ -26,7 +26,10 @@ export function PullToRefreshIndicator({
return (
<div
className="pointer-events-none fixed left-1/2 z-[60] flex w-full max-w-[480px] -translate-x-1/2 items-center justify-center overflow-hidden transition-[height] duration-100 ease-out"
style={{ height: isRefreshing ? 40 : pullDistance }}
style={{
top: "env(safe-area-inset-top, 0px)",
height: isRefreshing ? 40 : pullDistance,
}}
>
<div
className={cn(

View File

@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}

View File

@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}

View File

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

View File

@@ -588,13 +588,18 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
return allPlayColumns.filter((col) => allowedCodes.has(col.play.play_code));
}, [activeCategory, allPlayColumns, d4PlayGroup]);
const compactTable = playColumns.length > 6;
const amountColClass = compactTable
? "w-[3.75rem] min-w-[3.75rem] max-w-[3.75rem]"
: "w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem]";
const tableMinWidthPx = useMemo(() => {
const indexCol = 40;
const numberCol = activeCategory === "D4" ? 112 : activeCategory === "D3" ? 88 : 72;
const amountCol = 72;
const amountCol = compactTable ? 60 : 72;
const deleteCol = 36;
return indexCol + numberCol + playColumns.length * amountCol + deleteCol;
}, [activeCategory, playColumns.length]);
}, [activeCategory, compactTable, playColumns.length]);
const showWideTableHint = activeCategory === "D4" && allPlayColumns.length > 8 && playColumns.length > 8;
@@ -1382,9 +1387,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
className={cn(
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-opacity",
tableDisabled && "opacity-55",
showWideTableHint && "player-table-scroll-wrap",
)}
>
<div className="overflow-x-auto overscroll-x-contain">
<div className="player-table-scroll overflow-x-auto overscroll-x-contain">
<table
className="w-full border-collapse text-[11px]"
style={{ minWidth: tableMinWidthPx }}
@@ -1412,7 +1418,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
{playColumns.map((column) => (
<th
key={column.key}
className="w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem] px-0.5 py-2 text-center font-bold"
className={cn(amountColClass, "px-0.5 py-2 text-center font-bold")}
>
<span className="block whitespace-nowrap text-[11px] leading-tight">
{playColumnHeaderLabel(
@@ -1512,7 +1518,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
onClick={() => setActiveRowId(row.id)}
onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
className={cn(
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center text-xs font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
"h-9 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center text-base font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",

View File

@@ -15,8 +15,6 @@ export type TicketItemGroup = {
jackpot_win_amount: number;
};
export const ORDER_GROUP_STORAGE_PREFIX = "lottery:order-group:v1:";
/** 分组键:有 order_no 则按订单;否则 draw_no + placed_at + currency + status 兜底 */
export function getTicketItemGroupKey(row: TicketItemListRow): string {
const orderNo = (row.order_no ?? "").trim();
@@ -135,56 +133,6 @@ export function groupTicketItems(items: TicketItemListRow[]): TicketItemGroup[]
});
}
export function persistOrderGroup(group: TicketItemGroup): void {
try {
sessionStorage.setItem(
ORDER_GROUP_STORAGE_PREFIX + group.key,
JSON.stringify(group),
);
} catch {
/* quota / private mode */
}
}
export function loadPersistedOrderGroup(groupKey: string): TicketItemGroup | null {
try {
const raw = sessionStorage.getItem(ORDER_GROUP_STORAGE_PREFIX + groupKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as TicketItemGroup;
if (!parsed?.key || !Array.isArray(parsed.items)) return null;
return { ...parsed, items: sortTicketGroupItems(parsed.items) };
} catch {
return null;
}
}
export function orderGroupPath(groupKey: string, ticketNos?: string[]): string {
const base = `/orders/group/${encodeURIComponent(groupKey)}`;
if (!ticketNos?.length) return base;
return `${base}?tickets=${encodeURIComponent(ticketNos.join(","))}`;
}
export function orderGroupHref(group: TicketItemGroup): string {
if (group.items.length === 1) {
return `/orders/${encodeURIComponent(group.items[0].ticket_no)}`;
}
return orderGroupPath(
group.key,
group.items.map((i) => i.ticket_no),
);
}
/** 注项详情;来自订单组时带上 fromGroup便于返回订单详情 */
export function ticketDetailHref(
ticketNo: string,
fromGroup?: TicketItemGroup | null,
): string {
const base = `/orders/${encodeURIComponent(ticketNo)}`;
const key = (fromGroup?.key ?? "").trim();
if (!key) return base;
const params = new URLSearchParams({ fromGroup: key });
if (fromGroup && fromGroup.items.length > 1) {
params.set("tickets", fromGroup.items.map((i) => i.ticket_no).join(","));
}
return `${base}?${params.toString()}`;
export function ticketDetailHref(ticketNo: string): string {
return `/orders/${encodeURIComponent(ticketNo)}`;
}

View File

@@ -1,11 +1,11 @@
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getTicketItemDetail } from "@/api/ticket-items";
import { getTicketItemDetail, getTicketItems } from "@/api/ticket-items";
import { Button } from "@/components/ui/button";
import { PlayerPanel } from "@/components/layout/player-panel";
import {
@@ -18,7 +18,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { orderGroupPath } from "@/features/orders/group-ticket-items";
import { ticketDetailHref } from "@/features/orders/group-ticket-items";
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
import { formatPlayerInstant } from "@/lib/player-datetime";
import { formatMinorAsCurrency } from "@/lib/money";
@@ -28,7 +28,7 @@ import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { playLabel } from "@/lib/play-labels";
import { cn } from "@/lib/utils";
import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
import type { TicketItemDetailPayload, TicketItemListRow } from "@/types/api/ticket-items";
type OddsSnapRow = { prize_scope?: string; odds_value?: number };
@@ -78,35 +78,16 @@ type TicketItemDetailWithExtras = TicketItemDetailPayload & {
/** 界面文档 §4.8 注单详情 */
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
const searchParams = useSearchParams();
const { t } = useTranslation("player");
const { activeCurrency } = useActivePlayerCurrency();
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
useCurrencyCatalog();
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
const [siblingItems, setSiblingItems] = useState<TicketItemListRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const fromGroupKey = useMemo(
() => (searchParams.get("fromGroup") ?? "").trim(),
[searchParams],
);
const groupTickets = useMemo(
() => (searchParams.get("tickets") ?? "").trim(),
[searchParams],
);
const backNav = useMemo(() => {
if (!fromGroupKey) {
return { href: "/orders", label: t("orders.title") };
}
const ticketNos = groupTickets
? groupTickets.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
return {
href: orderGroupPath(fromGroupKey, ticketNos),
label: t("orders.groupDetail"),
};
}, [fromGroupKey, groupTickets, t]);
const backHref = "/orders";
const backLabel = t("orders.title");
const load = useCallback(async (options?: { silent?: boolean }) => {
if (!options?.silent) {
@@ -134,6 +115,33 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
});
}, [load]);
useEffect(() => {
const orderNo = (data?.order_no ?? "").trim();
if (!orderNo) {
setSiblingItems([]);
return;
}
let cancelled = false;
void (async () => {
try {
const res = await getTicketItems({ order_no: orderNo, per_page: 50 });
if (cancelled) return;
setSiblingItems(
res.items.filter((row) => row.ticket_no !== ticketNo),
);
} catch {
if (!cancelled) {
setSiblingItems([]);
}
}
})();
return () => {
cancelled = true;
};
}, [data?.order_no, ticketNo]);
useEffect(() => {
if (!data || !TRANSIENT_TICKET_STATUSES.has(data.status)) {
return;
@@ -153,8 +161,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
return (
<PlayerPanel
title={t("orders.betDetail")}
backHref={backNav.href}
backLabel={backNav.label}
backHref={backHref}
backLabel={backLabel}
>
<div className="space-y-3">
<Skeleton className="h-12 rounded-xl" />
@@ -168,8 +176,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
return (
<PlayerPanel
title={t("orders.betDetail")}
backHref={backNav.href}
backLabel={backNav.label}
backHref={backHref}
backLabel={backLabel}
>
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
<p>{error ?? t("orders.noData")}</p>
@@ -235,10 +243,55 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
return (
<PlayerPanel
title={t("orders.betDetail")}
backHref={backNav.href}
backLabel={backNav.label}
backHref={backHref}
backLabel={backLabel}
>
<div className="flex flex-col gap-3">
{siblingItems.length > 0 ? (
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<p className="text-sm font-black text-[#0b3f96]">{t("orders.sameOrderItems")}</p>
<div className="mt-2 space-y-2">
{siblingItems.map((row) => {
const lineSt = ticketStatusDisplay(
row.status,
row.win_amount,
row.jackpot_win_amount,
t,
creditMode,
);
const lineCur = row.currency_code ?? activeCurrency;
return (
<Link
key={row.ticket_no}
href={ticketDetailHref(row.ticket_no)}
className="flex items-center gap-3 rounded-xl border border-[#e5edf8] bg-[#fbfdff] px-3 py-2.5 transition-colors hover:border-[#b9ccf6] hover:bg-white"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-black text-[#0b3f96]">
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
</p>
<p className="mt-0.5 text-xs text-slate-500">
{t("orders.deduction")}{" "}
<span className="font-bold tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<StatusDot
label={lineSt.label}
dotClass={lineSt.dotClass}
ring={lineSt.ring}
/>
<ChevronRight className="size-4 text-[#7890b8]" aria-hidden />
</div>
</Link>
);
})}
</div>
</div>
) : null}
<Card className="ring-0 border border-[#e8eef7] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
<CardHeader className="space-y-2 border-b border-[#edf2f9] pb-3">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -445,12 +498,12 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
</Link>
) : null}
<Link
href={backNav.href}
href={backHref}
className={cn(
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl border border-[#dce7f7] bg-white px-4 text-sm font-semibold text-[#07459f] transition-colors hover:bg-[#f1f6ff]",
)}
>
{fromGroupKey ? t("orders.backToGroup") : t("orders.backToOrders")}
{t("orders.backToOrders")}
</Link>
</div>
</div>

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";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CalendarRange, ChevronDown, Search } from "lucide-react";
import { CalendarRange, ChevronDown, ChevronRight, Search, SlidersHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getDrawCurrent } from "@/api/draw";
@@ -14,15 +14,11 @@ import { Input } from "@/components/ui/input";
import { PlayerPanel } from "@/components/layout/player-panel";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import {
groupTicketItems,
orderGroupHref,
persistOrderGroup,
} from "@/features/orders/group-ticket-items";
import { groupTicketItems, ticketDetailHref } from "@/features/orders/group-ticket-items";
import { OrderMetaLine } from "@/features/orders/order-meta-line";
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { useIsMobile } from "@/hooks/use-mobile";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import { formatMinorAsCurrency } from "@/lib/money";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
@@ -52,16 +48,19 @@ const STATUS_OPTIONS = [
] as const;
export function TicketOrdersListScreen() {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation("player");
const { activeCurrency } = useActivePlayerCurrency();
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
useCurrencyCatalog();
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
const numberFilter = useMemo(() => (searchParams.get("number") ?? "").trim(), [searchParams]);
const statusFilter = useMemo(
() => searchParams.getAll("status").map((s) => s.trim()).filter(Boolean),
[searchParams],
);
const hasUrlFilters = drawNoFilter !== "" || numberFilter !== "" || statusFilter.length > 0;
const [items, setItems] = useState<TicketItemListRow[]>([]);
const [page, setPage] = useState(1);
@@ -74,7 +73,11 @@ export function TicketOrdersListScreen() {
base: drawNoFilter,
draft: drawNoFilter,
}));
const [queryNumber, setQueryNumber] = useState("");
const [queryNumberDraft, setQueryNumberDraft] = useState("");
const queryNumber = numberFilter || queryNumberDraft;
const [filtersOpen, setFiltersOpen] = useState(hasUrlFilters);
const [filtersCollapsed, setFiltersCollapsed] = useState(false);
const filtersExpanded = hasUrlFilters ? !filtersCollapsed : filtersOpen;
const [queryStatuses, setQueryStatuses] = useState<string[]>(statusFilter);
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
@@ -83,7 +86,6 @@ export function TicketOrdersListScreen() {
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
const [scheduleTimezone, setScheduleTimezone] = useState(LOTTERY_SCHEDULE_TIMEZONE);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobile();
const initialLoadDone = useRef(false);
const queryDrawNoInput =
@@ -110,7 +112,6 @@ export function TicketOrdersListScreen() {
if (fromDate && toDate) return `${formatCompactDate(fromDate)} ~ ${formatCompactDate(toDate)}`;
return `${fromDate ? formatCompactDate(fromDate) : "..." } ~ ${toDate ? formatCompactDate(toDate) : "..."}`;
}, [formatCompactDate, fromDate, t, toDate]);
const visiblePages = useMemo(() => buildPageWindow(page, lastPage), [lastPage, page]);
const orderGroups = useMemo(() => groupTicketItems(items), [items]);
const fetchPage = useCallback(
@@ -214,6 +215,24 @@ export function TicketOrdersListScreen() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
className={cn(
"h-9 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]",
filtersExpanded && "border-[#b9ccf6] bg-[#f1f6ff] text-[#0b56b7]",
)}
onClick={() => {
if (hasUrlFilters) {
setFiltersCollapsed((value) => !value);
return;
}
setFiltersOpen((value) => !value);
}}
>
<SlidersHorizontal className="size-3.5" />
{filtersExpanded ? t("orders.hideFilters") : t("orders.showFilters")}
</Button>
<Link
href="/hall"
className="inline-flex h-9 shrink-0 items-center rounded-full bg-[#e5002c] px-5 text-sm font-black text-white shadow-[0_8px_18px_rgba(229,0,44,0.22)]"
@@ -226,13 +245,18 @@ export function TicketOrdersListScreen() {
variant="outline"
className="h-9 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
onClick={() => {
setQueryDrawNoState({ base: drawNoFilter, draft: drawNoFilter });
setQueryNumber("");
setQueryDrawNoState({ base: "", draft: "" });
setQueryNumberDraft("");
setFromDate("");
setToDate("");
setQueryStatuses(statusFilter);
setQueryStatuses([]);
setFiltersOpen(false);
setFiltersCollapsed(false);
setRangeOpen(false);
setStatusOpen(false);
if (hasUrlFilters) {
router.replace("/orders");
}
}}
>
{t("actions.clear")}
@@ -241,6 +265,7 @@ export function TicketOrdersListScreen() {
</div>
</div>
{filtersExpanded ? (
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="flex h-9 min-w-0 items-center rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
<Input
@@ -250,7 +275,7 @@ export function TicketOrdersListScreen() {
}
placeholder={t("orders.drawNo")}
aria-label={t("orders.drawNo")}
className="h-7 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
/>
</div>
@@ -258,10 +283,10 @@ export function TicketOrdersListScreen() {
<Search className="size-3.5 shrink-0 text-slate-400" />
<Input
value={queryNumber}
onChange={(e) => setQueryNumber(e.target.value)}
onChange={(e) => setQueryNumberDraft(e.target.value)}
placeholder={t("orders.number")}
aria-label={t("orders.number")}
className="h-7 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
/>
</div>
@@ -286,7 +311,7 @@ export function TicketOrdersListScreen() {
mode="range"
month={calendarMonth}
onMonthChange={setCalendarMonth}
numberOfMonths={isMobile ? 1 : 2}
numberOfMonths={1}
selected={selectedRange}
onSelect={(range) => {
if (!range?.from && !range?.to) {
@@ -388,6 +413,7 @@ export function TicketOrdersListScreen() {
</PopoverContent>
</Popover>
</div>
) : null}
</div>
{loading ? (
@@ -432,113 +458,109 @@ export function TicketOrdersListScreen() {
);
const totalWin = group.win_amount + group.jackpot_win_amount;
return (
<Link
<div
key={group.key}
href={orderGroupHref(group)}
onClick={() => persistOrderGroup(group)}
className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]"
className="rounded-xl border border-[#e5edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)]"
>
<div className="flex items-start justify-between gap-3">
<p className="min-w-0 truncate font-mono text-sm font-black text-[#0b3f96]">
{group.draw_no ?? "—"}
</p>
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
</div>
<OrderMetaLine
orderNo={group.order_no}
placedAt={group.placed_at}
t={t}
/>
<div className="mt-2.5 space-y-1">
{group.items.map((row) => (
<p
key={row.ticket_no}
className="text-sm font-semibold text-[#32518d]"
>
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
</p>
))}
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
<p className="mt-1 text-sm font-black text-slate-900">
{formatMinorAsCurrency(group.total_bet_amount, cur)}
<div className="p-3 pb-2">
<div className="flex items-start justify-between gap-3">
<p className="min-w-0 truncate font-mono text-sm font-black text-[#0b3f96]">
{group.draw_no ?? "—"}
</p>
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
</div>
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
<p className="mt-1 text-sm font-black text-[#0b3f96]">
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
</p>
<OrderMetaLine
orderNo={group.order_no}
placedAt={group.placed_at}
t={t}
/>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
<p className="mt-1 text-sm font-black text-slate-900">
{formatMinorAsCurrency(group.total_bet_amount, cur)}
</p>
</div>
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
<p className="mt-1 text-sm font-black text-[#0b3f96]">
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
</p>
</div>
</div>
{group.status === "partial_failed" ? (
<p className="mt-2 text-xs font-bold text-amber-700">
{t("orders.partialFailedHint")}
</p>
) : null}
{totalWin > 0 && group.status === "settled_win" ? (
<p className="mt-2 text-xs font-bold text-emerald-600">
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
</p>
) : null}
</div>
{group.status === "partial_failed" ? (
<p className="mt-2 text-xs font-bold text-amber-700">
{t("orders.partialFailedHint")}
<div className="space-y-2 border-t border-[#edf2f8] px-3 py-3">
<p className="text-[11px] font-bold uppercase tracking-wide text-[#7890b8]">
{t("orders.betItems")}
</p>
) : null}
{totalWin > 0 && group.status === "settled_win" ? (
<p className="mt-2 text-xs font-bold text-emerald-600">
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
</p>
) : null}
</Link>
{group.items.map((row, index) => {
const lineSt = ticketStatusDisplay(
row.status,
row.win_amount,
row.jackpot_win_amount,
t,
creditMode,
);
const lineCur = row.currency_code ?? cur;
return (
<Link
key={row.ticket_no}
href={ticketDetailHref(row.ticket_no)}
aria-label={t("orders.viewBetLine")}
className="flex items-center gap-3 rounded-xl border border-[#e5edf8] bg-[#fbfdff] px-3 py-2.5 transition-colors hover:border-[#b9ccf6] hover:bg-white"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#eaf2ff] text-xs font-black text-[#0b56b7]">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-black text-[#0b3f96]">
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
</p>
<p className="mt-0.5 text-xs text-slate-500">
{t("orders.deduction")}{" "}
<span className="font-bold tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
</span>
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<StatusDot
label={lineSt.label}
dotClass={lineSt.dotClass}
ring={lineSt.ring}
/>
<ChevronRight className="size-4 text-[#7890b8]" aria-hidden />
</div>
</Link>
);
})}
</div>
</div>
);
})}
</div>
{isMobile ? <div ref={loadMoreRef} className="min-h-1" /> : null}
{isMobile && page < lastPage ? (
<Button
type="button"
variant="outline"
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
disabled={loadingMore}
onClick={() => void fetchPage(page + 1, true)}
>
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
</Button>
) : !isMobile && lastPage > 1 ? (
<div className="flex flex-wrap items-center justify-center gap-2 rounded-xl border border-[#e6edf8] bg-white px-3 py-3">
<Button
type="button"
variant="outline"
size="sm"
className="rounded-full border-[#dce7f7] bg-white text-[#32518d]"
disabled={loading || page <= 1}
onClick={() => void fetchPage(Math.max(1, page - 1), false)}
>
{t("actions.previous")}
</Button>
{visiblePages.map((p) => (
<Button
key={p}
type="button"
variant={p === page ? "default" : "outline"}
size="sm"
className={cn(
"min-w-8 rounded-full",
p === page
? "bg-[#07459f] text-white hover:bg-[#063b88]"
: "border-[#dce7f7] bg-white text-[#32518d]",
)}
disabled={loading}
onClick={() => void fetchPage(p, false)}
>
{p}
</Button>
))}
<Button
type="button"
variant="outline"
size="sm"
className="rounded-full border-[#dce7f7] bg-white text-[#32518d]"
disabled={loading || page >= lastPage}
onClick={() => void fetchPage(Math.min(lastPage, page + 1), false)}
>
{t("actions.next")}
</Button>
</div>
<div ref={loadMoreRef} className="min-h-1" />
{page < lastPage ? (
<Button
type="button"
variant="outline"
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
disabled={loadingMore}
onClick={() => void fetchPage(page + 1, true)}
>
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
</Button>
) : lastPage > 1 ? (
<p className="py-2 text-center text-xs text-slate-400">
{t("orders.noMore")}
@@ -551,11 +573,4 @@ export function TicketOrdersListScreen() {
);
}
function buildPageWindow(current: number, last: number): number[] {
if (last <= 5) {
return Array.from({ length: last }, (_, index) => index + 1);
}
const start = Math.max(1, Math.min(current - 2, last - 4));
return Array.from({ length: 5 }, (_, index) => start + index);
}

View File

@@ -116,6 +116,14 @@ export function EntryGate() {
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
usePlayerSessionStore();
/** 仅主站 iframe 内复用已有 token直连站点只认 URL `?token=`SSO */
const isReturningSession =
typeof window !== "undefined" &&
isInIframe() &&
!sessionExpired &&
!tokenFromUrl &&
(bearerToken ?? "").trim() !== "";
const [resumeSilent, setResumeSilent] = useState(isReturningSession);
const waitingForEmbeddedToken =
!sessionExpired &&
typeof window !== "undefined" &&
@@ -128,13 +136,11 @@ export function EntryGate() {
if (!isInIframe()) {
if (sessionExpired) return false;
const hasToken = tokenFromUrl !== "" || (bearerToken ?? "").trim() !== "";
if (!hasToken) return false;
return tokenFromUrl !== "";
}
return true;
}, [bearerToken, sessionExpired, tokenFromUrl]);
}, [sessionExpired, tokenFromUrl]);
useEffect(() => {
if (gateReady) return;
@@ -143,13 +149,10 @@ export function EntryGate() {
if (sessionExpired) {
router.replace("/login?session=expired");
} else {
const hasToken = tokenFromUrl !== "" || (bearerToken ?? "").trim() !== "";
if (!hasToken) {
router.replace("/login");
}
} else if (!tokenFromUrl) {
router.replace("/login");
}
}, [gateReady, router, sessionExpired, tokenFromUrl, bearerToken]);
}, [gateReady, router, sessionExpired, tokenFromUrl]);
const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading");
const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() =>
@@ -164,7 +167,15 @@ export function EntryGate() {
);
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
const effectiveToken = tokenFromUrl || bearerToken;
const entryToken = useMemo(() => {
if (typeof window === "undefined") {
return tokenFromUrl || bearerToken;
}
if (!isInIframe()) {
return tokenFromUrl;
}
return tokenFromUrl || bearerToken;
}, [bearerToken, tokenFromUrl]);
/** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下 */
const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle");
@@ -183,7 +194,7 @@ export function EntryGate() {
return;
}
if (!effectiveToken) {
if (!entryToken) {
// 主站 iframetoken 由 MAIN_INIT_TOKEN 稍后到达,勿先闪「授权失败」
if (typeof window !== "undefined" && isInIframe() && !tokenFromUrl) {
return;
@@ -199,6 +210,7 @@ export function EntryGate() {
}
entryLifecycleRef.current = "running";
setResumeSilent(false);
setPhase("loading");
setFailureDetails([]);
@@ -298,7 +310,7 @@ export function EntryGate() {
},
]);
}, [
effectiveToken,
entryToken,
tokenFromUrl,
setBearerToken,
setProfile,
@@ -323,26 +335,75 @@ export function EntryGate() {
stripSearchParamFromBrowserUrl("session");
}, [sessionExpired, clearBearerToken]);
const trimmedEffectiveToken = (effectiveToken ?? "").trim();
const trimmedEntryToken = (entryToken ?? "").trim();
const doEntryRef = useRef(doEntry);
useEffect(() => {
doEntryRef.current = doEntry;
}, [doEntry]);
useEffect(() => {
if (sessionExpired || waitingForEmbeddedToken) return;
if (sessionExpired || waitingForEmbeddedToken || isReturningSession) return;
if (entryLifecycleRef.current !== "idle") return;
if (!trimmedEffectiveToken) return;
if (!trimmedEntryToken) return;
const tmr = window.setTimeout(() => {
void doEntryRef.current();
}, 300);
return () => window.clearTimeout(tmr);
}, [sessionExpired, trimmedEffectiveToken, waitingForEmbeddedToken]);
}, [isReturningSession, sessionExpired, trimmedEntryToken, waitingForEmbeddedToken]);
useEffect(() => {
if (!isReturningSession) return;
if (entryLifecycleRef.current === "running" || entryLifecycleRef.current === "done") {
return;
}
entryLifecycleRef.current = "running";
let cancelled = false;
const resumeToHall = async () => {
try {
if (!usePlayerSessionStore.getState().profile) {
const [me] = await Promise.all([getPlayerMe(), sleep(300)]);
if (cancelled) return;
try {
const currencies = await getPublicCurrencies();
setCurrencies(currencies.items);
} catch {
/* 不阻断回大厅 */
}
setProfile(me);
await Promise.all([getPlayerPing(), sleep(200)]);
if (cancelled) return;
}
entryLifecycleRef.current = "done";
router.replace("/hall");
} catch (err) {
if (cancelled) return;
if (err instanceof LotteryApiBizError) {
entryLifecycleRef.current = "done";
clearBearerToken();
router.replace("/login");
return;
}
entryLifecycleRef.current = "idle";
setResumeSilent(false);
void doEntryRef.current();
}
};
void resumeToHall();
return () => {
cancelled = true;
if (entryLifecycleRef.current === "running") {
entryLifecycleRef.current = "idle";
}
};
}, [clearBearerToken, isReturningSession, router, setCurrencies, setProfile]);
useEffect(() => {
if (sessionExpired) return;
if (tokenFromUrl || effectiveToken) return;
if (tokenFromUrl || bearerToken) return;
if (typeof window === "undefined" || !isInIframe()) return;
const tmr = window.setTimeout(() => {
@@ -354,14 +415,22 @@ export function EntryGate() {
}, IFRAME_TOKEN_WAIT_MS);
return () => window.clearTimeout(tmr);
}, [sessionExpired, effectiveToken, tokenFromUrl]);
}, [bearerToken, sessionExpired, tokenFromUrl]);
if (!gateReady) {
return null;
return (
<div className="flex min-h-0 flex-1 items-center justify-center bg-white">
<Loader2 className="size-8 animate-spin text-red-600" aria-hidden />
</div>
);
}
if (resumeSilent && phase === "loading") {
return <EntryBusyScreen />;
}
return (
<div className="relative flex min-h-dvh flex-col bg-white">
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-white">
<div className={cn("relative h-[45vh] min-h-[200px]", phase === "success" ? "bg-white" : "bg-red-600")}>
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<Image
@@ -472,7 +541,8 @@ export function EntryGate() {
{t("failure.detailsTitle")}
</span>
</div>
<table className="w-full text-sm">
<div className="overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch]">
<table className="w-full min-w-[20rem] text-sm">
<thead className="bg-red-100/50 text-xs">
<tr>
<th className="px-3 py-2 text-left font-medium text-red-700">
@@ -502,6 +572,7 @@ export function EntryGate() {
))}
</tbody>
</table>
</div>
</div>
) : null}
@@ -586,6 +657,17 @@ export function EntryGate() {
);
}
function EntryBusyScreen() {
const { t } = useTranslation("entry");
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 bg-white px-6">
<Loader2 className="size-10 animate-spin text-red-600" aria-hidden />
<p className="text-sm font-medium text-slate-600">{t("loading.title")}</p>
</div>
);
}
function EntryStatusBadge({ status }: { status: EntryStepStatus }) {
const { t } = useTranslation("common");

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 (
<div className="relative flex min-h-dvh flex-col bg-white">
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-white">
<div className="relative h-[45vh] min-h-[200px] shrink-0 overflow-hidden bg-[#f8fafc]">
<div className="pointer-events-none absolute inset-0">
<Image src="/entry/image1.png" alt="" fill sizes="100vw" className="object-cover object-center" priority />

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";
import Link from "next/link";
import { BriefcaseBusiness, CheckCircle2, Clock3, RefreshCw, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getDrawResults } from "@/api/draw";
import { getTicketDrawMyMatch, getTicketItems } from "@/api/ticket-items";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { PlayerPanel } from "@/components/layout/player-panel";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { formatMinorAsCurrency } from "@/lib/money";
import { formatPlayerInstant } from "@/lib/player-datetime";
import { playLabel } from "@/lib/play-labels";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import type { DrawResultListItem } from "@/types/api/draw-results";
import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items";
type WinningCheckResult = {
draw: DrawResultListItem;
match: TicketDrawMyMatchPayload;
tickets: TicketItemListRow[];
};
import { Skeleton } from "@/components/ui/skeleton";
import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-panel";
/** 保留组件供内嵌使用;独立路由已 redirect 至期号详情 */
export function CheckWinningScreen() {
const { t } = useTranslation("player");
useCurrencyCatalog();
const [ticketNo, setTicketNo] = useState("");
const [latestDraw, setLatestDraw] = useState<DrawResultListItem | null>(null);
const [recent, setRecent] = useState<string[]>([]);
const [result, setResult] = useState<WinningCheckResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [drawNo, setDrawNo] = useState<string | null>(null);
useEffect(() => {
queueMicrotask(() => {
void (async () => {
try {
const res = await getDrawResults({ page: 1, size: 1 });
setLatestDraw(res.items[0] ?? null);
} catch {
setLatestDraw(null);
}
})();
});
void getDrawResults({ page: 1, size: 1 })
.then((res) => setDrawNo(res.items[0]?.draw_no ?? null))
.catch(() => setDrawNo(null));
}, []);
const normalizedTicketNo = ticketNo.trim();
const runCheck = useCallback(async () => {
if (!latestDraw || normalizedTicketNo === "") {
return;
}
setLoading(true);
setError(null);
try {
const [match, tickets] = await Promise.all([
getTicketDrawMyMatch(latestDraw.draw_no),
getTicketItems({
draw_no: latestDraw.draw_no,
number: normalizedTicketNo,
per_page: 10,
page: 1,
}),
]);
const next = {
draw: latestDraw,
match,
tickets: tickets.items,
};
setResult(next);
setRecent((current) => [normalizedTicketNo, ...current.filter((x) => x !== normalizedTicketNo)].slice(0, 5));
} catch {
setError(t("results.check.loadFailed"));
} finally {
setLoading(false);
}
}, [latestDraw, normalizedTicketNo, t]);
return (
<PlayerPanel title={t("results.check.title")} backHref="/results" backLabel={t("results.title")}>
<div className="space-y-3">
<section className="overflow-hidden rounded-2xl border border-red-100 bg-white shadow-[0_12px_32px_rgba(15,23,42,0.06)]">
<div className="bg-gradient-to-b from-red-50 to-white px-5 pb-5 pt-8 text-center">
<div className="mx-auto flex size-24 items-center justify-center rounded-full bg-white text-[#e5002c] shadow-[0_18px_40px_rgba(229,0,44,0.14)]">
<BriefcaseBusiness className="size-12" />
</div>
<h2 className="mt-5 text-lg font-black text-slate-950">
{t("results.check.enterTicket")}
</h2>
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-slate-500">
{t("results.check.description")}
</p>
</div>
<div className="space-y-2.5 px-3 pb-3">
<label className="block space-y-1.5">
<span className="text-xs font-black text-slate-700">
{t("results.check.ticketNumber")}
</span>
<Input
value={ticketNo}
placeholder={t("results.check.placeholder")}
onChange={(e) => setTicketNo(e.target.value)}
className="h-12 rounded-xl border-[#dce7f7] bg-white font-mono text-base font-bold"
/>
</label>
{latestDraw ? (
<p className="text-xs text-slate-500">
{t("results.check.latestDraw", { drawNo: latestDraw.draw_no })}
</p>
) : null}
{error ? <p className="text-sm font-semibold text-[#e5002c]">{error}</p> : null}
<Button
type="button"
disabled={!latestDraw || normalizedTicketNo === "" || loading}
onClick={() => void runCheck()}
className="h-12 w-full rounded-xl bg-[#e5002c] text-base font-black text-white hover:bg-[#d10028]"
>
{loading ? t("results.check.loading") : t("results.check.submit")}
</Button>
</div>
</section>
<section className="rounded-2xl border border-[#dfe8f6] bg-white p-4 shadow-[0_10px_26px_rgba(15,23,42,0.05)]">
<div className="flex items-center justify-between">
<h3 className="font-black text-slate-950">
{t("results.check.recent")}
</h3>
{recent.length > 0 ? (
<button type="button" className="text-sm font-bold text-[#0b56b7]" onClick={() => setRecent([])}>
{t("actions.clear")}
</button>
) : null}
</div>
<div className="mt-3 divide-y divide-[#edf2f9]">
{recent.length === 0 ? (
<p className="py-4 text-sm text-slate-500">
{t("results.check.noRecent")}
</p>
) : (
recent.map((row) => (
<button
key={row}
type="button"
className="flex w-full items-center justify-between py-3 text-left"
onClick={() => setTicketNo(row)}
>
<span className="flex items-center gap-2 font-mono text-sm font-black text-slate-800">
<Clock3 className="size-4 text-slate-400" />
{row}
</span>
<span className="text-xs text-slate-400">
{latestDraw?.business_date ?? "—"}
</span>
</button>
))
)}
</div>
</section>
</div>
<WinningResultDialog
open={result !== null}
data={result}
query={normalizedTicketNo}
onOpenChange={(open) => {
if (!open) setResult(null);
}}
onCheckAnother={() => {
setResult(null);
setTicketNo("");
}}
/>
{drawNo ? (
<DrawWinningCheckPanel drawNo={drawNo} collapsible={false} />
) : (
<Skeleton className="h-56 rounded-2xl" />
)}
</PlayerPanel>
);
}
function WinningResultDialog({
open,
data,
query,
onOpenChange,
onCheckAnother,
}: {
open: boolean;
data: WinningCheckResult | null;
query: string;
onOpenChange: (open: boolean) => void;
onCheckAnother: () => void;
}) {
const { t } = useTranslation("player");
const totalWin = (data?.match.total_win_minor ?? 0) + (data?.match.total_jackpot_win_minor ?? 0);
const isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0;
const firstTicket = useMemo(() => data?.tickets[0] ?? null, [data]);
const { activeCurrency } = useActivePlayerCurrency();
const currency = firstTicket?.currency_code ?? activeCurrency;
if (!data) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="flex max-h-[calc(100dvh-24px)] flex-col gap-0 overflow-hidden rounded-2xl border-[#e4ebf6] bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.28)] sm:max-w-md"
>
<div className="relative shrink-0 px-5 pt-4">
<button
type="button"
onClick={() => onOpenChange(false)}
className="absolute right-3 top-3 z-10 inline-flex size-9 items-center justify-center rounded-full text-slate-500 hover:bg-slate-100"
aria-label={t("actions.close")}
>
<XIcon className="size-5" />
</button>
<DialogHeader className="items-center text-center">
<div className="flex size-16 items-center justify-center rounded-full border-4 border-emerald-100 bg-white text-emerald-600">
<CheckCircle2 className="size-11" />
</div>
<DialogTitle className="mt-3 text-xl font-black text-slate-950">
{isWon
? t("results.check.winTitle")
: t("results.check.noWinTitle")}
</DialogTitle>
<DialogDescription className="text-sm text-slate-500">
{t("results.check.ticketNumber")}
</DialogDescription>
</DialogHeader>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
<div className="mx-auto mt-3 w-fit rounded-xl bg-emerald-50 px-8 py-2 font-mono text-lg font-black text-[#0a8f3e]">
{query}
</div>
<div className="mt-5 grid grid-cols-2 overflow-hidden rounded-xl border border-emerald-100 bg-emerald-50 text-center">
<div className="border-r border-emerald-100 px-3 py-4">
<p className="text-xs font-medium text-slate-500">
{t("results.check.match")}
</p>
<p className="mt-2 text-lg font-black text-[#0a8f3e]">
{firstTicket ? playLabel(firstTicket.play_code, t) : isWon ? t("orders.hit") : "—"}
</p>
</div>
<div className="px-3 py-4">
<p className="text-xs font-medium text-slate-500">
{t("results.check.amount")}
</p>
<p className="mt-2 font-mono text-lg font-black text-[#0a8f3e]">
{formatMinorAsCurrency(totalWin, currency)}
</p>
</div>
</div>
<div className="mt-5 rounded-xl border border-[#e8eef7] bg-white p-4 text-sm">
<p className="font-black text-slate-950">
{t("results.check.drawInfo")}
</p>
<div className="mt-2.5 grid grid-cols-2 gap-3 text-slate-500">
<div>
<p className="text-xs">{t("results.check.issueNo")}</p>
<p className="mt-1 font-mono font-black text-slate-900">{data.draw.draw_no}</p>
</div>
<div>
<p className="text-xs">{t("results.businessDate")}</p>
<p className="mt-1 font-semibold text-slate-900">{data.draw.business_date}</p>
</div>
<div className="col-span-2">
<p className="text-xs">{t("results.drawTime", { time: "" }).replace(":", "").trim()}</p>
<p className="mt-1 font-semibold text-slate-900">
{formatPlayerInstant(data.draw.draw_time_iso ?? data.draw.draw_time ?? null)}
</p>
</div>
</div>
</div>
<div className="mt-5 grid gap-3">
<Button
type="button"
nativeButton={false}
className="h-12 rounded-xl bg-[#07459f] text-base font-black text-white hover:bg-[#063b88]"
render={<Link href={`/orders?draw_no=${encodeURIComponent(data.draw.draw_no)}&number=${encodeURIComponent(query)}`} />}
>
{t("results.check.viewBetDetails")}
</Button>
<Button
type="button"
variant="outline"
className="h-12 rounded-xl border-[#ff3650] text-base font-black text-[#e5002c] hover:bg-[#fff5f6]"
onClick={onCheckAnother}
>
<RefreshCw className="size-5" />
{t("results.check.checkAnother")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

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

View File

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

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,24 +241,14 @@ export function LogRow({
</div>
<div className="mt-3 flex items-center justify-between rounded-xl bg-[#f8fbff] px-3 py-2 text-xs">
{creditMode && item.affects_available_credit === false ? (
<span className="font-medium text-slate-500">
{t("wallet.creditPaymentRecordOnly", {
defaultValue: "账期收付记账,不计入可用信用",
})}
</span>
) : (
<>
<span className="font-semibold text-slate-500">
{creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")}
</span>
<span className="font-mono font-black tabular-nums text-[#32518d]">
{item.balance_after != null
? formatMinorAsCurrency(item.balance_after, ccy)
: "—"}
</span>
</>
)}
<span className="font-semibold text-slate-500">
{creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")}
</span>
<span className="font-mono font-black tabular-nums text-[#32518d]">
{item.balance_after != null
? formatMinorAsCurrency(item.balance_after, ccy)
: "—"}
</span>
</div>
</li>
);

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 Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -13,7 +14,7 @@ import {
TransferInDialog,
TransferOutDialog,
} from "@/features/wallet/wallet-transfer-dialogs";
import { WalletPendingReconcileBanner } from "@/features/wallet/wallet-pending-reconcile-banner";
import { WalletPendingReconcileSection } from "@/features/wallet/wallet-pending-reconcile-section";
import { PlayerMoneyDisplay } from "@/components/player-money-display";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile";
@@ -28,9 +29,18 @@ import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-l
const WALLET_LOGS_PAGE_SIZE = 10;
function scrollToWalletSection(id: string): void {
if (typeof document === "undefined") return;
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
export function WalletScreen() {
const router = useRouter();
const searchParams = useSearchParams();
const { activeCurrency: currency } = useActivePlayerCurrency();
const { t } = useTranslation("player");
const [transferInOpen, setTransferInOpen] = useState(false);
const [transferOutOpen, setTransferOutOpen] = useState(false);
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [logs, setLogs] = useState<WalletLogsData | null>(null);
const [filter, setFilter] = useState("");
@@ -39,8 +49,47 @@ export function WalletScreen() {
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const filterInitializedRef = useRef(false);
const prevFilterRef = useRef("");
const actionDeepLinkHandledRef = useRef(false);
const sectionDeepLinkHandledRef = useRef(false);
const profile = usePlayerSessionStore((s) => s.profile);
const isCreditPlayer = isCreditFundingPlayer(balance) || isCreditFundingPlayer(profile);
useEffect(() => {
if (actionDeepLinkHandledRef.current || loading) return;
const action = searchParams.get("action");
if (action !== "transfer-in" && action !== "transfer-out") return;
actionDeepLinkHandledRef.current = true;
if (!isCreditPlayer) {
if (action === "transfer-in") {
setTransferInOpen(true);
} else {
setTransferOutOpen(true);
}
}
router.replace("/wallet", { scroll: false });
}, [isCreditPlayer, loading, router, searchParams]);
useEffect(() => {
if (sectionDeepLinkHandledRef.current || loading) return;
const section = searchParams.get("section");
if (section !== "logs" && section !== "pending") return;
sectionDeepLinkHandledRef.current = true;
const targetId =
section === "logs" || (section === "pending" && isCreditPlayer)
? "wallet-logs"
: "wallet-pending";
const timer = window.setTimeout(() => {
scrollToWalletSection(targetId);
router.replace("/wallet", { scroll: false });
}, 120);
return () => window.clearTimeout(timer);
}, [isCreditPlayer, loading, router, searchParams]);
const loadLogs = useCallback(async (targetPage = 1, append = false) => {
const nextLogs = await getWalletLogs({
@@ -164,8 +213,6 @@ export function WalletScreen() {
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
const profile = usePlayerSessionStore((s) => s.profile);
const isCreditPlayer = isCreditFundingPlayer(balance) || isCreditFundingPlayer(profile);
const displayMinor = isCreditPlayer
? Number(balance?.available_balance ?? 0)
: Number(balance?.balance ?? 0);
@@ -261,17 +308,8 @@ export function WalletScreen() {
</div>
</section>
{isCreditPlayer ? (
<p className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
{t("wallet.creditNoTransferHint", {
defaultValue:
"由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
})}
</p>
) : (
<>
<WalletPendingReconcileBanner />
<div className="grid grid-cols-2 gap-3">
{!isCreditPlayer ? (
<div className="grid grid-cols-2 gap-3">
<TransferInDialog
idPrefix="wallet-"
currency={currency}
@@ -285,6 +323,8 @@ export function WalletScreen() {
triggerVariant="hall"
triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })}
triggerClassName="h-14 rounded-2xl text-base font-black"
open={transferInOpen}
onOpenChange={setTransferInOpen}
/>
<TransferOutDialog
idPrefix="wallet-"
@@ -294,11 +334,17 @@ export function WalletScreen() {
triggerVariant="hall"
triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })}
triggerClassName="h-14 rounded-2xl text-base font-black"
open={transferOutOpen}
onOpenChange={setTransferOutOpen}
/>
</div>
</>
)}
) : null}
<WalletPendingReconcileSection
onViewLogs={() => scrollToWalletSection("wallet-logs")}
/>
<div id="wallet-logs" className="scroll-mt-3">
<WalletLogsBlock
creditMode={isCreditPlayer}
logs={logs}
@@ -311,6 +357,7 @@ export function WalletScreen() {
onFilterChange={setFilter}
currency={currency}
/>
</div>
</div>
</PlayerPanel>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,3 +7,6 @@ export const playerViewportColumnClass = "mx-auto w-full max-w-[480px]" as const
/** 贴底/贴顶固定栏:与内容列同宽并居中(桌面不铺满整屏) */
export const playerViewportFixedBarClass =
"fixed left-1/2 z-50 w-full max-w-[480px] -translate-x-1/2" as const;
/** 刘海屏 / 全屏 WebView 顶部安全区 */
export const playerSafeAreaTopClass = "pt-[env(safe-area-inset-top,0px)]" as const;